feat: default watchlist semua, login-gate fitur, CI anti-stale via /api/version
This commit is contained in:
@@ -2,6 +2,7 @@ import { createResource, createSignal, Show } from "solid-js";
|
||||
import { api, type AuthUser } from "../lib/api";
|
||||
import { Button } from "./ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
||||
|
||||
// Theme: .dark class on <html> (shadcn convention). Default dark.
|
||||
function theme(): string {
|
||||
@@ -30,7 +31,30 @@ export function useAuth() {
|
||||
const [me, { refetch }] = createResource(async (): Promise<AuthUser | null> => {
|
||||
try { return (await api.me()).user; } catch { return null; }
|
||||
});
|
||||
return { me, refetch };
|
||||
const [version] = createResource(async () => {
|
||||
try { return await api.version(); } catch { return null; }
|
||||
});
|
||||
return { me, refetch, version };
|
||||
}
|
||||
|
||||
// ButuhLogin: kartu ajakan login untuk halaman fitur yg di-hide bila logout.
|
||||
export function ButuhLogin(props: { fitur: string }) {
|
||||
return (
|
||||
<div class="mx-auto max-w-md py-[6vh]">
|
||||
<Card>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">🔒 {props.fitur} perlu login</CardTitle>
|
||||
<CardDescription>
|
||||
Biar datanya milik kamu sendiri (watchlist, notifikasi, report) —
|
||||
daftar gratis, cukup username + password.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="text-center">
|
||||
<a href="/login"><Button size="lg">Masuk / Daftar</Button></a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
|
||||
|
||||
+76
-22
@@ -1,8 +1,8 @@
|
||||
import { render } from "solid-js/web";
|
||||
import { Router, Route, useLocation, useNavigate, useParams, type RouteSectionProps } from "@solidjs/router";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { Router, Route, useLocation, useNavigate, type RouteSectionProps } from "@solidjs/router";
|
||||
import { createResource, createSignal, Show } from "solid-js";
|
||||
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
|
||||
import { ThemeToggle, useAuth, AuthButton } from "./components/auth";
|
||||
import { ThemeToggle, useAuth, AuthButton, ButuhLogin } from "./components/auth";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card";
|
||||
import { TextField, TextFieldInput } from "./components/ui/text-field";
|
||||
@@ -15,7 +15,7 @@ import Report from "./pages/Report";
|
||||
import Portfolio from "./pages/Portfolio";
|
||||
import "./styles/globals.css";
|
||||
|
||||
const NAV = [
|
||||
const NAV_ALL = [
|
||||
{ href: "/", icon: "📊", label: "Dashboard" },
|
||||
{ href: "/routines", icon: "🗓️", label: "Routines" },
|
||||
{ href: "/screener", icon: "🔍", label: "Screener" },
|
||||
@@ -23,14 +23,16 @@ const NAV = [
|
||||
{ href: "/portfolio", icon: "💼", label: "Portfolio" },
|
||||
];
|
||||
|
||||
function Nav() {
|
||||
function Nav(props: { loggedIn: boolean }) {
|
||||
const loc = useLocation();
|
||||
// Belum login: hanya Dashboard yg terlihat (fitur lain di-hide).
|
||||
const items = () => props.loggedIn ? NAV_ALL : NAV_ALL.slice(0, 1);
|
||||
return (
|
||||
<nav class="flex w-56 shrink-0 flex-col gap-1 border-r bg-card p-4 max-lg:hidden">
|
||||
<div class="mb-4 px-2 text-xl font-bold tracking-tight">
|
||||
Flow<span class="text-primary">Sight</span>
|
||||
</div>
|
||||
{NAV.map((n) => (
|
||||
{items().map((n) => (
|
||||
<a
|
||||
href={n.href}
|
||||
class={`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ${loc.pathname === n.href ? "bg-accent text-accent-foreground" : "text-muted-foreground"}`}
|
||||
@@ -42,11 +44,12 @@ function Nav() {
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNav() {
|
||||
function MobileNav(props: { loggedIn: boolean }) {
|
||||
const loc = useLocation();
|
||||
const items = () => props.loggedIn ? NAV_ALL : NAV_ALL.slice(0, 1);
|
||||
return (
|
||||
<nav class="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card px-2 py-1 lg:hidden">
|
||||
{NAV.map((n) => (
|
||||
{items().map((n) => (
|
||||
<a
|
||||
href={n.href}
|
||||
class={`flex flex-1 flex-col items-center rounded-md py-1.5 text-[11px] font-medium ${loc.pathname === n.href ? "text-primary" : "text-muted-foreground"}`}
|
||||
@@ -58,12 +61,13 @@ function MobileNav() {
|
||||
);
|
||||
}
|
||||
|
||||
function TickerSearch() {
|
||||
function TickerSearch(props: { loggedIn: boolean }) {
|
||||
const nav = useNavigate();
|
||||
const [q, setQ] = createSignal("");
|
||||
const go = () => {
|
||||
const t = q().toUpperCase().trim();
|
||||
if (t) nav(`/report/${t}`);
|
||||
// Report butuh login: arahkan yg belum login ke /login.
|
||||
if (t) nav(props.loggedIn ? `/report/${t}` : "/login");
|
||||
};
|
||||
return (
|
||||
<form class="flex max-w-sm flex-1 items-center gap-2" onSubmit={(e) => { e.preventDefault(); go(); }}>
|
||||
@@ -77,19 +81,51 @@ function TickerSearch() {
|
||||
}
|
||||
|
||||
function LoginPage() {
|
||||
const params = useParams<{ error?: string }>();
|
||||
const err = () => new URLSearchParams(window.location.search).get("error");
|
||||
void params;
|
||||
const [mode, setMode] = createSignal<"masuk" | "daftar">("masuk");
|
||||
const [username, setUsername] = createSignal("");
|
||||
const [password, setPassword] = createSignal("");
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [formErr, setFormErr] = createSignal("");
|
||||
const [version] = createResource(() => api.version().catch(() => null));
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
setFormErr(""); setBusy(true);
|
||||
try {
|
||||
if (mode() === "daftar") await api.signup(username().trim(), password());
|
||||
else await api.login(username().trim(), password());
|
||||
window.location.href = "/";
|
||||
} catch (e2) { setFormErr(String(e2)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<div class="mx-auto max-w-md py-[8vh]">
|
||||
<Card>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">Welcome to FlowSight</CardTitle>
|
||||
<CardDescription>Sign in with Google to get your own watchlist, routines, alerts and reports.</CardDescription>
|
||||
<CardTitle class="text-2xl">Masuk ke FlowSight 👋</CardTitle>
|
||||
<CardDescription>
|
||||
Daftar gratis pakai username + password — watchlist, notifikasi,
|
||||
dan report jadi milik kamu sendiri.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="text-center">
|
||||
<Show when={err()}><p class="mb-3 text-sm text-destructive">Login failed: {err()}</p></Show>
|
||||
<a href="/api/auth/start"><Button size="lg">Sign in with Google</Button></a>
|
||||
<CardContent class="space-y-3">
|
||||
<Show when={err()}><p class="text-sm text-destructive">Login gagal: {err()}</p></Show>
|
||||
<div class="flex gap-1 rounded-md bg-muted p-1">
|
||||
<Button size="sm" variant={mode() === "masuk" ? "default" : "ghost"} class="flex-1" onClick={() => setMode("masuk")}>Masuk</Button>
|
||||
<Button size="sm" variant={mode() === "daftar" ? "default" : "ghost"} class="flex-1" onClick={() => setMode("daftar")}>Daftar baru</Button>
|
||||
</div>
|
||||
<form class="space-y-3" onSubmit={submit}>
|
||||
<TextField><TextFieldInput placeholder="username (mis. budi)" value={username()} onInput={(e) => setUsername(e.currentTarget.value)} autocomplete="username" /></TextField>
|
||||
<TextField><TextFieldInput type="password" placeholder="password (min. 8 karakter)" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} autocomplete={mode() === "daftar" ? "new-password" : "current-password"} /></TextField>
|
||||
<Show when={formErr()}><p class="text-sm text-destructive">{formErr()}</p></Show>
|
||||
<Button type="submit" size="lg" class="w-full" disabled={busy()}>{busy() ? "…" : mode() === "daftar" ? "Daftar & Masuk" : "Masuk"}</Button>
|
||||
</form>
|
||||
<Show when={version()?.google_configured}>
|
||||
<div class="pt-1 text-center">
|
||||
<p class="mb-2 text-xs text-muted-foreground">atau</p>
|
||||
<a href="/api/auth/start"><Button size="lg" variant="outline" class="w-full">Sign in with Google</Button></a>
|
||||
</div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -101,10 +137,10 @@ function Shell(props: RouteSectionProps) {
|
||||
const logout = async () => { await api.logout(); refetch(); };
|
||||
return (
|
||||
<div class="flex min-h-screen bg-background text-foreground">
|
||||
<Nav />
|
||||
<Nav loggedIn={!!me()} />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<header class="sticky top-0 z-30 flex items-center gap-3 border-b bg-background/95 px-4 py-2.5 backdrop-blur">
|
||||
<TickerSearch />
|
||||
<TickerSearch loggedIn={!!me()} />
|
||||
<span class="flex-1" />
|
||||
<ThemeToggle />
|
||||
<AuthButton me={me()} onLogout={logout} />
|
||||
@@ -112,14 +148,32 @@ function Shell(props: RouteSectionProps) {
|
||||
<main class="mx-auto w-full max-w-6xl flex-1 space-y-6 p-4 pb-20 lg:p-6">{props.children}</main>
|
||||
</div>
|
||||
<aside class="hidden w-80 shrink-0 flex-col gap-4 border-l bg-card p-4 xl:flex">
|
||||
<WatchlistDrawer />
|
||||
<ChatSidebar />
|
||||
<Show when={me()} fallback={
|
||||
<Card><CardHeader class="pb-2"><CardTitle class="text-base">🔒 Login dulu</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-2"><p class="text-sm text-muted-foreground">Watchlist, filter, dan AI chat milikmu sendiri setelah login.</p>
|
||||
<a href="/login"><Button size="sm" class="w-full">Masuk / Daftar</Button></a></CardContent></Card>
|
||||
}>
|
||||
<WatchlistDrawer />
|
||||
<ChatSidebar />
|
||||
</Show>
|
||||
</aside>
|
||||
<MobileNav />
|
||||
<MobileNav loggedIn={!!me()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Gate: bungkus halaman fitur — belum login tampilkan ButuhLogin.
|
||||
export function Gate(props: { fitur: string; children: import("solid-js").JSX.Element }) {
|
||||
const { me } = useAuth();
|
||||
return (
|
||||
<Show when={me() !== null} fallback={<p class="text-sm text-muted-foreground">Memeriksa login…</p>}>
|
||||
<Show when={me()} fallback={<ButuhLogin fitur={props.fitur} />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router root={Shell}>
|
||||
|
||||
@@ -54,6 +54,9 @@ export const api = {
|
||||
chat: (message: string) => req<{ answer: string }>(`/api/chat`, { method: "POST", body: JSON.stringify({ message }) }),
|
||||
me: () => req<{ user: AuthUser }>(`/api/auth/me`),
|
||||
logout: () => req<{ ok: boolean }>(`/api/auth/logout`, { method: "POST" }),
|
||||
signup: (username: string, password: string) => req<{ user: AuthUser }>(`/api/auth/signup`, { method: "POST", body: JSON.stringify({ username, password }) }),
|
||||
login: (username: string, password: string) => req<{ user: AuthUser }>(`/api/auth/login`, { method: "POST", body: JSON.stringify({ username, password }) }),
|
||||
version: () => req<{ commit: string; started_at: string; google_configured: boolean }>(`/api/version`),
|
||||
};
|
||||
// SSE hook helper: subscribe to channels agents|alerts|activity.
|
||||
export function subscribeSSE(onEvent: (channel: string, data: string) => void): () => void {
|
||||
|
||||
@@ -15,7 +15,7 @@ const TEMPLATES: { label: string; desc: string; rule: Record<string, unknown> }[
|
||||
{ label: "📢 Semua gerakan mencolok", desc: "Skor gabungan di atas ambang", rule: { all: [{ field: "score", op: ">=", value: 60 }] } },
|
||||
];
|
||||
|
||||
export default function Alerts() {
|
||||
function AlertsInner() {
|
||||
const [alerts, { refetch }] = createResource(() => api.alerts());
|
||||
const [events, { refetch: refetchEv }] = createResource(() => api.alertEvents("2000-01-01").then((r) => r.events.slice(0, 30)));
|
||||
const [dests, { refetch: refetchDests }] = createResource(() => api.destinations());
|
||||
@@ -127,3 +127,9 @@ export default function Alerts() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { Gate } from "../index";
|
||||
|
||||
export default function Alerts() {
|
||||
return <Gate fitur="Notifikasi">{AlertsInner()}</Gate>;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../co
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
Chart.register(...registerables);
|
||||
|
||||
export default function Portfolio() {
|
||||
function PortfolioInner() {
|
||||
const [risk] = createResource(() => api.risk());
|
||||
const conc = () => risk()?.concentration || [];
|
||||
const betaN = () => Number(risk()?.beta ?? 1);
|
||||
@@ -66,3 +66,9 @@ export default function Portfolio() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { Gate } from "../index";
|
||||
|
||||
export default function Portfolio() {
|
||||
return <Gate fitur="Portofolio">{PortfolioInner()}</Gate>;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { TextField, TextFieldInput } from "../components/ui/text-field";
|
||||
|
||||
export default function Report() {
|
||||
function ReportInner() {
|
||||
const params = useParams();
|
||||
const [rep, setRep] = createSignal<ReportPayload | null>(null);
|
||||
const [md, setMd] = createSignal("");
|
||||
@@ -103,3 +103,9 @@ export default function Report() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { Gate } from "../index";
|
||||
|
||||
export default function Report() {
|
||||
return <Gate fitur="Report saham">{<ReportInner />}</Gate>;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const MANFAAT: Record<string, { judul: string; desc: string }> = {
|
||||
"weekend-review": { judul: "📝 Review mingguan", desc: "Ringkasan seminggu: apa yang terjadi dan pelajaran." },
|
||||
};
|
||||
|
||||
export default function Routines() {
|
||||
function RoutinesInner() {
|
||||
const [data, { refetch }] = createResource(() => api.routines());
|
||||
const [runs, { refetch: refetchRuns }] = createResource(() => api.runs().then((r) => r.runs.slice(0, 20)));
|
||||
const [type_, setType] = createSignal("morning-briefing");
|
||||
@@ -97,3 +97,9 @@ export default function Routines() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { Gate } from "../index";
|
||||
|
||||
export default function Routines() {
|
||||
return <Gate fitur="Jadwal otomatis">{RoutinesInner()}</Gate>;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,16 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../co
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
|
||||
// Preset awam -> body /api/screen.
|
||||
// Preset awam -> body /api/screen. PRESET_SEMUA = default auto-jalan.
|
||||
export const PRESET_SEMUA = { label: "📦 Semua — urut paling menarik", desc: "Tanpa filter, ranking gabungan", body: { limit: 20 } };
|
||||
const PRESETS: { label: string; desc: string; body: Record<string, unknown> }[] = [
|
||||
{ label: "🔥 Yang lagi diborong bandar", desc: "Broker besar net-beli besar", body: { institutional: { broker_score_min: 5 }, limit: 20 } },
|
||||
{ label: "🌍 Yang asing lagi beli", desc: "Uang luar negeri masuk", body: { institutional: { foreign_inflow: true }, limit: 20 } },
|
||||
{ label: "🕵️ Yang orang dalamnya ikut beli", desc: "Insider buying terdeteksi", body: { institutional: { insider_buying: true }, limit: 20 } },
|
||||
{ label: "📦 Semua — urut paling menarik", desc: "Tanpa filter, ranking gabungan", body: { limit: 20 } },
|
||||
PRESET_SEMUA,
|
||||
];
|
||||
|
||||
export default function Screener() {
|
||||
function ScreenerInner() {
|
||||
const navigate = useNavigate();
|
||||
const [rows, setRows] = createSignal<ScreenRow[]>([]);
|
||||
const [ran, setRan] = createSignal(false);
|
||||
@@ -31,6 +32,8 @@ export default function Screener() {
|
||||
} catch (e) { setErr(String(e)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
// Default = semua: auto-jalan preset "Semua" sekali saat halaman dibuka.
|
||||
if (!ran() && !busy()) void runPreset(PRESET_SEMUA);
|
||||
const hasil = () => rows().map((r) => ({ row: r, ...verdictFor(r) }));
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
@@ -77,3 +80,9 @@ export default function Screener() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import { Gate } from "../index";
|
||||
|
||||
export default function Screener() {
|
||||
return <Gate fitur="Cari saham">{ScreenerInner()}</Gate>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user