// Community profile plugin — GET + PUT /v1/community/profile.
//
// Read-only fields (community_did, created_at) render as plain
// text. Editable fields (name, description, language, contact
// email, public url, logo url) are inputs in a single form. The
// form tracks "dirty" state and only PUTs fields that changed.
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Field } from "@/components/Field";
import { getJson, putJson } from "@/lib/api";
const TRUST_TASK =
"https://trusttasks.org/openvtc/vtc/community/profile/manage/1.0";
// GET /v1/community/profile wire shape: the persisted profile fields
// are flattened at the top level (server uses `#[serde(flatten)]`),
// alongside the live `registryStatus`. Do not look for a nested
// `profile` object — there isn't one.
interface ProfileResponse {
communityDid: string;
name: string;
description: string;
logoUrl: string | null;
publicUrl: string | null;
contactEmail: string | null;
language: string;
createdAt: string;
extensions: unknown;
registryStatus: string;
}
interface ProfileUpdateRequest {
name?: string;
description?: string;
logoUrl?: string | null;
publicUrl?: string | null;
contactEmail?: string | null;
language?: string;
}
async function getProfile(): Promise<ProfileResponse> {
return getJson<ProfileResponse>("/v1/community/profile", {
trustTask: TRUST_TASK,
});
}
async function putProfile(body: ProfileUpdateRequest): Promise<unknown> {
// PUT returns `{ profile, fieldsChanged }` (nested). We don't read
// it — `onSuccess` invalidates the query, which refetches via
// `getProfile` and seeds the form from the flat GET shape.
return putJson<unknown>("/v1/community/profile", body, {
trustTask: TRUST_TASK,
});
}
export function Profile() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["profile"],
queryFn: getProfile,
});
// Form state is a separate copy of the profile so editing
// doesn't mutate the cached query result.
const [draft, setDraft] = useState<EditableFields | null>(null);
// When the query result first loads (or changes from outside),
// seed the draft. We don't overwrite an in-flight edit.
useEffect(() => {
if (!query.data) return;
if (draft !== null) return;
setDraft({
name: query.data.name,
description: query.data.description,
logoUrl: query.data.logoUrl ?? "",
publicUrl: query.data.publicUrl ?? "",
contactEmail: query.data.contactEmail ?? "",
language: query.data.language,
});
}, [query.data, draft]);
const mutation = useMutation({
mutationFn: putProfile,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["profile"] });
setDraft(null);
},
});
if (query.isPending) {
return (
<section className="page">
<h2>Community profile</h2>
<p>Loading…</p>
</section>
);
}
if (query.error) {
return (
<section className="page">
<h2>Community profile</h2>
<section className="card error">
<h3>Failed to load profile</h3>
<p>{(query.error as Error).message}</p>
</section>
</section>
);
}
if (!query.data || !draft) {
return null;
}
const original = query.data;
const dirty = isDirty(original, draft);
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!dirty) return;
mutation.mutate(buildPatch(original, draft));
};
const onCancel = () => {
setDraft(null);
mutation.reset();
};
return (
<section className="page">
<h2>Community profile</h2>
<form onSubmit={onSubmit} className="form-stack">
<section className="card">
<h3>Identity</h3>
<dl>
<dt>Community DID</dt>
<dd>
<code>{original.communityDid}</code>
</dd>
<dt>Created</dt>
<dd>
<code>{original.createdAt}</code>
</dd>
</dl>
</section>
<section className="card">
<h3>Editable</h3>
<Field label="Name">
<input
type="text"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
required
/>
</Field>
<Field label="Description">
<textarea
value={draft.description}
onChange={(e) =>
setDraft({ ...draft, description: e.target.value })
}
rows={3}
/>
</Field>
<Field label="Public URL">
<input
type="url"
placeholder="https://community.example.com"
value={draft.publicUrl}
onChange={(e) =>
setDraft({ ...draft, publicUrl: e.target.value })
}
/>
</Field>
<Field label="Logo URL">
<input
type="url"
placeholder="https://community.example.com/logo.svg"
value={draft.logoUrl}
onChange={(e) => setDraft({ ...draft, logoUrl: e.target.value })}
/>
</Field>
<Field label="Contact email">
<input
type="email"
placeholder="ops@community.example.com"
value={draft.contactEmail}
onChange={(e) =>
setDraft({ ...draft, contactEmail: e.target.value })
}
/>
</Field>
<Field label="Language (BCP 47)">
<input
type="text"
placeholder="en"
value={draft.language}
onChange={(e) =>
setDraft({ ...draft, language: e.target.value })
}
required
/>
</Field>
</section>
{mutation.error && (
<section className="card error">
<h3>Save failed</h3>
<p>{(mutation.error as Error).message}</p>
</section>
)}
<div className="form-actions">
<button
type="submit"
className="primary"
disabled={!dirty || mutation.isPending}
>
{mutation.isPending ? "Saving…" : "Save changes"}
</button>
<button
type="button"
className="secondary"
onClick={onCancel}
disabled={!dirty || mutation.isPending}
>
Discard changes
</button>
</div>
</form>
</section>
);
}
interface EditableFields {
name: string;
description: string;
logoUrl: string;
publicUrl: string;
contactEmail: string;
language: string;
}
function isDirty(
original: ProfileResponse,
draft: EditableFields,
): boolean {
if (original.name !== draft.name) return true;
if (original.description !== draft.description) return true;
if ((original.logoUrl ?? "") !== draft.logoUrl) return true;
if ((original.publicUrl ?? "") !== draft.publicUrl) return true;
if ((original.contactEmail ?? "") !== draft.contactEmail) return true;
if (original.language !== draft.language) return true;
return false;
}
function buildPatch(
original: ProfileResponse,
draft: EditableFields,
): ProfileUpdateRequest {
const patch: ProfileUpdateRequest = {};
if (original.name !== draft.name) patch.name = draft.name;
if (original.description !== draft.description) {
patch.description = draft.description;
}
// Empty string in the form means "clear the field" → send null.
const norm = (s: string) => (s.trim() === "" ? null : s);
if ((original.logoUrl ?? "") !== draft.logoUrl) {
patch.logoUrl = norm(draft.logoUrl);
}
if ((original.publicUrl ?? "") !== draft.publicUrl) {
patch.publicUrl = norm(draft.publicUrl);
}
if ((original.contactEmail ?? "") !== draft.contactEmail) {
patch.contactEmail = norm(draft.contactEmail);
}
if (original.language !== draft.language) {
patch.language = draft.language;
}
return patch;
}