update promo
This commit is contained in:
@@ -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" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -49,7 +49,6 @@ export interface PromoData {
|
||||
ctaText: string
|
||||
message: string
|
||||
scrollThreshold: number
|
||||
showOnce: boolean
|
||||
}
|
||||
|
||||
export interface SiteData {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user