update promo

This commit is contained in:
2026-08-25 10:56:46 +07:00
parent d8e4e8a502
commit a61f3c1f23
24 changed files with 697 additions and 134 deletions
+57
View File
@@ -0,0 +1,57 @@
import { createAdminClient } from "npm:@insforge/sdk";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export default async function (req: Request): Promise<Response> {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const { id, approved } = await req.json();
if (typeof id !== "string" || id.length === 0) {
return new Response(JSON.stringify({ error: "ID user tidak valid." }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const admin = createAdminClient({
baseUrl: Deno.env.get("INSFORGE_BASE_URL")!,
apiKey: Deno.env.get("INSFORGE_API_KEY")!,
});
const { error } = await admin.database
.from("app_users")
.update({ approved: approved === true })
.eq("id", id);
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (err) {
return new Response(
JSON.stringify({ error: err instanceof Error ? err.message : "Unknown error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { createAdminClient } from "npm:@insforge/sdk";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export default async function (req: Request): Promise<Response> {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
try {
const admin = createAdminClient({
baseUrl: Deno.env.get("INSFORGE_BASE_URL")!,
apiKey: Deno.env.get("INSFORGE_API_KEY")!,
});
const { data, error } = await admin.database
.from("visitor_stats")
.select("count")
.eq("id", "global")
.maybeSingle();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ count: Number(data?.count ?? 0) }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (err) {
return new Response(
JSON.stringify({ error: err instanceof Error ? err.message : "Unknown error" }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { createAdminClient } from "npm:@insforge/sdk";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export default async function (req: Request): Promise<Response> {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const admin = createAdminClient({
baseUrl: Deno.env.get("INSFORGE_BASE_URL")!,
apiKey: Deno.env.get("INSFORGE_API_KEY")!,
});
const { data, error } = await admin.database
.from("app_users")
.select("id, username, approved, created_at")
.order("created_at", { ascending: true });
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ users: data ?? [] }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (err) {
return new Response(
JSON.stringify({ error: err instanceof Error ? err.message : "Unknown error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
}
+13 -2
View File
@@ -49,7 +49,7 @@ export default async function (req: Request): Promise<Response> {
const { data: user } = await admin.database
.from("app_users")
.select("username, password_hash, salt")
.select("username, password_hash, salt, approved")
.eq("username", username.trim())
.maybeSingle();
@@ -60,6 +60,13 @@ export default async function (req: Request): Promise<Response> {
});
}
if (!user.approved) {
return new Response(
JSON.stringify({ error: "Akun Anda belum disetujui oleh admin." }),
{ status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const pepper = Deno.env.get("AUTH_PEPPER") ?? "";
const hash = await sha256Hex(`${user.salt}|${password}|${pepper}`);
@@ -70,7 +77,11 @@ export default async function (req: Request): Promise<Response> {
});
}
return new Response(JSON.stringify({ ok: true, username: user.username }), {
const token = await sha256Hex(`${user.username}|${pepper}`);
return new Response(
JSON.stringify({ ok: true, username: user.username, token }),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
+1 -12
View File
@@ -55,17 +55,6 @@ export default async function (req: Request): Promise<Response> {
apiKey: Deno.env.get("INSFORGE_API_KEY")!,
});
const { count } = await admin.database
.from("app_users")
.select("id", { count: "exact", head: true });
if ((count ?? 0) > 0) {
return new Response(
JSON.stringify({ error: "Akun admin sudah dibuat. Pendaftaran ditutup." }),
{ status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data: existing } = await admin.database
.from("app_users")
.select("id")
@@ -84,7 +73,7 @@ export default async function (req: Request): Promise<Response> {
const hash = await sha256Hex(`${salt}|${password}|${pepper}`);
const { error } = await admin.database.from("app_users").insert([
{ username: username.trim(), password_hash: hash, salt },
{ username: username.trim(), password_hash: hash, salt, approved: false },
]);
if (error) {
+103
View File
@@ -0,0 +1,103 @@
import { createAdminClient } from "npm:@insforge/sdk";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
function sha256Hex(text: string): Promise<string> {
return crypto.subtle
.digest("SHA-256", new TextEncoder().encode(text))
.then((buf) =>
Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
);
}
export default async function (req: Request): Promise<Response> {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const { username, token, data } = await req.json();
if (
typeof username !== "string" ||
typeof token !== "string" ||
!username.trim() ||
!token
) {
return new Response(JSON.stringify({ error: "Sesi admin tidak valid." }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const admin = createAdminClient({
baseUrl: Deno.env.get("INSFORGE_BASE_URL")!,
apiKey: Deno.env.get("INSFORGE_API_KEY")!,
});
const { data: user } = await admin.database
.from("app_users")
.select("username")
.eq("username", username.trim())
.eq("approved", true)
.maybeSingle();
if (!user) {
return new Response(
JSON.stringify({ error: "Hanya admin yang disetujui boleh menyimpan." }),
{ status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const pepper = Deno.env.get("AUTH_PEPPER") ?? "";
const expected = await sha256Hex(`${username.trim()}|${pepper}`);
if (expected !== token) {
return new Response(JSON.stringify({ error: "Token tidak valid." }), {
status: 403,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (!data || typeof data !== "object") {
return new Response(JSON.stringify({ error: "Data tidak valid." }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const { error } = await admin.database.from("site_data").upsert({
id: "main",
data,
updated_at: new Date().toISOString(),
});
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (err) {
return new Response(
JSON.stringify({ error: err instanceof Error ? err.message : "Unknown error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { createAdminClient } from "npm:@insforge/sdk";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export default async function (req: Request): Promise<Response> {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const admin = createAdminClient({
baseUrl: Deno.env.get("INSFORGE_BASE_URL")!,
apiKey: Deno.env.get("INSFORGE_API_KEY")!,
});
const { data, error } = await admin.database.rpc("increment_visitor");
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ count: Number(data) }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (err) {
return new Response(
JSON.stringify({ error: err instanceof Error ? err.message : "Unknown error" }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
}
+17 -4
View File
@@ -4,15 +4,28 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="PT. Lintas Jaringan Indonesia - Solusi WiFi Cerdas untuk Rumah & Bisnis Anda. Cabang Cikembar Sukabumi." />
<meta
name="description"
content="PT. Lintas Jaringan Nusantara - Solusi WiFi Cerdas untuk Rumah & Bisnis Anda. Cabang Cikembar Sukabumi."
/>
<title>LJN Cikembar | Solusi WiFi Cerdas</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;900&display=swap" rel="stylesheet" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;900&display=swap"
rel="stylesheet"
/>
<!-- Fallback font sementara Google Fonts loading — cegah FOIT -->
<style>
body { font-family: system-ui, -apple-system, sans-serif; }
.fonts-loaded body { font-family: 'Inter', system-ui, sans-serif; }
body {
font-family:
system-ui,
-apple-system,
sans-serif;
}
.fonts-loaded body {
font-family: "Inter", system-ui, sans-serif;
}
</style>
</head>
<body>
@@ -0,0 +1,4 @@
ALTER TABLE public.app_users
ADD COLUMN IF NOT EXISTS approved boolean NOT NULL DEFAULT false;
UPDATE public.app_users SET approved = true;
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS public.site_data (
id text PRIMARY KEY DEFAULT 'main',
data jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.site_data ENABLE ROW LEVEL SECURITY;
GRANT SELECT ON public.site_data TO anon, authenticated;
DROP POLICY IF EXISTS "site_data public read" ON public.site_data;
CREATE POLICY "site_data public read" ON public.site_data
FOR SELECT TO anon, authenticated USING (true);
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS public.visitor_stats (
id text PRIMARY KEY,
count bigint NOT NULL DEFAULT 0
);
INSERT INTO public.visitor_stats (id, count)
VALUES ('global', 0)
ON CONFLICT (id) DO NOTHING;
CREATE OR REPLACE FUNCTION public.increment_visitor()
RETURNS bigint
LANGUAGE sql
AS $$
UPDATE public.visitor_stats
SET count = count + 1
WHERE id = 'global'
RETURNING count;
$$;
ALTER TABLE public.visitor_stats ENABLE ROW LEVEL SECURITY;
CREATE POLICY visitor_stats_select ON public.visitor_stats
FOR SELECT TO anon
USING (true);
GRANT SELECT ON public.visitor_stats TO anon;
GRANT EXECUTE ON FUNCTION public.increment_visitor() TO anon, authenticated;
+13 -4
View File
@@ -18,6 +18,7 @@ const ContactForm = lazy(() => import("@/pages/ContactForm"))
const SocialForm = lazy(() => import("@/pages/SocialForm"))
const GalleryForm = lazy(() => import("@/pages/GalleryForm"))
const PromoForm = lazy(() => import("@/pages/PromoForm"))
const UserManager = lazy(() => import("@/pages/UserManager"))
function AdminFallback() {
return (
@@ -44,8 +45,8 @@ function AdminGate() {
export default function App() {
return (
<BrowserRouter>
<SiteProvider>
<AuthProvider>
<AuthProvider>
<SiteProvider>
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<LandingPage />} />
@@ -123,11 +124,19 @@ export default function App() {
</Suspense>
}
/>
<Route
path="users"
element={
<Suspense fallback={<AdminFallback />}>
<UserManager />
</Suspense>
}
/>
</Route>
</Routes>
<Toaster />
</AuthProvider>
</SiteProvider>
</SiteProvider>
</AuthProvider>
</BrowserRouter>
)
}
+6 -6
View File
@@ -7,8 +7,6 @@ import { toast } from "sonner"
import { useSite } from "@/context/SiteContext"
import { insforge } from "@/lib/insforge"
const DISMISS_KEY = "ljn-promo-dismissed"
export default function PromoPopup() {
const { data } = useSite()
const { promo, contact } = data
@@ -21,11 +19,11 @@ export default function PromoPopup() {
useEffect(() => {
if (!promo.enabled) return
if (promo.showOnce && localStorage.getItem(DISMISS_KEY)) return
if (triggeredRef.current) return
const threshold = Math.max(promo.scrollThreshold, 0)
const onScroll = () => {
if (triggeredRef.current) return
if (window.scrollY >= threshold) {
triggeredRef.current = true
setOpen(true)
@@ -34,16 +32,18 @@ export default function PromoPopup() {
onScroll()
window.addEventListener("scroll", onScroll, { passive: true })
return () => window.removeEventListener("scroll", onScroll)
}, [promo.enabled, promo.showOnce, promo.scrollThreshold])
}, [promo.enabled, promo.scrollThreshold])
const dismiss = () => {
triggeredRef.current = true
if (promo.showOnce) localStorage.setItem(DISMISS_KEY, "1")
setOpen(false)
setStep("intro")
setName("")
setPhone("")
setSubmitting(false)
toast("Untuk melihat promo lagi, silakan muat ulang halaman.", {
duration: 4000,
})
}
const openWhatsApp = (claimName?: string, claimPhone?: string) => {
@@ -120,7 +120,7 @@ export default function PromoPopup() {
</div>
<div className="p-6 space-y-6">
<p className="text-base font-bold text-black/90 leading-relaxed">
<p className="text-base font-bold text-black/90 leading-relaxed whitespace-pre-line">
{promo.description}
</p>
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from "react"
import { Eye } from "lucide-react"
import { insforge } from "@/lib/insforge"
export default function VisitorCounter() {
const [count, setCount] = useState<number | null>(null)
useEffect(() => {
let active = true
const track = async () => {
try {
const { data } = await insforge.functions.invoke("track-visit", {
method: "POST",
})
if (active && data && typeof data.count === "number") {
setCount(data.count)
}
} catch {
// jangan ganggu UX kalau gagal
}
}
track()
return () => {
active = false
}
}, [])
return (
<div className="fixed bottom-4 right-4 z-40 flex items-center gap-1.5 rounded-full border-2 border-black bg-black/80 px-3 py-1.5 text-xs font-black text-white shadow-brutal backdrop-blur">
<Eye className="h-3.5 w-3.5 text-yellow-300" />
{count === null
? "Memuat..."
: `Dikunjungi ${count.toLocaleString("id-ID")} kali`}
</div>
)
}
+4 -3
View File
@@ -3,12 +3,13 @@ import { toast } from "sonner"
export interface AuthUser {
username: string
token: string
}
interface AuthContextType {
user: AuthUser | null
loading: boolean
login: (username: string) => void
login: (username: string, token: string) => void
logout: () => Promise<void>
refresh: () => void
}
@@ -31,8 +32,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setLoading(false)
}, [])
const login = (username: string) => {
const u = { username }
const login = (username: string, token: string) => {
const u = { username, token }
localStorage.setItem(SESSION_KEY, JSON.stringify(u))
setUser(u)
}
+95 -79
View File
@@ -1,6 +1,8 @@
import { createContext, useContext, useState, type ReactNode } from "react"
import { createContext, useContext, useState, useRef, useEffect, type ReactNode } from "react"
import type { SiteData, HeroData, PackageData, Benefit, AboutData, GalleryImage, PromoData } from "@/types/site"
import { defaultSiteData } from "@/data/site"
import { insforge } from "@/lib/insforge"
import { useAuth } from "@/context/AuthContext"
interface SiteContextType {
@@ -21,10 +23,10 @@ interface SiteContextType {
const SiteContext = createContext<SiteContextType | null>(null)
export function SiteProvider({ children }: { children: ReactNode }) {
const [data, setData] = useState<SiteData>(() => {
const stored = localStorage.getItem("ljn-site-data")
if (!stored) return defaultSiteData
function loadInitial(): SiteData {
const stored = localStorage.getItem("ljn-site-data")
if (!stored) return defaultSiteData
try {
const parsed = JSON.parse(stored) as Partial<SiteData>
return {
...defaultSiteData,
@@ -32,118 +34,132 @@ export function SiteProvider({ children }: { children: ReactNode }) {
gallery: parsed.gallery ?? defaultSiteData.gallery,
promo: parsed.promo ?? defaultSiteData.promo,
}
})
} catch {
return defaultSiteData
}
}
export function SiteProvider({ children }: { children: ReactNode }) {
const { user } = useAuth()
const [data, setDataState] = useState<SiteData>(loadInitial)
const dataRef = useRef<SiteData>(data)
const token = user?.token
const username = user?.username
const persist = (next: SiteData) => {
try {
localStorage.setItem("ljn-site-data", JSON.stringify(next))
} catch {
// ignore quota errors
}
if (token && username) {
insforge.functions
.invoke("save-site-data", { body: { username, token, data: next } })
.then(() => {}, () => {})
}
}
const commit = (next: SiteData) => {
dataRef.current = next
setDataState(next)
persist(next)
}
useEffect(() => {
let active = true
try {
insforge.database
.from("site_data")
.select("data")
.eq("id", "main")
.then(
({
data: rows,
error,
}: {
data: Array<{ data: Partial<SiteData> }> | null
error: unknown
}) => {
if (active && !error && Array.isArray(rows) && rows[0]?.data) {
const next = { ...dataRef.current, ...rows[0].data }
dataRef.current = next
setDataState(next)
}
},
() => {}
)
} catch {
// table may not be in client schema cache yet; keep defaults
}
return () => {
active = false
}
}, [])
const updateHero = (hero: Partial<HeroData>) => {
setData((prev) => {
const next = { ...prev, hero: { ...prev.hero, ...hero } }
localStorage.setItem("ljn-site-data", JSON.stringify(next))
return next
})
commit({ ...dataRef.current, hero: { ...dataRef.current.hero, ...hero } })
}
const updatePackage = (id: string, pkg: Partial<PackageData>) => {
setData((prev) => {
const packages = prev.packages.map((p) =>
p.id === id ? { ...p, ...pkg } : p
)
const next = { ...prev, packages }
localStorage.setItem("ljn-site-data", JSON.stringify(next))
return next
})
const packages = dataRef.current.packages.map((p) =>
p.id === id ? { ...p, ...pkg } : p
)
commit({ ...dataRef.current, packages })
}
const addPackage = (pkg: PackageData) => {
setData((prev) => {
const next = { ...prev, packages: [...prev.packages, pkg] }
localStorage.setItem("ljn-site-data", JSON.stringify(next))
return next
})
commit({ ...dataRef.current, packages: [...dataRef.current.packages, pkg] })
}
const removePackage = (id: string) => {
setData((prev) => {
const next = { ...prev, packages: prev.packages.filter((p) => p.id !== id) }
localStorage.setItem("ljn-site-data", JSON.stringify(next))
return next
commit({
...dataRef.current,
packages: dataRef.current.packages.filter((p) => p.id !== id),
})
}
const updateBenefit = (index: number, benefit: Partial<Benefit>) => {
setData((prev) => {
const benefits = prev.benefits.map((b, i) =>
i === index ? { ...b, ...benefit } : b
)
const next = { ...prev, benefits }
localStorage.setItem("ljn-site-data", JSON.stringify(next))
return next
})
const benefits = dataRef.current.benefits.map((b, i) =>
i === index ? { ...b, ...benefit } : b
)
commit({ ...dataRef.current, benefits })
}
const updateAbout = (about: Partial<AboutData>) => {
setData((prev) => {
const next = { ...prev, about: { ...prev.about, ...about } }
localStorage.setItem("ljn-site-data", JSON.stringify(next))
return next
})
commit({ ...dataRef.current, about: { ...dataRef.current.about, ...about } })
}
const updateContact = (contact: Partial<SiteData["contact"]>) => {
setData((prev) => {
const next = { ...prev, contact: { ...prev.contact, ...contact } }
localStorage.setItem("ljn-site-data", JSON.stringify(next))
return next
})
commit({ ...dataRef.current, contact: { ...dataRef.current.contact, ...contact } })
}
const updateSocial = (index: number, social: Partial<SiteData["social"][number]>) => {
setData((prev) => {
const sc = [...prev.social]
sc[index] = { ...sc[index], ...social }
const next = { ...prev, social: sc }
localStorage.setItem("ljn-site-data", JSON.stringify(next))
return next
})
const socialList = [...dataRef.current.social]
socialList[index] = { ...socialList[index], ...social }
commit({ ...dataRef.current, social: socialList })
}
const updatePromo = (promo: Partial<PromoData>) => {
setData((prev) => {
const next = { ...prev, promo: { ...prev.promo, ...promo } }
localStorage.setItem("ljn-site-data", JSON.stringify(next))
return next
})
commit({ ...dataRef.current, promo: { ...dataRef.current.promo, ...promo } })
}
const addGalleryImage = (image: Omit<GalleryImage, "id">) => {
const nextImage: GalleryImage = { ...image, id: crypto.randomUUID() }
setData((prev) => {
const next = { ...prev, gallery: [...prev.gallery, nextImage] }
try {
localStorage.setItem("ljn-site-data", JSON.stringify(next))
} catch {
throw new Error("Penyimpanan browser penuh. Gunakan foto berukuran lebih kecil.")
}
return next
})
commit({ ...dataRef.current, gallery: [...dataRef.current.gallery, nextImage] })
}
const removeGalleryImage = (id: string) => {
setData((prev) => {
const next = { ...prev, gallery: prev.gallery.filter((g) => g.id !== id) }
localStorage.setItem("ljn-site-data", JSON.stringify(next))
return next
commit({
...dataRef.current,
gallery: dataRef.current.gallery.filter((g) => g.id !== id),
})
}
const updateGalleryCaption = (id: string, caption: string) => {
setData((prev) => {
const gallery = prev.gallery.map((g) =>
g.id === id ? { ...g, caption } : g
)
const next = { ...prev, gallery }
localStorage.setItem("ljn-site-data", JSON.stringify(next))
return next
})
const gallery = dataRef.current.gallery.map((g) =>
g.id === id ? { ...g, caption } : g
)
commit({ ...dataRef.current, gallery })
}
return (
+1 -2
View File
@@ -126,10 +126,9 @@ export const defaultSiteData: SiteData = {
message:
"Halo, saya tertarik dengan promo diskon 50% Paket Gancang. Boleh info lebih lanjut?",
scrollThreshold: 400,
showOnce: true,
},
company: {
name: "PT. Lintas Jaringan Indonesia",
name: "PT. Lintas Jaringan Nusantara",
branch: "Kantor Cabang Cikembar Sukabumi Jawa Barat",
address:
"Jl. Cirengkol, Cikembar, Kec. Cikembar, Kabupaten Sukabumi, Jawa Barat 43157",
+2
View File
@@ -13,6 +13,7 @@ import {
Home,
Image,
LogOut,
ShieldCheck,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/utils/helpers"
@@ -28,6 +29,7 @@ const menuItems = [
{ href: "/admin/contact", icon: Phone, label: "Kontak" },
{ href: "/admin/social", icon: Share2, label: "Media Sosial" },
{ href: "/admin/promo", icon: Gift, label: "Promo Pop-up" },
{ href: "/admin/users", icon: ShieldCheck, label: "Manajemen Akun" },
]
export default function AdminLayout() {
+2
View File
@@ -1,6 +1,7 @@
import { Outlet } from "react-router-dom"
import Navbar from "@/components/Navbar"
import PromoPopup from "@/components/PromoPopup"
import VisitorCounter from "@/components/VisitorCounter"
export default function MainLayout() {
return (
@@ -10,6 +11,7 @@ export default function MainLayout() {
<Outlet />
</main>
<PromoPopup />
<VisitorCounter />
</div>
)
}
+3 -3
View File
@@ -46,18 +46,18 @@ export default function AdminLogin() {
setSubmitting(false)
return
}
toast.success("Akun berhasil dibuat. Silakan masuk.")
toast.success("Akun dibuat. Menunggu persetujuan admin sebelum bisa login.")
setMode("signin")
setPassword("")
setSubmitting(false)
return
}
const { error } = await insforge.functions.invoke("login-user", {
const { data, error } = await insforge.functions.invoke("login-user", {
body: { username: username.trim(), password },
})
if (error) throw error
login(username.trim())
login(username.trim(), data?.token ?? "")
toast.success("Login berhasil!")
navigate("/admin")
} catch (err) {
+8 -18
View File
@@ -66,25 +66,15 @@ export default function PromoForm() {
className="w-full rounded-md border-2 border-black bg-white px-3 py-2 font-bold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block font-bold text-sm mb-1">
Ambang Scroll (px)
</label>
<Input
type="number"
{...register("scrollThreshold", { valueAsNumber: true, min: 0 })}
className="border-2 border-black font-bold"
/>
</div>
<label className="flex items-end gap-2 font-bold text-sm pb-2">
<input
type="checkbox"
{...register("showOnce")}
className="h-4 w-4 border-2 border-black"
/>
Tampil sekali per pengunjung
<div>
<label className="block font-bold text-sm mb-1">
Ambang Scroll (px)
</label>
<Input
type="number"
{...register("scrollThreshold", { valueAsNumber: true, min: 0 })}
className="border-2 border-black font-bold"
/>
</div>
<Button type="submit" variant="brutal" disabled={!isDirty}>
Simpan Perubahan
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { insforge } from "@/lib/insforge"
import { Check, X, Loader2, ShieldCheck, UserLock } from "lucide-react"
interface AppUser {
id: string
username: string
approved: boolean
created_at: string
}
export default function UserManager() {
const [users, setUsers] = useState<AppUser[]>([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
const load = async () => {
setLoading(true)
try {
const { data, error } = await insforge.functions.invoke("list-users")
if (error) throw error
setUsers(data?.users ?? [])
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Gagal memuat daftar akun."
)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const setApproved = async (id: string, approved: boolean) => {
setBusyId(id)
try {
const { error } = await insforge.functions.invoke("approve-user", {
body: { id, approved },
})
if (error) throw error
toast.success(approved ? "Akun disetujui." : "Persetujuan dicabut.")
setUsers((prev) =>
prev.map((u) => (u.id === id ? { ...u, approved } : u))
)
} catch (err) {
toast.error(err instanceof Error ? err.message : "Gagal memproses.")
} finally {
setBusyId(null)
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-black">Manajemen Akun</h1>
<p className="text-sm font-bold text-gray-500">
Setujui akun baru sebelum bisa login
</p>
</div>
<Card className="border-4 border-black shadow-brutal-sm">
<CardHeader className="bg-yellow-300 border-b-4 border-black">
<CardTitle className="font-black flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
Daftar Akun ({users.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-10 font-bold text-gray-500">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Memuat...
</div>
) : users.length === 0 ? (
<div className="py-10 text-center font-bold text-gray-500">
Belum ada akun terdaftar.
</div>
) : (
<ul className="divide-y-2 divide-black">
{users.map((u) => (
<li
key={u.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<div className="min-w-0">
<p className="font-black truncate">{u.username}</p>
<p className="text-xs font-bold text-gray-500">
{new Date(u.created_at).toLocaleDateString("id-ID")}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{u.approved ? (
<span className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-md bg-green-300 border-2 border-black text-xs font-black">
<Check className="h-3 w-3" /> Disetujui
</span>
) : (
<span className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-300 border-2 border-black text-xs font-black">
<UserLock className="h-3 w-3" /> Pending
</span>
)}
{u.approved ? (
<Button
variant="destructive"
size="sm"
onClick={() => setApproved(u.id, false)}
disabled={busyId === u.id}
>
<X className="h-4 w-4" />
Cabut
</Button>
) : (
<Button
variant="brutal"
size="sm"
className="bg-green-500 text-white"
onClick={() => setApproved(u.id, true)}
disabled={busyId === u.id}
>
<Check className="h-4 w-4" />
Setujui
</Button>
)}
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
)
}
-1
View File
@@ -49,7 +49,6 @@ export interface PromoData {
ctaText: string
message: string
scrollThreshold: number
showOnce: boolean
}
export interface SiteData {
+8
View File
@@ -0,0 +1,8 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}