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
|
||||
* @param brandId
|
||||
@@ -35,6 +42,43 @@ export async function brandTelegramScan(brandId: string) {
|
||||
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")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设置信息
|
||||
*/
|
||||
|
||||
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 tabs = [
|
||||
{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},
|
||||
];
|
||||
return (
|
||||
<nav className="flex items-center gap-0.5" aria-label="Dashboard navigation">
|
||||
{tabs.map(item => {
|
||||
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 (
|
||||
<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