diff --git a/web/src/app/app/settings/billing/page.tsx b/web/src/app/app/settings/billing/page.tsx index 56603a8b..da3cbefb 100644 --- a/web/src/app/app/settings/billing/page.tsx +++ b/web/src/app/app/settings/billing/page.tsx @@ -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(null); const [billingInterval, setBillingInterval] = @@ -352,17 +354,17 @@ export default function BillingSettingsPage() { eyebrow="Usage" description="What this workspace is consuming this period." > - + - + Discard - comingSoon("Profile editing")}> - Save profile + + {updateProfile.isPending ? "Saving…" : "Save profile"} ) : null diff --git a/web/src/app/app/settings/workspace/page.tsx b/web/src/app/app/settings/workspace/page.tsx index a3eae98a..e17cccd3 100644 --- a/web/src/app/app/settings/workspace/page.tsx +++ b/web/src/app/app/settings/workspace/page.tsx @@ -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() { Discard - comingSoon("Workspace settings")}> - Save workspace + + {updateOrg.isPending ? "Saving…" : "Save workspace"} ) : null @@ -87,23 +99,15 @@ export default function WorkspaceSettingsPage() { eyebrow="Sending defaults" description="Used by new campaigns unless overridden." > - - - - undefined} - type="number" - className="w-full max-w-[120px]" + diff --git a/web/src/lib/api/client/app/emails/addEmailBulk.ts b/web/src/lib/api/client/app/emails/addEmailBulk.ts deleted file mode 100644 index 0226427b..00000000 --- a/web/src/lib/api/client/app/emails/addEmailBulk.ts +++ /dev/null @@ -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 { - return await Request({ - method: "POST", - url: `/emails/other/bulk`, - data: emails, - authorization: true, - }) -} diff --git a/web/src/lib/api/client/auth/updateProfile.ts b/web/src/lib/api/client/auth/updateProfile.ts new file mode 100644 index 00000000..ec67a6f7 --- /dev/null +++ b/web/src/lib/api/client/auth/updateProfile.ts @@ -0,0 +1,15 @@ +import Request from "../Request"; + +interface UpdateProfileData { + first_name: string; + last_name: string; +} + +export default async function updateProfile(data: UpdateProfileData): Promise { + await Request({ + method: "PATCH", + url: "/auth/me", + data, + authorization: true, + }); +} diff --git a/web/src/lib/api/hooks/app/emails/useAddEmailBulk.ts b/web/src/lib/api/hooks/app/emails/useAddEmailBulk.ts deleted file mode 100644 index ce257902..00000000 --- a/web/src/lib/api/hooks/app/emails/useAddEmailBulk.ts +++ /dev/null @@ -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"] - }) - } - }) -} diff --git a/web/src/lib/api/hooks/auth/useUpdateProfile.ts b/web/src/lib/api/hooks/auth/useUpdateProfile.ts new file mode 100644 index 00000000..3c6db58b --- /dev/null +++ b/web/src/lib/api/hooks/auth/useUpdateProfile.ts @@ -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"] }); + }, + }); +} diff --git a/web/src/lib/api/models/app/analytics/UsageOverview.ts b/web/src/lib/api/models/app/analytics/UsageOverview.ts index bd47ee4d..db3b9901 100644 --- a/web/src/lib/api/models/app/analytics/UsageOverview.ts +++ b/web/src/lib/api/models/app/analytics/UsageOverview.ts @@ -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; }