This commit is contained in:
zhu
2026-05-11 10:05:33 +08:00
parent bf6b44a950
commit 3d2dc708cc
19 changed files with 4164 additions and 3 deletions

2070
BACKEND_API_DOCUMENTATION.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,13 @@ export async function setBrandApi(name: string, timezone: string) {
}) })
} }
export async function updateBrandApi(name: string, timezone: string) {
return await request.patch("/me/brand", {
name,
timezone,
})
}
/** /**
* 绑定telegram的id * 绑定telegram的id
* @param brandId * @param brandId
@@ -35,9 +42,46 @@ export async function brandTelegramScan(brandId: string) {
return await request.post(`/brands/${brandId}/telegram/pair`) return await request.post(`/brands/${brandId}/telegram/pair`)
} }
export async function updateNotificationsApi(
brandId: string,
data: { morningBriefHour: number; eveningRecapHour: number },
) {
return await request.patch(`/brands/${brandId}/notifications`, data)
}
export async function addCompetitorApi(brandId: string, productUrl: string, label?: string) {
return await request.post(`/brands/${brandId}/competitors`, {
productUrl,
label: label || null,
})
}
export async function deleteCompetitorApi(brandId: string, competitorId: string) {
return await request.delete(`/brands/${brandId}/competitors/${competitorId}`)
}
export async function updateThresholdsApi(
brandId: string,
thresholds: Record<string, number | null>,
) {
return await request.patch(`/brands/${brandId}/thresholds`, {
thresholds,
})
}
export async function changePasswordApi(password: string) {
return await request.patch("/auth/password", {
password,
})
}
export async function deleteAccountApi() {
return await request.delete("/me/account")
}
/** /**
* 获取设置信息 * 获取设置信息
*/ */
export async function getSettingApi() { export async function getSettingApi() {
return await request.get("/dashboard/settings") return await request.get("/dashboard/settings")
} }

145
src/api/settings.ts Normal file
View File

@@ -0,0 +1,145 @@
import request from "@/utils/reqeust";
/**
* Settings page domain types. The shape mirrors BACKEND_API_DOCUMENTATION.md
* for /dashboard/settings and the related brand settings endpoints.
*/
export type ThresholdKey =
| "SALES_01_GMV_DECLINE"
| "SALES_01_MIN_ORDERS"
| "SALES_02_ZERO_HOURS"
| "SALES_04_DAYS_LEFT"
| "SALES_05_RATING_MAX"
| "AD_01_MIN_SPEND"
| "AD_02_KEYWORD_SHARE"
| "AD_03_ROAS_MIN"
| "COMP_01_GAP"
| "COMP_02_DROP";
export type CompetitorRow = {
id: string;
product_url: string;
label: string | null;
created_at: string | null;
};
export type SettingsBrand = {
id: string;
name: string;
timezone: string;
thresholds: Partial<Record<ThresholdKey, number>> | null;
telegram_chat_id: string | null;
morning_brief_hour: number;
evening_recap_hour: number;
platform_account_id: string | null;
platform_account_id_bound_at: string | null;
stores: Array<{ id: string }> | null;
};
export type SettingsResponse = {
user: {
id: string;
email: string | null;
};
brand: SettingsBrand | null;
manualUsedToday: number;
lastAutoScanAt: string | null;
competitors: CompetitorRow[];
};
export type TelegramPairResponse = {
deeplink?: string;
ttlSeconds?: number;
};
export type AddCompetitorResponse = {
competitor?: CompetitorRow;
};
/**
* Loads the full settings payload used by /dashboard/setting.
*/
export async function getSettingsApi() {
return await request.get("/dashboard/settings") as unknown as SettingsResponse;
}
/**
* Updates the current user's first brand profile.
*/
export async function updateSettingsBrandApi(name: string, timezone: string) {
return await request.patch("/me/brand", {
name,
timezone,
});
}
/**
* Saves the two scheduled report hours for a brand.
*/
export async function updateSettingsNotificationsApi(
brandId: string,
data: { morningBriefHour: number; eveningRecapHour: number },
) {
return await request.patch(`/brands/${brandId}/notifications`, data);
}
/**
* Creates a Telegram pairing token and deeplink.
*/
export async function createTelegramPairApi(brandId: string) {
return await request.post(`/brands/${brandId}/telegram/pair`) as unknown as TelegramPairResponse;
}
/**
* Manually saves or clears the Telegram chat id.
*/
export async function updateTelegramChatApi(brandId: string, chatId: string | null) {
return await request.patch(`/brands/${brandId}/telegram`, {
chatId,
});
}
/**
* Adds a tracked Shopee competitor URL.
*/
export async function addSettingsCompetitorApi(brandId: string, productUrl: string, label?: string) {
return await request.post(`/brands/${brandId}/competitors`, {
productUrl,
label: label || null,
}) as unknown as AddCompetitorResponse;
}
/**
* Removes a tracked competitor. The backend treats this as idempotent.
*/
export async function deleteSettingsCompetitorApi(brandId: string, competitorId: string) {
return await request.delete(`/brands/${brandId}/competitors/${competitorId}`);
}
/**
* Saves rule threshold overrides. Null values remove an override.
*/
export async function updateSettingsThresholdsApi(
brandId: string,
thresholds: Record<string, number | null>,
) {
return await request.patch(`/brands/${brandId}/thresholds`, {
thresholds,
});
}
/**
* Changes the signed-in user's password.
*/
export async function changeSettingsPasswordApi(password: string) {
return await request.patch("/auth/password", {
password,
});
}
/**
* Permanently deletes the account when backend subscription checks allow it.
*/
export async function deleteSettingsAccountApi() {
return await request.delete("/me/account");
}

View File

@@ -87,14 +87,16 @@ const NavTabs = () => {
const pathname = usePathname(); const pathname = usePathname();
const tabs = [ const tabs = [
{href: "/dashboard", label: "Dashboard", icon: LayoutGrid}, {href: "/dashboard", label: "Dashboard", icon: LayoutGrid},
{href: "/dashboard/settings", label: "Settings", icon: SettingsIcon}, {href: "/dashboard/setting", label: "Settings", icon: SettingsIcon},
{href: "/dashboard/billing", label: "Billing", icon: CreditCard}, {href: "/dashboard/billing", label: "Billing", icon: CreditCard},
]; ];
return ( return (
<nav className="flex items-center gap-0.5" aria-label="Dashboard navigation"> <nav className="flex items-center gap-0.5" aria-label="Dashboard navigation">
{tabs.map(item => { {tabs.map(item => {
const Icon = item.icon; const Icon = item.icon;
const isActive = pathname == item.href; const isActive = item.href === "/dashboard"
? pathname === item.href
: pathname === item.href || pathname.startsWith(`${item.href}/`);
return ( return (
<Link <Link

View File

@@ -0,0 +1,230 @@
"use client";
import {useState} from "react";
import {useRouter} from "next/navigation";
import {Eye, EyeOff} from "lucide-react";
import {changeSettingsPasswordApi, deleteSettingsAccountApi} from "@/api/settings";
import useUserStore from "@/store/user";
import {FormFooter, Feedback} from "./feedback";
import {getErrorMessage} from "./utils";
/**
* Account management actions for the settings page.
*/
export function AccountForm({userEmail}: { userEmail: string }) {
return (
<div className="space-y-4">
<ChangePasswordCard/>
<DeleteAccountCard userEmail={userEmail}/>
</div>
);
}
function ChangePasswordCard() {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const [flash, setFlash] = useState("");
const [error, setError] = useState("");
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setFlash("");
setError("");
if (password.length < 8) {
setError("Password must be at least 8 characters.");
return;
}
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setBusy(true);
try {
await changeSettingsPasswordApi(password);
setFlash("Password updated.");
setPassword("");
setConfirm("");
} catch (err) {
setError(getErrorMessage(err, "Password update failed."));
} finally {
setBusy(false);
}
}
return (
<form onSubmit={handleSubmit} className="rounded-lg border border-border bg-card p-5 shadow-sm">
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold tracking-tight">Change password</h3>
<span className="text-[11px] text-muted-foreground">8 characters minimum</span>
</div>
<div className="mt-4 space-y-3">
<PasswordField
id="new-password"
label="New password"
value={password}
show={show}
onChange={setPassword}
onToggleShow={() => setShow((value) => !value)}
withToggle
/>
<PasswordField
id="confirm-password"
label="Confirm new password"
value={confirm}
show={show}
onChange={setConfirm}
/>
</div>
<FormFooter flash={flash} error={error}>
<button
type="submit"
disabled={busy || password.length < 8 || password !== confirm}
className="inline-flex h-9 items-center justify-center rounded-md bg-foreground px-4 text-xs font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-40"
>
{busy ? "Saving..." : "Update password"}
</button>
</FormFooter>
</form>
);
}
function PasswordField({
id,
label,
value,
show,
onChange,
onToggleShow,
withToggle,
}: {
id: string;
label: string;
value: string;
show: boolean;
onChange: (value: string) => void;
onToggleShow?: () => void;
withToggle?: boolean;
}) {
return (
<div className="space-y-1.5">
<label htmlFor={id} className="text-xs font-medium">{label}</label>
<div className="relative">
<input
id={id}
type={show ? "text" : "password"}
autoComplete="new-password"
required
minLength={8}
value={value}
onChange={(event) => onChange(event.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{withToggle && onToggleShow && (
<button
type="button"
onClick={onToggleShow}
className="absolute right-2 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={show ? "Hide password" : "Show password"}
>
{show ? <EyeOff className="h-3.5 w-3.5" aria-hidden/> :
<Eye className="h-3.5 w-3.5" aria-hidden/>}
</button>
)}
</div>
</div>
);
}
function DeleteAccountCard({userEmail}: { userEmail: string }) {
const router = useRouter();
const userStore = useUserStore();
const [confirmText, setConfirmText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [armed, setArmed] = useState(false);
const ready = confirmText.trim().toLowerCase() === userEmail.toLowerCase();
async function handleDelete() {
if (!ready || busy) return;
setBusy(true);
setError("");
try {
await deleteSettingsAccountApi();
userStore.logout();
router.push("/login");
} catch (err) {
setError(getErrorMessage(err, "Delete failed."));
setBusy(false);
}
}
return (
<div className="rounded-lg border border-rose-200 bg-rose-50/40 p-5 shadow-sm">
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold tracking-tight text-rose-900">Delete account</h3>
<span className="text-[11px] text-rose-700/80">Permanent - cannot be undone</span>
</div>
<p className="mt-1 text-xs leading-relaxed text-rose-900/85">
Removes your brand, scans, alerts, and extension pairing. Cancel your subscription in Billing first.
</p>
{!armed ? (
<button
type="button"
onClick={() => setArmed(true)}
className="mt-4 inline-flex h-9 items-center justify-center rounded-md border border-rose-300 bg-background px-4 text-xs font-medium text-rose-800 transition-colors hover:bg-rose-100"
>
I want to delete my account
</button>
) : (
<div className="mt-4 space-y-3">
<div className="space-y-1.5">
<label htmlFor="confirm-delete" className="text-xs font-medium text-rose-900">
Type your email <span className="font-mono">{userEmail}</span> to confirm
</label>
<input
id="confirm-delete"
type="email"
value={confirmText}
onChange={(event) => setConfirmText(event.target.value)}
placeholder={userEmail}
autoComplete="off"
className="flex h-10 w-full rounded-md border border-rose-300 bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-rose-200 pt-3">
<Feedback flash="" error={error}/>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setArmed(false);
setConfirmText("");
setError("");
}}
disabled={busy}
className="inline-flex h-9 items-center justify-center rounded-md border border-border bg-background px-3 text-xs font-medium transition-colors hover:bg-accent"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={!ready || busy}
className="inline-flex h-9 items-center justify-center rounded-md bg-rose-600 px-4 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
{busy ? "Deleting..." : "Permanently delete"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import {Store} from "lucide-react";
import {formatDate, maskShopId} from "./utils";
/**
* Shows the Shopee Seller account binding used to prevent cross-store data.
*/
export function BoundShopCard({platformAccountId, boundAt}: {
platformAccountId: string | null;
boundAt: string | null
}) {
return (
<section className="overflow-hidden rounded-2xl border border-border/60 bg-card p-5 sm:p-6">
<div className="flex items-start gap-4">
<span
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Store className="h-5 w-5 text-foreground" aria-hidden="true"/>
</span>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Shopee account binding
</p>
{platformAccountId ? (
<>
<h2 className="mt-1 text-base font-semibold tracking-tight sm:text-[17px]">
Locked to Shopee account{" "}
<span className="font-mono text-foreground/85">{maskShopId(platformAccountId)}</span>
</h2>
<p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted-foreground">
Scans from any other Shopee account are rejected so this brand's history stays clean.
{boundAt && (
<> Bound on <span
className="font-medium text-foreground/85">{formatDate(boundAt)}</span>.</>
)}
</p>
</>
) : (
<>
<h2 className="mt-1 text-base font-semibold tracking-tight sm:text-[17px]">
Will lock on first scan
</h2>
<p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted-foreground">
The first successful scan binds this brand to the currently logged-in Shopee Seller
account.
</p>
</>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import {useEffect, useState} from "react";
import {updateSettingsBrandApi} from "@/api/settings";
import {FormFooter} from "./feedback";
import {TIMEZONE_OPTIONS} from "./constants";
import type {RefreshSettings} from "./types";
import {formatInTimezone, getErrorMessage} from "./utils";
/**
* Edits the brand display name and timezone. The timezone preview is live so
* users can verify the selected local time before saving.
*/
export function BrandForm({
initialName,
initialTimezone,
onSaved,
}: {
initialName: string;
initialTimezone: string;
onSaved: RefreshSettings;
}) {
const [name, setName] = useState(initialName);
const [timezone, setTimezone] = useState(initialTimezone);
const [busy, setBusy] = useState(false);
const [flash, setFlash] = useState("");
const [error, setError] = useState("");
const [tzNow, setTzNow] = useState(() => formatInTimezone(initialTimezone));
useEffect(() => {
setName(initialName);
setTimezone(initialTimezone);
}, [initialName, initialTimezone]);
useEffect(() => {
setTzNow(formatInTimezone(timezone));
const timer = setInterval(() => setTzNow(formatInTimezone(timezone)), 1000);
return () => clearInterval(timer);
}, [timezone]);
const dirty = name.trim() !== initialName || timezone !== initialTimezone;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!dirty || busy) return;
setBusy(true);
setFlash("");
setError("");
try {
await updateSettingsBrandApi(name.trim(), timezone);
setFlash("Saved.");
await onSaved();
} catch (err) {
setError(getErrorMessage(err, "Something went wrong."));
} finally {
setBusy(false);
}
}
return (
<form onSubmit={handleSubmit} className="rounded-lg border border-border bg-card p-5 shadow-sm">
<div className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="brand-name-edit" className="text-sm font-medium">Brand name</label>
<input
id="brand-name-edit"
type="text"
required
maxLength={80}
value={name}
onChange={(event) => setName(event.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-xs text-muted-foreground">Shows up in your reports and Telegram messages.</p>
</div>
<div className="space-y-1.5">
<label htmlFor="brand-tz-edit" className="text-sm font-medium">Timezone</label>
<select
id="brand-tz-edit"
value={timezone}
onChange={(event) => setTimezone(event.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{TIMEZONE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Morning brief and evening recap fire in this timezone.
</p>
<div
className="mt-1 inline-flex flex-wrap items-center gap-2 rounded-md border border-border/40 bg-muted/40 px-2.5 py-1 text-[11px]">
<span className="font-medium text-foreground/80">Currently {tzNow}</span>
<span className="text-muted-foreground">in {timezone}</span>
{dirty && timezone !== initialTimezone && (
<span
className="rounded-full bg-amber-100 px-1.5 py-0 text-[10px] text-amber-800">unsaved</span>
)}
</div>
</div>
</div>
<FormFooter flash={flash} error={error}>
<button
type="submit"
disabled={!dirty || busy}
className="inline-flex h-9 items-center justify-center rounded-md bg-foreground px-4 text-xs font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-40"
>
{busy ? "Saving..." : "Save changes"}
</button>
</FormFooter>
</form>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
import {useEffect, useState, useTransition} from "react";
import {ExternalLink, Plus, Trash2} from "lucide-react";
import {
addSettingsCompetitorApi,
deleteSettingsCompetitorApi,
type CompetitorRow,
} from "@/api/settings";
import {Feedback} from "./feedback";
import {MAX_COMPETITORS, SHOPEE_PRODUCT_URL_RE} from "./constants";
import type {RefreshSettings} from "./types";
import {getErrorMessage, shortenUrl} from "./utils";
/**
* Editor for the Shopee competitor URLs that StoreAI tracks daily.
*/
export function CompetitorsForm({
brandId,
initial,
onChanged,
}: {
brandId: string;
initial: CompetitorRow[];
onChanged: RefreshSettings;
}) {
const [pending, startTransition] = useTransition();
const [rows, setRows] = useState(initial);
const [url, setUrl] = useState("");
const [label, setLabel] = useState("");
const [error, setError] = useState("");
const [flash, setFlash] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => setRows(initial), [initial]);
const atCap = rows.length >= MAX_COMPETITORS;
const validUrl = url.trim().length === 0 || SHOPEE_PRODUCT_URL_RE.test(url.trim());
async function handleAdd(event: React.FormEvent) {
event.preventDefault();
if (busy || atCap) return;
setError("");
setFlash("");
if (!SHOPEE_PRODUCT_URL_RE.test(url.trim())) {
setError("Not a Shopee product URL. It should include -i.<shopId>.<itemId>.");
return;
}
setBusy(true);
try {
const response = await addSettingsCompetitorApi(brandId, url.trim(), label.trim());
if (response.competitor) {
setRows((current) => [...current, response.competitor!]);
}
setUrl("");
setLabel("");
setFlash("Added.");
startTransition(() => {
void onChanged();
});
} catch (err) {
setError(getErrorMessage(err, "Could not add."));
} finally {
setBusy(false);
}
}
async function handleDelete(id: string) {
setError("");
setFlash("");
setBusy(true);
try {
await deleteSettingsCompetitorApi(brandId, id);
setRows((current) => current.filter((row) => row.id !== id));
setFlash("Removed.");
startTransition(() => {
void onChanged();
});
} catch (err) {
setError(getErrorMessage(err, "Could not remove."));
} finally {
setBusy(false);
}
}
return (
<section className="rounded-lg border border-border bg-card p-5 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-base font-semibold">Competitors</h3>
<span className="text-xs text-muted-foreground tabular-nums">{rows.length} / {MAX_COMPETITORS}</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
Product URLs to watch alongside your own. Price drops show up in your reports.
</p>
{rows.length > 0 ? (
<ul className="mt-5 divide-y divide-border/60">
{rows.map((row) => (
<li key={row.id} className="flex items-center gap-3 py-3">
<div className="min-w-0 flex-1">
{row.label && <div className="truncate text-sm font-medium">{row.label}</div>}
<a
href={row.product_url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 truncate text-xs text-muted-foreground transition-colors hover:text-foreground"
title={row.product_url}
>
<span className="truncate">{shortenUrl(row.product_url)}</span>
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden/>
</a>
</div>
<button
type="button"
onClick={() => handleDelete(row.id)}
disabled={busy || pending}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
aria-label="Remove competitor"
>
<Trash2 className="h-4 w-4" aria-hidden/>
</button>
</li>
))}
</ul>
) : (
<p className="mt-5 text-sm text-muted-foreground">No competitors yet. Add up
to {MAX_COMPETITORS} below.</p>
)}
{!atCap && (
<form onSubmit={handleAdd} className="mt-5 space-y-3 border-t border-border/60 pt-5">
<div className="space-y-1.5">
<label htmlFor="competitor-url" className="text-sm font-medium">Product URL</label>
<input
id="competitor-url"
type="url"
required
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://shopee.com.my/Item-Name-i.123456.987654321"
className={`flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
validUrl ? "border-input" : "border-destructive"
}`}
/>
</div>
<div className="space-y-1.5">
<label htmlFor="competitor-label" className="text-sm font-medium">
Label <span className="font-normal text-muted-foreground">(optional)</span>
</label>
<input
id="competitor-label"
type="text"
maxLength={80}
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="e.g. Cheaper alternative"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="flex items-center justify-between gap-3 pt-1">
<Feedback flash={flash} error={error}/>
<button
type="submit"
disabled={busy || pending || url.trim().length === 0}
className="inline-flex h-9 items-center gap-2 rounded-md bg-foreground px-4 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" aria-hidden/>
{busy ? "Adding..." : "Add competitor"}
</button>
</div>
</form>
)}
{atCap && (
<p className="mt-5 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
You have reached the {MAX_COMPETITORS}-competitor limit. Remove one before adding another.
</p>
)}
</section>
);
}

View File

@@ -0,0 +1,185 @@
import {Bell, Building2, MessagesSquare, SlidersHorizontal, UserCircle, Users} from "lucide-react";
import type {SettingsSection, ThresholdCategory, ThresholdMeta} from "./types";
export const DAILY_MANUAL_SCAN_CAP = 3;
export const MAX_COMPETITORS = 10;
export const SHOPEE_PRODUCT_URL_RE = /^https?:\/\/shopee\.com\.[a-z.]+\/[^/]+-i\.\d+\.\d+/i;
/**
* Sidebar and section metadata for the settings page.
*/
export const SETTINGS_SECTIONS: SettingsSection[] = [
{id: "brand", label: "Brand", description: "Name and timezone", icon: Building2},
{id: "telegram", label: "Telegram push", description: "Where reports get delivered", icon: MessagesSquare},
{id: "notifications", label: "Schedule", description: "Morning and evening scan times", icon: Bell},
{id: "competitors", label: "Competitors", description: "Product URLs to track", icon: Users},
{id: "thresholds", label: "Thresholds", description: "Tune alert sensitivity", icon: SlidersHorizontal},
{id: "account", label: "Account", description: "Password and account actions", icon: UserCircle},
];
/**
* Curated timezone list shared with onboarding. It keeps the dropdown compact
* while covering the markets currently targeted by the product.
*/
export const TIMEZONE_OPTIONS: Array<{ value: string; label: string }> = [
{value: "Asia/Kuala_Lumpur", label: "Kuala Lumpur (UTC+8)"},
{value: "Asia/Singapore", label: "Singapore (UTC+8)"},
{value: "Asia/Bangkok", label: "Bangkok (UTC+7)"},
{value: "Asia/Jakarta", label: "Jakarta (UTC+7)"},
{value: "Asia/Manila", label: "Manila (UTC+8)"},
{value: "Asia/Ho_Chi_Minh", label: "Ho Chi Minh City (UTC+7)"},
{value: "Asia/Shanghai", label: "Shanghai (UTC+8)"},
{value: "Asia/Hong_Kong", label: "Hong Kong (UTC+8)"},
{value: "Asia/Taipei", label: "Taipei (UTC+8)"},
{value: "Asia/Tokyo", label: "Tokyo (UTC+9)"},
{value: "Asia/Seoul", label: "Seoul (UTC+9)"},
{value: "Australia/Sydney", label: "Sydney (UTC+10/11)"},
{value: "Europe/London", label: "London (UTC+0/1)"},
{value: "America/New_York", label: "New York (UTC-5/4)"},
{value: "America/Los_Angeles", label: "Los Angeles (UTC-8/7)"},
{value: "UTC", label: "UTC"},
];
export const CATEGORY_LABELS: Record<ThresholdCategory, string> = {
sales: "Sales alerts",
ads: "Advertising alerts",
competitors: "Competitor alerts",
};
/**
* User-tunable thresholds from BACKEND_API_DOCUMENTATION.md. Fraction values
* are stored as decimals but displayed as percentages.
*/
export const THRESHOLD_META: ThresholdMeta[] = [
{
key: "SALES_01_GMV_DECLINE",
ruleId: "SALES-01",
category: "sales",
label: "GMV drop vs yesterday",
hint: "Alert when today's GMV is at least this much below yesterday at the same hour.",
unit: "fraction",
min: 0.1,
max: 0.5,
step: 0.01,
defaultValue: 0.2,
decimals: 0,
},
{
key: "SALES_01_MIN_ORDERS",
ruleId: "SALES-01",
category: "sales",
label: "Minimum orders before GMV check",
hint: "Suppresses false alarms on quiet mornings with too few orders.",
unit: "count",
min: 3,
max: 20,
step: 1,
defaultValue: 5,
decimals: 0,
},
{
key: "SALES_02_ZERO_HOURS",
ruleId: "SALES-02",
category: "sales",
label: "Hours a top SKU may stay flat",
hint: "How many consecutive hours a top seller can take zero orders before we alert.",
unit: "hours",
min: 1,
max: 6,
step: 1,
defaultValue: 2,
decimals: 0,
},
{
key: "SALES_04_DAYS_LEFT",
ruleId: "SALES-04",
category: "sales",
label: "Low-stock warning days of cover",
hint: "Alert when a SKU has fewer than this many days of stock left.",
unit: "days",
min: 1,
max: 7,
step: 1,
defaultValue: 3,
decimals: 0,
},
{
key: "SALES_05_RATING_MAX",
ruleId: "SALES-05",
category: "sales",
label: "Max star rating treated as negative",
hint: "Used for low-rating review sensitivity.",
unit: "stars",
min: 1,
max: 4,
step: 1,
defaultValue: 3,
decimals: 0,
},
{
key: "AD_01_MIN_SPEND",
ruleId: "AD-01",
category: "ads",
label: "Minimum spend before zero-conversion alert",
hint: "A campaign needs to spend this much before zero orders are flagged.",
unit: "currency_rm",
min: 10,
max: 200,
step: 1,
defaultValue: 30,
decimals: 0,
},
{
key: "AD_02_KEYWORD_SHARE",
ruleId: "AD-02",
category: "ads",
label: "Single keyword share of campaign spend",
hint: "Flag a keyword when it consumes this much spend with zero orders.",
unit: "fraction",
min: 0.3,
max: 0.8,
step: 0.05,
defaultValue: 0.5,
decimals: 0,
},
{
key: "AD_03_ROAS_MIN",
ruleId: "AD-03",
category: "ads",
label: "Target ROAS",
hint: "Alert when a campaign revenue/spend ratio drops below this value.",
unit: "roas",
min: 1.5,
max: 5,
step: 0.1,
defaultValue: 2,
decimals: 1,
},
{
key: "COMP_01_GAP",
ruleId: "COMP-01",
category: "competitors",
label: "Competitor price gap",
hint: "Alert when a tracked competitor is this much cheaper than your lowest SKU price.",
unit: "fraction",
min: 0.05,
max: 0.3,
step: 0.01,
defaultValue: 0.15,
decimals: 0,
},
{
key: "COMP_02_DROP",
ruleId: "COMP-02",
category: "competitors",
label: "Competitor price drop vs yesterday",
hint: "Alert when a tracked competitor drops price by this much day over day.",
unit: "fraction",
min: 0.05,
max: 0.3,
step: 0.01,
defaultValue: 0.1,
decimals: 0,
},
];

View File

@@ -0,0 +1,25 @@
import React from "react";
/**
* Small shared status line used by all settings forms.
*/
export function Feedback({flash, error}: { flash: string; error: string }) {
return (
<div className="text-xs">
{flash && <span className="text-emerald-700">{flash}</span>}
{error && <span className="text-destructive">{error}</span>}
</div>
);
}
/**
* Consistent footer layout for forms that save a single settings section.
*/
export function FormFooter({flash, error, children}: { flash: string; error: string; children: React.ReactNode }) {
return (
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-4">
<Feedback flash={flash} error={error}/>
{children}
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import {useEffect, useState, useTransition} from "react";
import {updateSettingsNotificationsApi} from "@/api/settings";
import {FormFooter} from "./feedback";
import type {RefreshSettings} from "./types";
import {getErrorMessage} from "./utils";
/**
* Lets users choose the two report hours that drive scheduled scans.
*/
export function NotificationsForm({
brandId,
initial,
onSaved,
}: {
brandId: string;
initial: { morningBriefHour: number; eveningRecapHour: number };
onSaved: RefreshSettings;
}) {
const [pending, startTransition] = useTransition();
const [values, setValues] = useState(initial);
const [error, setError] = useState("");
const [flash, setFlash] = useState("");
useEffect(() => setValues(initial), [initial]);
const dirty = values.morningBriefHour !== initial.morningBriefHour || values.eveningRecapHour !== initial.eveningRecapHour;
const sameHour = values.morningBriefHour === values.eveningRecapHour;
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setError("");
setFlash("");
if (sameHour) {
setError("Morning and evening hours must differ.");
return;
}
try {
await updateSettingsNotificationsApi(brandId, values);
setFlash("Saved.");
startTransition(() => {
void onSaved();
});
} catch (err) {
setError(getErrorMessage(err, "Failed to save."));
}
}
return (
<form onSubmit={handleSubmit} className="rounded-lg border border-border bg-card p-5 shadow-sm">
<h3 className="text-base font-semibold">Daily report schedule</h3>
<p className="mt-1 text-sm text-muted-foreground">
StoreAI scans your store and sends a Telegram report twice a day.
</p>
<div className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-2">
<HourCard
title="Morning brief"
hint="Yesterday's recap and today's focus"
value={values.morningBriefHour}
onChange={(value) => setValues((current) => ({...current, morningBriefHour: value}))}
/>
<HourCard
title="Evening recap"
hint="Today's performance and new anomalies"
value={values.eveningRecapHour}
onChange={(value) => setValues((current) => ({...current, eveningRecapHour: value}))}
/>
</div>
{sameHour && <p className="mt-3 text-xs text-destructive">Morning and evening must be different hours.</p>}
<FormFooter flash={flash} error={error}>
<button
type="submit"
disabled={pending || !dirty || sameHour}
className="w-full rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50 sm:w-auto"
>
{pending ? "Saving..." : "Save schedule"}
</button>
</FormFooter>
</form>
);
}
function HourCard({title, hint, value, onChange}: {
title: string;
hint: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<label className="flex cursor-pointer flex-col gap-2 rounded-md border border-border bg-accent/20 p-4">
<span className="text-sm font-medium">{title}</span>
<span className="text-xs text-muted-foreground">{hint}</span>
<div className="mt-1 flex items-center gap-2">
<select
aria-label={title}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{Array.from({length: 24}, (_, hour) => (
<option key={hour} value={hour}>{String(hour).padStart(2, "0")}:00</option>
))}
</select>
<span className="text-xs text-muted-foreground">local</span>
</div>
</label>
);
}

View File

@@ -0,0 +1,123 @@
import {ChevronRight, Clock3, Radar} from "lucide-react";
import {formatRelativeAge} from "./utils";
/**
* Displays manual scan usage, auto-scan schedule, and stale auto-scan hints.
*/
export function ScanQuotaCard({
manualUsed,
manualCap,
morningBriefHour,
eveningRecapHour,
lastAutoScanAt,
brandTimezone,
}: {
manualUsed: number;
manualCap: number;
morningBriefHour: number;
eveningRecapHour: number;
lastAutoScanAt: string | null;
brandTimezone: string;
}) {
const remaining = Math.max(0, manualCap - manualUsed);
const exhausted = remaining === 0;
const autoScanAgeHours = lastAutoScanAt
? (Date.now() - new Date(lastAutoScanAt).getTime()) / 3600000
: null;
const autoScanStale = autoScanAgeHours !== null && autoScanAgeHours > 14;
return (
<section
className="overflow-hidden rounded-2xl border border-border/60 bg-linear-to-br from-card via-card to-muted/30 p-5 sm:p-6">
<div className="flex items-start gap-4">
<span
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-card">
<Radar className="h-5 w-5 text-foreground" aria-hidden="true"/>
</span>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Today's scan quota
</p>
<h2 className="mt-1 text-balance text-xl font-semibold tracking-tight sm:text-[22px]">
<span
className={exhausted ? "text-rose-700" : remaining === 1 ? "text-amber-700" : "text-foreground"}>
{manualUsed} / {manualCap}
</span>{" "}
<span className="text-muted-foreground">manual scans used</span>
</h2>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground">
{exhausted
? `You have used all ${manualCap} manual scans today. They reset at midnight (${brandTimezone}). Auto scans still run on time.`
: `You can run ${remaining} more manual scan${remaining === 1 ? "" : "s"} today. Quota resets at midnight (${brandTimezone}).`}
</p>
<div className="mt-4 flex h-2 w-full max-w-md gap-1">
{Array.from({length: manualCap}).map((_, index) => (
<div
key={index}
className={`h-2 flex-1 rounded-full ${
index < manualUsed ? (exhausted ? "bg-rose-500" : "bg-foreground") : "bg-muted"
}`}
aria-hidden="true"
/>
))}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<ScheduleRow label="Morning auto-scan" hour={morningBriefHour} note={brandTimezone}/>
<ScheduleRow label="Evening auto-scan" hour={eveningRecapHour} note={brandTimezone}/>
</div>
{lastAutoScanAt ? (
<div className={`mt-3 rounded-md border px-3 py-2 text-xs ${
autoScanStale
? "border-amber-200 bg-amber-50/60 text-amber-900"
: "border-border/60 bg-muted/20 text-muted-foreground"
}`}>
Last successful auto scan:{" "}
<span className="font-medium tabular-nums">{formatRelativeAge(lastAutoScanAt)}</span>
{autoScanStale && (
<span className="font-medium"> - Chrome may be closed. Open Chrome to resume auto scanning.</span>
)}
</div>
) : (
<p className="mt-3 text-[11px] text-muted-foreground">
No auto scans yet. They start running once Chrome is open at the scheduled hour.
</p>
)}
<details className="group mt-4 rounded-md border border-border/60 bg-background/40">
<summary
className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-xs font-medium transition-colors hover:bg-muted/30">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90"
aria-hidden="true"/>
Keep Chrome auto-running
</summary>
<div
className="space-y-3 border-t border-border/60 px-4 py-3 text-[11px] leading-relaxed text-muted-foreground">
<p>
Add Google Chrome to your startup apps so the extension can run the scheduled scans.
Auto scans do not consume manual quota.
</p>
</div>
</details>
</div>
</div>
</section>
);
}
function ScheduleRow({label, hour, note}: { label: string; hour: number; note: string }) {
return (
<div className="flex items-start gap-2 rounded-lg border border-border/60 bg-background/40 px-3 py-2">
<Clock3 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true"/>
<div>
<p className="text-xs font-medium">{label}</p>
<p className="mt-0.5 text-[11px] text-muted-foreground tabular-nums">
{String(hour).padStart(2, "0")}:00 <span className="opacity-70">{note}</span>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import {useEffect, useState} from "react";
import {SETTINGS_SECTIONS} from "./constants";
/**
* Sticky in-page navigation with a small IntersectionObserver scroll spy.
*/
export function SettingsNav() {
const [activeId, setActiveId] = useState<string>(SETTINGS_SECTIONS[0].id);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (visible[0]) {
setActiveId(visible[0].target.id);
}
},
{
rootMargin: "-25% 0px -55% 0px",
threshold: [0, 0.25, 0.5, 0.75, 1],
},
);
const elements = SETTINGS_SECTIONS
.map((section) => document.getElementById(section.id))
.filter((element): element is HTMLElement => element !== null);
elements.forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, []);
return (
<nav className="sticky top-20 space-y-1">
<p className="px-2 pb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
On this page
</p>
{SETTINGS_SECTIONS.map(({id, label, description, icon: Icon}) => {
const isActive = activeId === id;
return (
<a
key={id}
href={`#${id}`}
className={`block rounded-md px-2.5 py-2 transition-colors ${
isActive
? "bg-muted/60 text-foreground"
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground"
}`}
>
<div className="flex items-center gap-2 text-sm font-medium">
<Icon className="h-3.5 w-3.5" aria-hidden="true"/>
{label}
</div>
<p className="ml-[22px] mt-0.5 text-[11px] leading-snug text-muted-foreground/80">
{description}
</p>
</a>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import type {SettingsSection as SettingsSectionType} from "./types";
/**
* Shared wrapper for each settings section title and icon.
*/
export function SettingsSection({
section,
children,
}: {
section: SettingsSectionType;
children: React.ReactNode;
}) {
const Icon = section.icon;
return (
<section id={section.id} className="scroll-mt-24">
<div className="mb-4 flex items-center gap-2">
<span
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-muted/30">
<Icon className="h-3.5 w-3.5 text-foreground" aria-hidden="true"/>
</span>
<h2 className="text-base font-semibold tracking-tight">{section.label}</h2>
</div>
{children}
</section>
);
}

View File

@@ -0,0 +1,247 @@
"use client";
import {useEffect, useState, useTransition} from "react";
import {QRCodeCanvas} from "qrcode.react";
import {createTelegramPairApi, updateTelegramChatApi} from "@/api/settings";
import {Feedback} from "./feedback";
import type {RefreshSettings} from "./types";
import {getErrorMessage} from "./utils";
/**
* Handles QR pairing, manual chat_id entry, polling, and disconnect.
*/
export function TelegramConnect({
brandId,
currentChatId,
onRefresh,
}: {
brandId: string;
currentChatId: string | null;
onRefresh: RefreshSettings;
}) {
const [pending, startTransition] = useTransition();
const [showManual, setShowManual] = useState(false);
const [error, setError] = useState("");
const [flash, setFlash] = useState("");
const [pairingLink, setPairingLink] = useState("");
const [pollingUntil, setPollingUntil] = useState<number | null>(null);
const [minting, setMinting] = useState(false);
useEffect(() => {
if (!pollingUntil) return;
if (currentChatId) {
setPollingUntil(null);
setPairingLink("");
setFlash("Connected.");
return;
}
const interval = setInterval(() => {
if (Date.now() > pollingUntil) {
setPollingUntil(null);
return;
}
void onRefresh();
}, 2500);
return () => clearInterval(interval);
}, [pollingUntil, currentChatId, onRefresh]);
async function startPairing() {
setError("");
setFlash("");
setPairingLink("");
setMinting(true);
try {
const response = await createTelegramPairApi(brandId);
if (!response.deeplink) {
throw new Error("Telegram pair link is missing.");
}
setPairingLink(response.deeplink);
setPollingUntil(Date.now() + (response.ttlSeconds || 900) * 1000);
} catch (err) {
setError(getErrorMessage(err, "Failed to start pairing."));
} finally {
setMinting(false);
}
}
async function saveManual(chatId: string | null) {
setError("");
setFlash("");
try {
await updateTelegramChatApi(brandId, chatId);
setFlash(chatId ? "Saved." : "Telegram disconnected.");
startTransition(() => {
void onRefresh();
});
} catch (err) {
setError(getErrorMessage(err, "Failed to save."));
}
}
return (
<section className="rounded-lg border border-border bg-card p-5 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-base font-semibold">Telegram push</h3>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${
currentChatId ? "bg-emerald-100 text-emerald-800" : "bg-slate-200 text-slate-700"
}`}>
{currentChatId ? "Connected" : "Not connected"}
</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">Urgent alerts and daily digests go here.</p>
{currentChatId ? (
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm">
<span className="text-muted-foreground">
Chat ending <span className="font-mono text-foreground/80">{currentChatId.slice(-4)}</span>
</span>
<button
type="button"
onClick={() => saveManual(null)}
disabled={pending}
className="text-xs text-muted-foreground transition-colors hover:text-destructive disabled:opacity-50"
>
Disconnect
</button>
</div>
) : pairingLink ? (
<QrPairPanel
pairingLink={pairingLink}
onRefresh={onRefresh}
onCancel={() => {
setPairingLink("");
setPollingUntil(null);
}}
/>
) : (
<InitialTelegramPanel
onStart={startPairing}
minting={minting}
showManual={showManual}
onToggleManual={() => setShowManual((value) => !value)}
onManualSave={(id) => saveManual(id)}
pending={pending}
/>
)}
<Feedback flash={flash} error={error}/>
</section>
);
}
function QrPairPanel({
pairingLink,
onRefresh,
onCancel,
}: {
pairingLink: string;
onRefresh: RefreshSettings;
onCancel: () => void;
}) {
return (
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6">
<div className="flex shrink-0 justify-center rounded-lg border border-border bg-white p-3">
<QRCodeCanvas value={pairingLink} size={180} level="M"/>
</div>
<div className="min-w-0 flex-1 space-y-3">
<div>
<p className="text-sm font-medium">Scan with your phone camera</p>
<p className="mt-1 text-xs text-muted-foreground">
Telegram opens the bot with a Start button pre-filled. Tap it and this page will check
automatically.
</p>
</div>
<button
type="button"
onClick={() => onRefresh()}
className="rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium transition-colors hover:bg-accent"
>
I have tapped Start - check now
</button>
<div className="flex flex-wrap items-center gap-3 border-t border-border/40 pt-3">
<a
href={pairingLink}
target="_blank"
rel="noreferrer noopener"
className="text-xs font-medium text-muted-foreground underline decoration-muted-foreground/60 underline-offset-2 hover:text-foreground"
>
Or open in Telegram app
</a>
<button
type="button"
onClick={onCancel}
className="text-xs text-muted-foreground hover:text-destructive"
>
Cancel
</button>
</div>
</div>
</div>
);
}
function InitialTelegramPanel({
onStart,
minting,
showManual,
onToggleManual,
onManualSave,
pending,
}: {
onStart: () => void;
minting: boolean;
showManual: boolean;
onToggleManual: () => void;
onManualSave: (id: string) => void;
pending: boolean;
}) {
const [value, setValue] = useState("");
return (
<div className="mt-4 space-y-3">
<button
type="button"
onClick={onStart}
disabled={minting}
className="rounded-md bg-[#229ED9] px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>
{minting ? "Preparing..." : "Connect with Telegram"}
</button>
<div className="pt-2 text-xs">
<button
type="button"
onClick={onToggleManual}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{showManual ? "Hide manual entry" : "Paste a chat_id instead"}
</button>
</div>
{showManual && (
<div className="flex flex-wrap items-center gap-2 border-t border-border/40 pt-3">
<input
type="text"
inputMode="numeric"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="e.g. 1234567890 or -1009876543210"
className="min-w-[200px] flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="button"
onClick={() => onManualSave(value.trim())}
disabled={pending || value.trim() === ""}
className="rounded-md border border-input bg-background px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
>
Save chat_id
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,222 @@
"use client";
import {useEffect, useMemo, useState, useTransition} from "react";
import {updateSettingsThresholdsApi, type ThresholdKey} from "@/api/settings";
import {CATEGORY_LABELS, THRESHOLD_META} from "./constants";
import {Feedback} from "./feedback";
import type {RefreshSettings, ThresholdCategory, ThresholdMeta} from "./types";
import {
approxEqual,
formatThresholdForInput,
getErrorMessage,
isThresholdDirty,
parseThresholdInput,
thresholdInputStep,
thresholdUnitSuffix,
} from "./utils";
/**
* Form for all rule-threshold overrides. Only changed values are sent to the
* backend, and values matching defaults remove stored overrides.
*/
export function ThresholdsForm({
brandId,
savedThresholds,
onSaved,
}: {
brandId: string;
savedThresholds: Partial<Record<ThresholdKey, number>>;
onSaved: RefreshSettings;
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState("");
const [flash, setFlash] = useState("");
const initial = useMemo(() => {
const output = {} as Record<ThresholdKey, string>;
for (const meta of THRESHOLD_META) {
const raw = savedThresholds[meta.key] ?? meta.defaultValue;
output[meta.key] = formatThresholdForInput(raw, meta);
}
return output;
}, [savedThresholds]);
const [values, setValues] = useState(initial);
useEffect(() => setValues(initial), [initial]);
const byCategory = useMemo(() => {
const map: Record<ThresholdCategory, ThresholdMeta[]> = {
sales: [],
ads: [],
competitors: [],
};
for (const meta of THRESHOLD_META) {
map[meta.category].push(meta);
}
return map;
}, []);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setError("");
setFlash("");
const payload: Partial<Record<ThresholdKey, number | null>> = {};
for (const meta of THRESHOLD_META) {
const parsed = parseThresholdInput(values[meta.key], meta);
if (parsed === null) {
setError(`"${meta.label}" is not a valid number.`);
return;
}
if (parsed < meta.min || parsed > meta.max) {
setError(`"${meta.label}" must be between ${formatThresholdForInput(meta.min, meta)} and ${formatThresholdForInput(meta.max, meta)}.`);
return;
}
const hasOverride = meta.key in savedThresholds;
const savedNumeric = savedThresholds[meta.key] ?? meta.defaultValue;
const matchesDefault = approxEqual(parsed, meta.defaultValue, meta);
const matchesSaved = approxEqual(parsed, savedNumeric, meta);
if (matchesDefault && hasOverride) {
payload[meta.key] = null;
} else if (!matchesSaved) {
payload[meta.key] = parsed;
}
}
if (Object.keys(payload).length === 0) {
setFlash("No changes to save.");
return;
}
try {
await updateSettingsThresholdsApi(brandId, payload as Record<string, number | null>);
setFlash(`Saved ${Object.keys(payload).length} change${Object.keys(payload).length === 1 ? "" : "s"}.`);
startTransition(() => {
void onSaved();
});
} catch (err) {
setError(getErrorMessage(err, "Failed to save."));
}
}
function resetAllToDefaults() {
const next = {} as Record<ThresholdKey, string>;
for (const meta of THRESHOLD_META) {
next[meta.key] = formatThresholdForInput(meta.defaultValue, meta);
}
setValues(next);
setError("");
setFlash("");
}
return (
<form onSubmit={handleSubmit} className="space-y-10">
{(Object.keys(byCategory) as ThresholdCategory[]).map((category) => (
<section key={category}>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{CATEGORY_LABELS[category]}
</h3>
<div className="space-y-4 rounded-lg border border-border bg-card p-5 shadow-sm">
{byCategory[category].map((meta, index) => {
const savedNumeric = savedThresholds[meta.key] ?? meta.defaultValue;
return (
<ThresholdField
key={meta.key}
meta={meta}
value={values[meta.key]}
onChange={(value) => setValues((current) => ({...current, [meta.key]: value}))}
savedNumeric={savedNumeric}
isDirty={isThresholdDirty(values[meta.key], savedNumeric, meta)}
isSetByUser={!approxEqual(savedNumeric, meta.defaultValue, meta)}
showDivider={index < byCategory[category].length - 1}
/>
);
})}
</div>
</section>
))}
<div
className="flex flex-col-reverse gap-3 border-t border-border/60 pt-5 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<button
type="button"
onClick={resetAllToDefaults}
disabled={pending}
className="self-start text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50 sm:self-auto"
>
Reset all to defaults
</button>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 sm:justify-end">
<Feedback flash={flash} error={error}/>
<button
type="submit"
disabled={pending}
className="w-full rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50 sm:w-auto"
>
{pending ? "Saving..." : "Save changes"}
</button>
</div>
</div>
</form>
);
}
function ThresholdField({
meta,
value,
onChange,
savedNumeric,
isDirty,
isSetByUser,
showDivider,
}: {
meta: ThresholdMeta;
value: string;
onChange: (value: string) => void;
savedNumeric: number;
isDirty: boolean;
isSetByUser: boolean;
showDivider: boolean;
}) {
const suffix = thresholdUnitSuffix(meta.unit);
return (
<div className={showDivider ? "border-b border-border/40 pb-4" : ""}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<label htmlFor={`thr-${meta.key}`} className="text-sm font-medium leading-snug">
{meta.label}
</label>
<span className="font-mono text-[10px] text-muted-foreground">{meta.ruleId}</span>
{isSetByUser && !isDirty &&
<span className="text-[10px] uppercase tracking-wide text-emerald-700">custom</span>}
{isDirty && <span className="text-[10px] uppercase tracking-wide text-amber-700">unsaved</span>}
</div>
<p className="mt-1 text-xs text-muted-foreground">{meta.hint}</p>
<p className="mt-1 text-[11px] text-muted-foreground">
Default {formatThresholdForInput(meta.defaultValue, meta)}
{suffix && <> {suffix}</>} -
range {formatThresholdForInput(meta.min, meta)}-{formatThresholdForInput(meta.max, meta)}
{suffix && <> {suffix}</>}
{isSetByUser && ` - currently ${formatThresholdForInput(savedNumeric, meta)}${suffix ? ` ${suffix}` : ""}`}
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<input
id={`thr-${meta.key}`}
type="number"
inputMode="decimal"
value={value}
onChange={(event) => onChange(event.target.value)}
min={formatThresholdForInput(meta.min, meta)}
max={formatThresholdForInput(meta.max, meta)}
step={thresholdInputStep(meta)}
className="w-20 rounded-md border border-input bg-background px-2 py-1 text-right text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-ring"
/>
{suffix && <span className="text-sm text-muted-foreground">{suffix}</span>}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import type {LucideIcon} from "lucide-react";
import type {ThresholdKey} from "@/api/settings";
export type ThresholdCategory = "sales" | "ads" | "competitors";
export type ThresholdUnit = "fraction" | "currency_rm" | "hours" | "days" | "stars" | "roas" | "count";
/**
* Metadata that drives the threshold form and keeps UI labels/ranges in one
* place. The backend validates the same keys and ranges.
*/
export type ThresholdMeta = {
key: ThresholdKey;
ruleId: string;
category: ThresholdCategory;
label: string;
hint: string;
unit: ThresholdUnit;
min: number;
max: number;
step: number;
defaultValue: number;
decimals: number;
};
export type SettingsSection = {
id: string;
label: string;
description: string;
icon: LucideIcon;
};
export type RefreshSettings = () => Promise<unknown>;

View File

@@ -0,0 +1,112 @@
import type {ThresholdMeta, ThresholdUnit} from "./types";
export function getErrorMessage(error: unknown, fallback: string) {
if (typeof error === "string" && error) return error;
const value = error as any;
return value?.response?.data?.message
|| value?.response?.data?.data?.error
|| value?.message
|| value?.data?.error
|| fallback;
}
export function formatInTimezone(timezone: string) {
try {
return new Intl.DateTimeFormat("en-GB", {
timeZone: timezone,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date());
} catch {
return "-";
}
}
export function formatRelativeAge(iso: string) {
const milliseconds = Date.now() - new Date(iso).getTime();
if (milliseconds < 60000) return "just now";
const minutes = Math.floor(milliseconds / 60000);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
export function maskShopId(id: string) {
if (id.length <= 6) return id;
return `${id.slice(0, 3)}...${id.slice(-3)}`;
}
export function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function shortenUrl(url: string) {
try {
const parsed = new URL(url);
const path = parsed.pathname.length > 60 ? `${parsed.pathname.slice(0, 60)}...` : parsed.pathname;
return `${parsed.host}${path}`;
} catch {
return url.length > 80 ? `${url.slice(0, 80)}...` : url;
}
}
export function thresholdUnitSuffix(unit: ThresholdUnit) {
switch (unit) {
case "fraction":
return "%";
case "currency_rm":
return "RM";
case "hours":
return "h";
case "days":
return "d";
case "stars":
return "stars";
case "roas":
case "count":
return "";
}
}
export function formatThresholdForInput(value: number, meta: ThresholdMeta) {
if (meta.unit === "fraction") {
const percent = Math.round(value * 10000) / 100;
return Number.isInteger(percent) ? String(percent) : percent.toFixed(meta.decimals);
}
if (meta.decimals === 0) return String(Math.round(value));
return value.toFixed(meta.decimals);
}
export function parseThresholdInput(raw: string, meta: ThresholdMeta) {
const trimmed = raw.trim();
if (!trimmed) return null;
const value = Number(trimmed);
if (!Number.isFinite(value)) return null;
if (meta.unit === "fraction") return Math.round(value * 100) / 10000;
return value;
}
export function thresholdInputStep(meta: ThresholdMeta) {
if (meta.unit === "fraction") {
const percentStep = meta.step * 100;
return percentStep >= 1 ? String(Math.round(percentStep)) : percentStep.toFixed(2);
}
return String(meta.step);
}
export function isThresholdDirty(rawInput: string, savedNumeric: number, meta: ThresholdMeta) {
const parsed = parseThresholdInput(rawInput, meta);
if (parsed === null) return true;
return !approxEqual(parsed, savedNumeric, meta);
}
export function approxEqual(a: number, b: number, meta: ThresholdMeta) {
return Math.abs(a - b) < meta.step / 2;
}

View File

@@ -0,0 +1,162 @@
"use client";
import {useEffect, useState} from "react";
import Link from "next/link";
import {useRouter} from "next/navigation";
import {getSettingsApi, type SettingsResponse} from "@/api/settings";
import {AccountForm} from "./_components/account-form";
import {BoundShopCard} from "./_components/bound-shop-card";
import {BrandForm} from "./_components/brand-form";
import {CompetitorsForm} from "./_components/competitors-form";
import {DAILY_MANUAL_SCAN_CAP, SETTINGS_SECTIONS} from "./_components/constants";
import {NotificationsForm} from "./_components/notifications-form";
import {ScanQuotaCard} from "./_components/scan-quota-card";
import {SettingsNav} from "./_components/settings-nav";
import {SettingsSection} from "./_components/settings-section";
import {TelegramConnect} from "./_components/telegram-connect";
import {ThresholdsForm} from "./_components/thresholds-form";
import {getErrorMessage} from "./_components/utils";
/**
* Settings page shell.
*
* Keep this file intentionally small: it owns page-level loading, auth failure
* redirects, and data refresh. Field-specific mutations live in _components.
*/
export default function SettingsPage() {
const router = useRouter();
const [data, setData] = useState<SettingsResponse | null>(null);
const [error, setError] = useState("");
async function refreshSettings() {
const settings = await getSettingsApi();
setData(settings);
return settings;
}
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const settings = await getSettingsApi();
if (!cancelled) {
setData(settings);
}
} catch (err) {
if (cancelled) return;
setError(getErrorMessage(err, "Settings failed to load."));
router.replace("/login?next=/dashboard/setting");
}
})();
return () => {
cancelled = true;
};
}, [router]);
const brand = data?.brand ?? null;
return (
<main className="container mx-auto max-w-6xl px-4 py-8 md:px-6 md:py-10">
<header className="mb-8 sm:mb-10">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Brand / {brand?.name ?? "Settings"}
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-[26px]">
Settings
</h1>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
Tune how StoreAI watches your store. Changes apply on the next scheduled scan.
</p>
</header>
{!data ? (
<div className="h-64 animate-pulse rounded-lg border border-border bg-muted/40"/>
) : !brand ? (
<section className="rounded-xl border border-border bg-card p-8 shadow-sm">
<h2 className="text-base font-medium">No brand yet</h2>
<p className="mt-2 text-sm text-muted-foreground">
Finish onboarding before tuning settings.{" "}
<Link href="/onboarding/brand" className="underline underline-offset-2 hover:text-foreground">
Set up your brand
</Link>
.
</p>
</section>
) : (
<div className="grid gap-10 lg:grid-cols-[220px_1fr] lg:gap-14">
<aside className="hidden lg:block">
<SettingsNav/>
</aside>
<div className="space-y-12">
<ScanQuotaCard
manualUsed={data.manualUsedToday}
manualCap={DAILY_MANUAL_SCAN_CAP}
morningBriefHour={brand.morning_brief_hour}
eveningRecapHour={brand.evening_recap_hour}
lastAutoScanAt={data.lastAutoScanAt}
brandTimezone={brand.timezone}
/>
<BoundShopCard
platformAccountId={brand.platform_account_id}
boundAt={brand.platform_account_id_bound_at}
/>
<SettingsSection section={SETTINGS_SECTIONS[0]}>
<BrandForm
initialName={brand.name}
initialTimezone={brand.timezone}
onSaved={refreshSettings}
/>
</SettingsSection>
<SettingsSection section={SETTINGS_SECTIONS[1]}>
<TelegramConnect
brandId={brand.id}
currentChatId={brand.telegram_chat_id}
onRefresh={refreshSettings}
/>
</SettingsSection>
<SettingsSection section={SETTINGS_SECTIONS[2]}>
<NotificationsForm
brandId={brand.id}
initial={{
morningBriefHour: brand.morning_brief_hour,
eveningRecapHour: brand.evening_recap_hour,
}}
onSaved={refreshSettings}
/>
</SettingsSection>
<SettingsSection section={SETTINGS_SECTIONS[3]}>
<CompetitorsForm
brandId={brand.id}
initial={data.competitors}
onChanged={refreshSettings}
/>
</SettingsSection>
<SettingsSection section={SETTINGS_SECTIONS[4]}>
<ThresholdsForm
brandId={brand.id}
savedThresholds={brand.thresholds ?? {}}
onSaved={refreshSettings}
/>
</SettingsSection>
<SettingsSection section={SETTINGS_SECTIONS[5]}>
<AccountForm userEmail={data.user.email ?? ""}/>
</SettingsSection>
</div>
</div>
)}
{error && <p className="mt-4 text-sm text-destructive">{error}</p>}
</main>
);
}