mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-26 08:00:42 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user