diff --git a/functions/approve-user.ts b/functions/approve-user.ts new file mode 100644 index 0000000..3dd10af --- /dev/null +++ b/functions/approve-user.ts @@ -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 { + 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" } } + ); + } +} diff --git a/functions/get-visits.ts b/functions/get-visits.ts new file mode 100644 index 0000000..1b57e68 --- /dev/null +++ b/functions/get-visits.ts @@ -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 { + 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" }, + } + ); + } +} diff --git a/functions/list-users.ts b/functions/list-users.ts new file mode 100644 index 0000000..4580cd6 --- /dev/null +++ b/functions/list-users.ts @@ -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 { + 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" } } + ); + } +} diff --git a/functions/login-user.ts b/functions/login-user.ts index bf15830..5722655 100644 --- a/functions/login-user.ts +++ b/functions/login-user.ts @@ -49,7 +49,7 @@ export default async function (req: Request): Promise { 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 { }); } + 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 { }); } - 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" }, }); diff --git a/functions/register-user.ts b/functions/register-user.ts index e3ce76f..1cc8712 100644 --- a/functions/register-user.ts +++ b/functions/register-user.ts @@ -55,17 +55,6 @@ export default async function (req: Request): Promise { 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 { 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) { diff --git a/functions/save-site-data.ts b/functions/save-site-data.ts new file mode 100644 index 0000000..6515ee6 --- /dev/null +++ b/functions/save-site-data.ts @@ -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 { + 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 { + 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" } } + ); + } +} diff --git a/functions/track-visit.ts b/functions/track-visit.ts new file mode 100644 index 0000000..c6476b9 --- /dev/null +++ b/functions/track-visit.ts @@ -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 { + 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" }, + } + ); + } +} diff --git a/index.html b/index.html index 5c2ee7d..ff5a27f 100644 --- a/index.html +++ b/index.html @@ -4,15 +4,28 @@ - + LJN Cikembar | Solusi WiFi Cerdas - + diff --git a/migrations/20260824050000_app-users-approved.sql b/migrations/20260824050000_app-users-approved.sql new file mode 100644 index 0000000..c445daa --- /dev/null +++ b/migrations/20260824050000_app-users-approved.sql @@ -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; diff --git a/migrations/20260824060000_site-data.sql b/migrations/20260824060000_site-data.sql new file mode 100644 index 0000000..f958f94 --- /dev/null +++ b/migrations/20260824060000_site-data.sql @@ -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); diff --git a/migrations/20260825025042_visitor-stats.sql b/migrations/20260825025042_visitor-stats.sql new file mode 100644 index 0000000..887d3c9 --- /dev/null +++ b/migrations/20260825025042_visitor-stats.sql @@ -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; diff --git a/src/App.tsx b/src/App.tsx index b2d9b76..2924a8f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( - - + + }> } /> @@ -123,11 +124,19 @@ export default function App() { } /> + }> + + + } + /> - - + + ) } diff --git a/src/components/PromoPopup.tsx b/src/components/PromoPopup.tsx index c8130ed..1518239 100644 --- a/src/components/PromoPopup.tsx +++ b/src/components/PromoPopup.tsx @@ -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() {
-

+

{promo.description}

diff --git a/src/components/VisitorCounter.tsx b/src/components/VisitorCounter.tsx new file mode 100644 index 0000000..53ad6e6 --- /dev/null +++ b/src/components/VisitorCounter.tsx @@ -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(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 ( +
+ + {count === null + ? "Memuat..." + : `Dikunjungi ${count.toLocaleString("id-ID")} kali`} +
+ ) +} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index ba435e5..31e0553 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -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 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) } diff --git a/src/context/SiteContext.tsx b/src/context/SiteContext.tsx index 2bc5ff3..f9737d3 100644 --- a/src/context/SiteContext.tsx +++ b/src/context/SiteContext.tsx @@ -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(null) -export function SiteProvider({ children }: { children: ReactNode }) { - const [data, setData] = useState(() => { - 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 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(loadInitial) + const dataRef = useRef(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 }> | 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) => { - 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) => { - 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) => { - 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) => { - 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) => { - 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) => { - 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) => { - 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) => { 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 ( diff --git a/src/data/site.ts b/src/data/site.ts index 7ab3519..4bf3d19 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -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", diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 6497e4b..2d6bd7b 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -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() { diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index cccafc7..cab061a 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -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() { +
) } \ No newline at end of file diff --git a/src/pages/AdminLogin.tsx b/src/pages/AdminLogin.tsx index 539fb76..c457c3e 100644 --- a/src/pages/AdminLogin.tsx +++ b/src/pages/AdminLogin.tsx @@ -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) { diff --git a/src/pages/PromoForm.tsx b/src/pages/PromoForm.tsx index d25874f..e873e6c 100644 --- a/src/pages/PromoForm.tsx +++ b/src/pages/PromoForm.tsx @@ -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" /> -
-
- - -
-
+ + ))} + + )} + + + + ) +} diff --git a/src/types/site.ts b/src/types/site.ts index 32308c0..ea0dccd 100644 --- a/src/types/site.ts +++ b/src/types/site.ts @@ -49,7 +49,6 @@ export interface PromoData { ctaText: string message: string scrollThreshold: number - showOnce: boolean } export interface SiteData { diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..1323cda --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +}