feat: wire settings profile updates

Persist profile and workspace setting edits, show live billing usage, and remove the unused bulk email API helper.
This commit is contained in:
Matthew Meszaros
2026-06-06 07:50:26 +02:00
parent ce8181de0e
commit aa4f43b284
8 changed files with 127 additions and 63 deletions
+6 -4
View File
@@ -27,6 +27,7 @@ import useValidateDiscountCode from "@/lib/api/hooks/app/subscription/useValidat
import useCreateCheckoutSession from "@/lib/api/hooks/app/subscription/useCreateCheckoutSession";
import useChangePlan from "@/lib/api/hooks/app/subscription/useChangePlan";
import usePlans from "@/lib/api/hooks/app/subscription/usePlans";
import useUsageOverview from "@/lib/api/hooks/app/analytics/useUsageOverview";
import { useAppStore } from "@/stores";
import type { AppError } from "@/lib/api/client/normalizeError";
import type DiscountPreview from "@/lib/api/models/app/subscription/DiscountPreview";
@@ -47,6 +48,7 @@ export default function BillingSettingsPage() {
const checkout = useCreateCheckoutSession();
const changePlan = useChangePlan();
const plansQuery = usePlans();
const usage = useUsageOverview().data;
const [codeInput, setCodeInput] = React.useState("");
const [applied, setApplied] = React.useState<DiscountPreview | null>(null);
const [billingInterval, setBillingInterval] =
@@ -352,17 +354,17 @@ export default function BillingSettingsPage() {
eyebrow="Usage"
description="What this workspace is consuming this period."
>
<UsageRow label="Mailboxes" current={0} max={"Unlimited"} />
<UsageRow label="Mailboxes" current={usage?.email_accounts.total ?? 0} max={"Unlimited"} />
<UsageRow
label="Sends / day"
current={0}
label="Sends this period"
current={usage?.campaigns.emails_sent ?? 0}
max={
currentPlan.sendsPerDay === Number.POSITIVE_INFINITY
? "Custom"
: currentPlan.sendsPerDay
}
/>
<UsageRow label="Warmup" current={0} max={"Unlimited"} />
<UsageRow label="Warmup" current={usage?.email_accounts.in_warmup ?? 0} max={"Unlimited"} />
<UsageRow
label="Dedicated IPs"
current={currentPlan.id === "business" ? 1 : 0}
+24 -3
View File
@@ -1,8 +1,11 @@
import React from "react";
import toast from "react-hot-toast";
import { useUserProfile } from "@/hooks/context/user";
import { TextInput } from "@/components/ui/field";
import { TopbarAction } from "@/components/layout/Page";
import { comingSoon } from "@/lib/helper/comingSoon";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import useUpdateProfile from "@/lib/api/hooks/auth/useUpdateProfile";
import { AvatarUploader } from "@/components/app/avatar/AvatarUploader";
import {
useDeleteUserAvatar,
@@ -19,6 +22,24 @@ export default function ProfileSettingsPage() {
const uploadAvatar = useUploadUserAvatar();
const removeAvatar = useDeleteUserAvatar();
const updateProfile = useUpdateProfile();
async function save() {
if (!dirty || updateProfile.isPending) return;
if (!firstName.trim() || !lastName.trim()) {
toast.error("First and last name are required.");
return;
}
try {
await updateProfile.mutateAsync({
first_name: firstName.trim(),
last_name: lastName.trim(),
});
toast.success("Profile saved");
} catch (err) {
toast.error(buildError(err as AppError));
}
}
return (
<SectionShell
@@ -36,8 +57,8 @@ export default function ProfileSettingsPage() {
>
Discard
</TopbarAction>
<TopbarAction onClick={() => comingSoon("Profile editing")}>
Save profile
<TopbarAction onClick={save} disabled={updateProfile.isPending}>
{updateProfile.isPending ? "Saving…" : "Save profile"}
</TopbarAction>
</>
) : null
+23 -19
View File
@@ -1,8 +1,11 @@
import React from "react";
import toast from "react-hot-toast";
import { useAppStore } from "@/stores";
import { TextInput } from "@/components/ui/field";
import { TopbarAction } from "@/components/layout/Page";
import { comingSoon } from "@/lib/helper/comingSoon";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import useUpdateOrganization from "@/lib/api/hooks/app/organizations/useUpdateOrganization";
import { AvatarUploader } from "@/components/app/avatar/AvatarUploader";
import {
useDeleteOrgAvatar,
@@ -13,19 +16,28 @@ import { Row, Section, SectionShell, ToggleRow } from "../_components/SectionShe
export default function WorkspaceSettingsPage() {
const currentOrg = useAppStore((s) => s.currentOrganization);
const [name, setName] = React.useState(currentOrg?.name ?? "");
const [domain, setDomain] = React.useState("");
const uploadOrgAvatar = useUploadOrgAvatar();
const removeOrgAvatar = useDeleteOrgAvatar();
const updateOrg = useUpdateOrganization();
// Avatar changes are committed immediately by the uploader, so
// they don't count toward the dirty flag — only fields that need
// a "Save workspace" action do.
const dirty = name !== (currentOrg?.name ?? "") || domain !== "";
const dirty = name.trim() !== (currentOrg?.name ?? "");
function discard() {
setName(currentOrg?.name ?? "");
setDomain("");
}
async function save() {
if (!dirty || updateOrg.isPending) return;
try {
await updateOrg.mutateAsync({ name: name.trim() });
toast.success("Workspace saved");
} catch (err) {
toast.error(buildError(err as AppError));
}
}
return (
@@ -38,8 +50,8 @@ export default function WorkspaceSettingsPage() {
<TopbarAction variant="ghost" onClick={discard}>
Discard
</TopbarAction>
<TopbarAction onClick={() => comingSoon("Workspace settings")}>
Save workspace
<TopbarAction onClick={save} disabled={updateOrg.isPending}>
{updateOrg.isPending ? "Saving…" : "Save workspace"}
</TopbarAction>
</>
) : null
@@ -87,23 +99,15 @@ export default function WorkspaceSettingsPage() {
eyebrow="Sending defaults"
description="Used by new campaigns unless overridden."
>
<Row label="Default sender domain" description="Outgoing mail uses this domain by default.">
<TextInput
value={domain}
onChange={setDomain}
placeholder="company.com"
className="w-full max-w-[280px]"
/>
</Row>
<Row
label="Default daily cap"
description="Built-in safety: 50/day per cold mailbox. Raise per-campaign if needed."
>
<TextInput
value="50"
onChange={() => undefined}
type="number"
className="w-full max-w-[120px]"
<input
type="text"
value="50 / day"
disabled
className="w-full max-w-[120px] h-7 px-2.5 rounded-md border border-slate-200 bg-slate-50 text-[12px] text-slate-500"
/>
</Row>
</Section>
@@ -1,12 +0,0 @@
import Request from "../../Request";
import type AddEmail from "@/lib/api/models/app/emails/AddEmail";
import type Inbox from "@/lib/api/models/app/emails/Inbox";
export default async function addEmailBulk(emails: AddEmail[]): Promise<Inbox[]> {
return await Request<Inbox[]>({
method: "POST",
url: `/emails/other/bulk`,
data: emails,
authorization: true,
})
}
@@ -0,0 +1,15 @@
import Request from "../Request";
interface UpdateProfileData {
first_name: string;
last_name: string;
}
export default async function updateProfile(data: UpdateProfileData): Promise<void> {
await Request<void>({
method: "PATCH",
url: "/auth/me",
data,
authorization: true,
});
}
@@ -1,16 +0,0 @@
import addEmailBulk from "@/lib/api/client/app/emails/addEmailBulk";
import type AddEmail from "@/lib/api/models/app/emails/AddEmail";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export default function useAddEmailBulk() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (emails: AddEmail[]) => addEmailBulk(emails),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["emails", "list"]
})
}
})
}
@@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import updateProfile from "../../client/auth/updateProfile";
interface UpdateProfileData {
first_name: string;
last_name: string;
}
export default function useUpdateProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: UpdateProfileData) => updateProfile(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["auth", "me"] });
},
});
}
@@ -1,10 +1,43 @@
export default interface UsageOverview {
emails_sent: number
emails_limit: number
contacts_count: number
contacts_limit: number
campaigns_count: number
campaigns_limit: number
storage_used: number
storage_limit: number
// Mirrors the backend models.UsageOverview (GET /analytics/usage) — a nested
// snapshot of what the workspace is consuming this period.
export interface AccountsUsage {
total: number;
active: number;
in_warmup: number;
with_errors: number;
}
export interface CampaignsUsage {
total: number;
active: number;
paused: number;
draft: number;
emails_sent: number;
}
export interface ContactsUsage {
total: number;
subscribed: number;
added_today: number;
}
export interface EndpointUsage {
endpoint: string;
calls: number;
}
export interface APIUsage {
total_calls: number;
daily_limit: number;
top_endpoints: EndpointUsage[];
}
export default interface UsageOverview {
user_id: string;
period: string;
email_accounts: AccountsUsage;
campaigns: CampaignsUsage;
contacts: ContactsUsage;
api: APIUsage;
}