1
This commit is contained in:
2070
BACKEND_API_DOCUMENTATION.md
Normal file
2070
BACKEND_API_DOCUMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
145
src/api/settings.ts
Normal 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");
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
230
src/app/dashboard/setting/_components/account-form.tsx
Normal file
230
src/app/dashboard/setting/_components/account-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/dashboard/setting/_components/bound-shop-card.tsx
Normal file
52
src/app/dashboard/setting/_components/bound-shop-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/app/dashboard/setting/_components/brand-form.tsx
Normal file
116
src/app/dashboard/setting/_components/brand-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
src/app/dashboard/setting/_components/competitors-form.tsx
Normal file
185
src/app/dashboard/setting/_components/competitors-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
src/app/dashboard/setting/_components/constants.ts
Normal file
185
src/app/dashboard/setting/_components/constants.ts
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
25
src/app/dashboard/setting/_components/feedback.tsx
Normal file
25
src/app/dashboard/setting/_components/feedback.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/app/dashboard/setting/_components/notifications-form.tsx
Normal file
115
src/app/dashboard/setting/_components/notifications-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/app/dashboard/setting/_components/scan-quota-card.tsx
Normal file
123
src/app/dashboard/setting/_components/scan-quota-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/app/dashboard/setting/_components/settings-nav.tsx
Normal file
66
src/app/dashboard/setting/_components/settings-nav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/app/dashboard/setting/_components/settings-section.tsx
Normal file
28
src/app/dashboard/setting/_components/settings-section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
src/app/dashboard/setting/_components/telegram-connect.tsx
Normal file
247
src/app/dashboard/setting/_components/telegram-connect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
src/app/dashboard/setting/_components/thresholds-form.tsx
Normal file
222
src/app/dashboard/setting/_components/thresholds-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/app/dashboard/setting/_components/types.ts
Normal file
32
src/app/dashboard/setting/_components/types.ts
Normal 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>;
|
||||||
112
src/app/dashboard/setting/_components/utils.ts
Normal file
112
src/app/dashboard/setting/_components/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
162
src/app/dashboard/setting/page.tsx
Normal file
162
src/app/dashboard/setting/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user