feat(auth,docs): add AGENTS guidelines, consistency kit, particle background, solid card, logo swap, header toggles, signup password rules, and phone input border fix

This commit is contained in:
2025-08-10 01:18:32 +07:00
parent bc77267291
commit 9aa4484129
40 changed files with 7288 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -0,0 +1,103 @@
import React, { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import Field from "../UI/Field";
import AnimatedAlerts from "../UI/AnimatedAlerts";
type Props = {
t: any;
palette: any;
loading: boolean;
globalOk?: string;
globalErr?: string;
shakeKey?: number;
onForgot: (data: { email?: string; whatsapp?: string }) => void;
go: (v: "login") => void;
};
type Mode = "email" | "whatsapp";
export default function ForgotForm({ t, palette, loading, globalOk, globalErr, shakeKey, onForgot, go }: Props) {
const [mode, setMode] = useState<Mode>("email");
const schema = useMemo(() =>
mode === "email"
? z.object({ email: z.string().email(t.errEmail) })
: z.object({
whatsapp: z
.string()
.regex(/^\d+$/, t.errWADigits)
.transform((v) => (v.startsWith("0") ? `62${v.slice(1)}` : v.startsWith("8") ? `62${v}` : v))
.refine((v) => v.startsWith("62"), t.errWAFormat),
}),
[mode, t]
);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<any>({ resolver: zodResolver(schema) });
const onSubmit = (data: any) => onForgot(data);
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5" noValidate>
<div className="flex gap-2 p-1 rounded-xl border" style={{ background: "rgba(255,255,255,0.7)", borderColor: palette.border }}>
<button
type="button"
className={`flex-1 py-2 rounded-lg text-sm font-medium ${mode === "email" ? "text-white shadow" : "text-gray-700 hover:bg-gray-50"}`}
style={{ background: mode === "email" ? palette.primary : "transparent" }}
onClick={() => setMode("email")}
>
{t.viaEmail}
</button>
<button
type="button"
className={`flex-1 py-2 rounded-lg text-sm font-medium ${mode === "whatsapp" ? "text-white shadow" : "text-gray-700 hover:bg-gray-50"}`}
style={{ background: mode === "whatsapp" ? palette.primary : "transparent" }}
onClick={() => setMode("whatsapp")}
>
{t.viaWA}
</button>
</div>
{mode === "email" ? (
<Field id="email" label={t.email} error={errors.email?.message as string}>
<input className="input" type="email" inputMode="email" placeholder="nama@domain.com" {...register("email")} />
</Field>
) : (
<Field id="whatsapp" label={t.whatsapp} hint={t.waHint} error={errors.whatsapp?.message as string}>
<div
className="group flex items-stretch rounded-xl border bg-white/60 dark:bg-white/5 focus-within:ring-2 focus-within:ring-[rgb(var(--accent))]"
style={{ borderColor: palette.border }}
>
<span className="inline-flex items-center px-3 rounded-l-xl text-gray-700 dark:text-gray-200 select-none">+62</span>
<input
className="flex-1 bg-transparent px-3 py-2 outline-none border-0 rounded-r-xl"
type="tel"
inputMode="numeric"
placeholder="8xxxxxxxxx"
{...register("whatsapp")}
/>
</div>
</Field>
)}
<AnimatedAlerts ok={globalOk} err={globalErr} shakeKey={shakeKey} />
<button className="btn-primary w-full" style={{ background: palette.primary }} disabled={loading}>
{loading ? (t.lang === "ID" ? "Memproses..." : "Processing...") : t.resetBtn}
</button>
<p className="text-sm text-center">
{t.backToLogin} {" "}
<button type="button" className="link" style={{ color: palette.primary }} onClick={() => go("login")}>
{t.toLogin}
</button>
</p>
</form>
);
}

View File

@@ -0,0 +1,151 @@
import React, { useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import Field from "../UI/Field";
import AnimatedAlerts from "../UI/AnimatedAlerts";
type Props = {
t: any;
palette: any;
loading: boolean;
globalOk?: string;
globalErr?: string;
shakeKey?: number;
onLoginEmail: (data: { email: string; password: string }) => void;
onLoginWARequestOTP: (data: { whatsapp: string }) => void;
go: (v: "signup" | "forgot") => void;
};
type Mode = "email" | "whatsapp";
export default function LoginForm({
t,
palette,
loading,
globalOk,
globalErr,
shakeKey,
onLoginEmail,
onLoginWARequestOTP,
go,
}: Props) {
const [mode, setMode] = React.useState<Mode>("email");
const schema = useMemo(() =>
mode === "email"
? z.object({
email: z.string().email(t.errEmail),
password: z.string().min(1, t.errPassReq),
})
: z.object({
whatsapp: z
.string()
.regex(/^\d+$/, t.errWADigits)
.transform((v) => (v.startsWith("0") ? `62${v.slice(1)}` : v.startsWith("8") ? `62${v}` : v))
.refine((v) => v.startsWith("62"), t.errWAFormat),
}),
[mode, t]
);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<any>({ resolver: zodResolver(schema) });
const onSubmit = (data: any) => {
if (mode === "email") onLoginEmail(data as { email: string; password: string });
else onLoginWARequestOTP(data as { whatsapp: string });
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5" noValidate>
<div
className="flex gap-2 p-1 rounded-xl border"
style={{ background: "rgba(255,255,255,0.7)", borderColor: palette.border }}
role="tablist"
aria-label="Login method"
>
<button
type="button"
role="tab"
aria-selected={mode === "email"}
className={`flex-1 py-2 rounded-lg text-sm font-medium ${mode === "email" ? "text-white shadow" : "text-gray-700 hover:bg-gray-50"}`}
style={{ background: mode === "email" ? palette.primary : "transparent" }}
onClick={() => setMode("email")}
>
{t.email}
</button>
<button
type="button"
role="tab"
aria-selected={mode === "whatsapp"}
className={`flex-1 py-2 rounded-lg text-sm font-medium ${mode === "whatsapp" ? "text-white shadow" : "text-gray-700 hover:bg-gray-50"}`}
style={{ background: mode === "whatsapp" ? palette.primary : "transparent" }}
onClick={() => setMode("whatsapp")}
>
{t.whatsapp}
</button>
</div>
{mode === "email" ? (
<>
<Field id="email" label={t.email} error={errors.email?.message as string}>
<input
className="input"
type="email"
inputMode="email"
placeholder="nama@domain.com"
{...register("email")}
autoComplete="email"
required
/>
</Field>
<Field id="password" label={t.password} error={errors.password?.message as string}>
<input
className="input"
type="password"
placeholder="••••••••"
{...register("password")}
autoComplete="current-password"
required
/>
</Field>
</>
) : (
<Field id="whatsapp" label={t.whatsapp} hint={t.waHint} error={errors.whatsapp?.message as string}>
<div
className="group flex items-stretch rounded-xl border bg-white/60 dark:bg-white/5 focus-within:ring-2 focus-within:ring-[rgb(var(--accent))]"
style={{ borderColor: palette.border }}
>
<span className="inline-flex items-center px-3 rounded-l-xl text-gray-700 dark:text-gray-200 select-none">+62</span>
<input
className="flex-1 bg-transparent px-3 py-2 outline-none border-0 rounded-r-xl"
type="tel"
inputMode="numeric"
placeholder="8xxxxxxxxx"
{...register("whatsapp")}
required
/>
</div>
</Field>
)}
<AnimatedAlerts ok={globalOk} err={globalErr} shakeKey={shakeKey} />
<button className="btn-primary w-full" style={{ background: palette.primary }} disabled={loading}>
{loading ? (t.lang === "ID" ? "Memproses..." : "Processing...") : mode === "email" ? t.loginBtn : t.sendOTPWA}
</button>
<div className="flex justify-between text-sm">
<button type="button" className="link" style={{ color: palette.primary }} onClick={() => go("forgot")}>
{t.forgot}?
</button>
<button type="button" className="link" style={{ color: palette.primary }} onClick={() => go("signup")}>
{t.toSignup}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,41 @@
import React from "react";
function checkPasswordStrength(pw: string) {
const rules = {
length: pw.length >= 8,
upper: /[A-Z]/.test(pw),
lower: /[a-z]/.test(pw),
number: /\d/.test(pw),
symbol: /[^A-Za-z0-9]/.test(pw),
} as const;
const passed = Object.values(rules).filter(Boolean).length;
const score = passed / 5;
return { rules, score, passed };
}
export default function PasswordMeter({ value }: { value: string }) {
const { rules, score } = checkPasswordStrength(value || "");
const bars = 5;
const filled = Math.round(score * bars);
const Item = ({ ok, text }: { ok: boolean; text: string }) => (
<li className={`text-[12px] ${ok ? "text-green-600" : "text-gray-500"}`}> {text}</li>
);
return (
<div className="mt-2 space-y-2">
<div className="h-1.5 w-full bg-gray-200 rounded">
<div
className={`h-full rounded ${filled <= 2 ? "bg-red-500" : filled <= 4 ? "bg-amber-500" : "bg-green-500"}`}
style={{ width: `${(filled / bars) * 100}%`, transition: "width .2s ease" }}
/>
</div>
<ul className="grid grid-cols-2 gap-x-4 gap-y-1">
<Item ok={rules.length} text="≥ 8 karakter / 8+ chars" />
<Item ok={rules.upper} text="Huruf besar / Uppercase" />
<Item ok={rules.lower} text="Huruf kecil / Lowercase" />
<Item ok={rules.number} text="Angka / Number" />
<Item ok={rules.symbol} text="Simbol / Symbol" />
</ul>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import React from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import Field from "../UI/Field";
import AnimatedAlerts from "../UI/AnimatedAlerts";
// Password strength bar removed; replace with concise rule checklist
type Props = {
t: any;
palette: any;
loading: boolean;
globalOk?: string;
globalErr?: string;
shakeKey?: number;
onSignup: (data: { name: string; email: string; whatsapp: string; password: string; confirm: string }) => void;
go: (v: "login") => void;
lang?: "ID" | "EN";
};
const schema = z
.object({
name: z.string().min(1, "Required"),
email: z.string().email("Invalid email"),
whatsapp: z
.string()
.regex(/^\d+$/, "Digits only")
.transform((v) => (v.startsWith("0") ? `62${v.slice(1)}` : v.startsWith("8") ? `62${v}` : v))
.refine((v) => v.startsWith("62"), "Must start with 62xxxx."),
password: z.string().min(8, "At least 8 chars"),
confirm: z.string().min(1, "Required"),
})
.refine((d) => d.password === d.confirm, {
message: "Passwords do not match",
path: ["confirm"],
});
type FormData = z.infer<typeof schema>;
export default function SignupForm({ t, palette, loading, globalOk, globalErr, shakeKey, onSignup, go, lang }: Props) {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(schema) });
const onSubmit = (data: FormData) => onSignup(data);
const pw = watch("password") || "";
function getRules(pwVal: string) {
return {
length: pwVal.length >= 8,
upper: /[A-Z]/.test(pwVal),
lower: /[a-z]/.test(pwVal),
number: /\d/.test(pwVal),
symbol: /[^A-Za-z0-9]/.test(pwVal),
};
}
const rules = getRules(pw);
const isID = lang === "ID";
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5" noValidate>
<Field id="name" label={t.name} error={errors.name?.message}>
<input className="input" placeholder={t.lang === "ID" ? "Nama kamu" : "Your name"} {...register("name")} required />
</Field>
<Field id="email" label={t.email} error={errors.email?.message}>
<input className="input" type="email" inputMode="email" placeholder="nama@domain.com" {...register("email")} required />
</Field>
<Field id="whatsapp" label={t.whatsapp} hint={t.waHint} error={errors.whatsapp?.message}>
<div
className="group flex items-stretch rounded-xl border bg-white/60 dark:bg-white/5 focus-within:ring-2 focus-within:ring-[rgb(var(--accent))]"
style={{ borderColor: palette.border }}
>
<span className="inline-flex items-center px-3 rounded-l-xl text-gray-700 dark:text-gray-200 select-none">+62</span>
<input
className="flex-1 bg-transparent px-3 py-2 outline-none border-0 rounded-r-xl"
type="tel"
inputMode="numeric"
placeholder="8xxxxxxxxx"
{...register("whatsapp")}
required
/>
</div>
</Field>
<Field id="password" label={t.password} error={errors.password?.message}>
<input className="input" type="password" placeholder={isID ? "Minimal 8 karakter" : "At least 8 characters"} {...register("password")} required />
<ul className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<li className="flex items-center gap-1.5">
{rules.length ? (
<svg width="14" height="14" viewBox="0 0 24 24" className="text-green-600" aria-hidden>
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" className="text-red-500" aria-hidden>
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
<span>{isID ? "≥ 8 karakter" : "≥ 8 characters"}</span>
</li>
<li className="flex items-center gap-1.5">
{rules.upper ? (
<svg width="14" height="14" viewBox="0 0 24 24" className="text-green-600" aria-hidden>
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" className="text-red-500" aria-hidden>
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
<span>{isID ? "Huruf besar" : "Uppercase letter"}</span>
</li>
<li className="flex items-center gap-1.5">
{rules.lower ? (
<svg width="14" height="14" viewBox="0 0 24 24" className="text-green-600" aria-hidden>
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" className="text-red-500" aria-hidden>
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
<span>{isID ? "Huruf kecil" : "Lowercase letter"}</span>
</li>
<li className="flex items-center gap-1.5">
{rules.number ? (
<svg width="14" height="14" viewBox="0 0 24 24" className="text-green-600" aria-hidden>
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" className="text-red-500" aria-hidden>
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
<span>{isID ? "Angka" : "Number"}</span>
</li>
<li className="flex items-center gap-1.5">
{rules.symbol ? (
<svg width="14" height="14" viewBox="0 0 24 24" className="text-green-600" aria-hidden>
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" className="text-red-500" aria-hidden>
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
<span>{isID ? "Simbol" : "Symbol"}</span>
</li>
</ul>
</Field>
<Field id="confirm" label={t.confirmPass} error={errors.confirm?.message}>
<input className="input" type="password" placeholder={t.lang === "ID" ? "Ulangi password" : "Repeat password"} {...register("confirm")} required />
</Field>
<AnimatedAlerts ok={globalOk} err={globalErr} shakeKey={shakeKey} />
<button className="btn-primary w-full" style={{ background: palette.primary }} disabled={loading}>
{loading ? (t.lang === "ID" ? "Memproses..." : "Processing...") : t.signupBtn}
</button>
<p className="text-sm text-center">
{t.haveAcc} {" "}
<button type="button" className="link" style={{ color: palette.primary }} onClick={() => go("login")}>
{t.toLogin}
</button>
</p>
</form>
);
}

View File

@@ -0,0 +1,68 @@
import React, { useRef } from "react";
import AnimatedAlerts from "../UI/AnimatedAlerts";
import { onlyDigits } from "../../lib/utils";
type Props = {
t: any;
palette: any;
loading: boolean;
globalOk?: string;
globalErr?: string;
shakeKey?: number;
otp: string[];
setOtp: (val: string[]) => void;
handleVerify: () => void;
go: (v: "login") => void;
};
export default function VerifyForm({ t, palette, loading, globalOk, globalErr, shakeKey, otp, setOtp, handleVerify, go }: Props) {
const otpRefs = useRef<Array<HTMLInputElement | null>>([]);
const submit = (e: React.FormEvent) => {
e.preventDefault();
handleVerify();
};
return (
<form onSubmit={submit} className="space-y-6" noValidate>
<p className="text-sm text-gray-600">{t.otpDesc}</p>
<div className="flex gap-2 justify-center">
{otp.map((v, i) => (
<input
key={i}
ref={(el) => (otpRefs.current[i] = el)}
className="w-11 h-12 text-center border rounded-lg outline-none text-lg"
style={{ borderColor: palette.border }}
value={v}
inputMode="numeric"
maxLength={1}
onChange={(e) => {
const d = onlyDigits(e.target.value).slice(0, 1);
const next = [...otp];
next[i] = d;
setOtp(next);
if (d && i < 5) otpRefs.current[i + 1]?.focus();
}}
onKeyDown={(e) => {
if (e.key === "Backspace" && !otp[i] && i > 0) otpRefs.current[i - 1]?.focus();
}}
aria-label={`OTP digit ${i + 1}`}
/>
))}
</div>
<AnimatedAlerts ok={globalOk} err={globalErr} shakeKey={shakeKey} />
<button className="btn-primary w-full" style={{ background: palette.primary }} disabled={loading}>
{loading ? (t.lang === "ID" ? "Memproses..." : "Processing...") : t.verifyBtn}
</button>
<p className="text-sm text-center">
{t.notReceive} {" "}
<button type="button" className="link" style={{ color: palette.primary }} onClick={() => go("login")}>
{t.resend}
</button>
</p>
</form>
);
}

View File

@@ -0,0 +1,213 @@
import React, { useEffect, useRef } from "react";
type Palette = {
bgFrom: string;
bgTo: string;
text: string;
glass: string;
border: string;
primary: string;
primaryHover: string;
};
type Props = {
palette: Palette;
className?: string;
// Approximate particles per (25k px^2). 2 is a good default.
density?: number;
// Speed multiplier to make motion faster/slower (1 = default).
speedMultiplier?: number;
// Depth-of-field intensity in pixels (higher = more blur off-focus).
dofIntensity?: number;
};
type Particle = {
ax: number; // anchor x (px)
ay: number; // anchor y (px)
x: number; // current x (px)
y: number; // current y (px)
phase: number; // oscillator phase
speed: number; // phase speed
ampX: number; // local oscillation amplitude x
ampY: number; // local oscillation amplitude y
radius: number; // base dot radius (px)
z: number; // 0..1 depth (0 near, 1 far)
hue: string; // color hex
};
function usePrefersReducedMotion() {
const ref = useRef(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
ref.current = mq.matches;
const handler = () => (ref.current = mq.matches);
mq.addEventListener?.("change", handler);
return () => mq.removeEventListener?.("change", handler);
}, []);
return ref;
}
export function TechParticles({ palette, className, density = 2, speedMultiplier = 1.2, dofIntensity = 6 }: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const motionPref = usePrefersReducedMotion();
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext("2d", { alpha: true })!;
let width = 0;
let height = 0;
let dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
let raf = 0;
let running = true;
let last = performance.now();
// no parallax/pointer effect per request
const colors = [palette.primary, palette.primaryHover];
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
const pick = <T,>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
let particles: Particle[] = [];
function regenerateParticles() {
const areaK = (width * height) / 25000; // per 25k px
const count = Math.max(18, Math.min(120, Math.floor(areaK * density)));
particles = new Array(count).fill(0).map(() => {
const ax = Math.random() * width;
const ay = Math.random() * height;
const radius = rand(1, 2.2);
return {
ax,
ay,
x: ax,
y: ay,
phase: Math.random() * Math.PI * 2,
// faster by default, still gentle
speed: rand(0.8, 1.8),
ampX: rand(2, 8),
ampY: rand(2, 8),
radius,
z: Math.random(),
hue: pick(colors),
} as Particle;
});
}
function resize() {
width = canvas.clientWidth;
height = canvas.clientHeight;
dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
regenerateParticles();
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
window.addEventListener("resize", resize);
function step(now: number) {
if (!running) return;
const dt = Math.min(1 / 15, (now - last) / 1000);
last = now;
ctx.clearRect(0, 0, width, height);
// soft gradient base
const grd = ctx.createLinearGradient(0, 0, 0, height);
grd.addColorStop(0, palette.bgFrom);
grd.addColorStop(1, palette.bgTo);
ctx.fillStyle = grd;
ctx.fillRect(0, 0, width, height);
const reduced = motionPref.current;
// update positions; each dot oscillates around its anchor (no center drift)
for (let p of particles) {
if (!reduced) p.phase += p.speed * dt * speedMultiplier;
p.x = p.ax + Math.cos(p.phase) * p.ampX;
p.y = p.ay + Math.sin(p.phase * 0.85) * p.ampY;
}
// connections (constellation): nearby lines
const threshold = Math.min(120, Math.max(60, Math.min(width, height) * 0.14));
for (let i = 0; i < particles.length; i++) {
const a = particles[i];
for (let j = i + 1; j < particles.length; j++) {
const b = particles[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const dist = Math.hypot(dx, dy);
if (dist > threshold) continue;
const near = 1 - dist / threshold; // 0..1
// depth-of-field for lines: blur more if away from focal plane
const meanZ = (a.z + b.z) / 2;
const lineBlur = Math.abs(meanZ - 0.5) * dofIntensity;
ctx.save();
// lines fade with distance and depth
ctx.globalAlpha = Math.max(0.06, 0.28 * near * (1 - meanZ * 0.5));
(ctx as any).filter = `blur(${lineBlur}px)`;
ctx.strokeStyle = palette.primaryHover;
ctx.lineWidth = 0.6 + near * 0.7;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
ctx.restore();
}
}
// dots
for (const p of particles) {
// depth-of-field for dots
const blur = Math.abs(p.z - 0.5) * dofIntensity;
const size = p.radius * (1.1 + (1 - p.z) * 1.4); // near = larger
const alpha = 0.5 + (1 - p.z) * 0.35; // near = more opaque
ctx.save();
ctx.globalAlpha = alpha;
(ctx as any).filter = `blur(${blur}px)`;
ctx.fillStyle = p.hue;
ctx.beginPath();
ctx.arc(p.x, p.y, size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
raf = requestAnimationFrame(step);
}
function onVis() {
running = document.visibilityState === "visible";
if (running) {
last = performance.now();
raf = requestAnimationFrame(step);
} else {
cancelAnimationFrame(raf);
}
}
document.addEventListener("visibilitychange", onVis);
raf = requestAnimationFrame(step);
return () => {
running = false;
cancelAnimationFrame(raf);
document.removeEventListener("visibilitychange", onVis);
window.removeEventListener("resize", resize);
ro.disconnect();
};
}, [palette, density]);
return (
<canvas
ref={canvasRef}
aria-hidden
className={["absolute inset-0 w-full h-full", className || ""].join(" ")}
style={{ zIndex: -5 }}
/>
);
}
export default TechParticles;

View File

@@ -0,0 +1,35 @@
import React from "react";
type Props = {
ok?: string;
err?: string;
shakeKey?: number;
};
export default function AnimatedAlerts({ ok, err, shakeKey }: Props) {
return (
<>
{err && (
<div
role="alert"
key={`e-${shakeKey}`}
className="alert-anim text-sm rounded-lg px-3 py-2 border"
style={{ background: "#fff", borderColor: "rgba(239,68,68,0.25)", color: "#B91C1C" }}
>
{err}
</div>
)}
{ok && (
<div
role="status"
key={`o-${shakeKey}`}
className="alert-anim text-sm rounded-lg px-3 py-2 border"
style={{ background: "#fff", borderColor: "rgba(34,197,94,0.25)", color: "#166534" }}
>
{ok}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,38 @@
import React from "react";
type Props = {
label: string;
hint?: string;
error?: string;
id?: string;
children: React.ReactNode;
};
export default function Field({ label, hint, error, id, children }: Props) {
const describedBy = error ? `${id}-error` : hint ? `${id}-hint` : undefined;
return (
<div className="space-y-1.5">
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
{React.isValidElement(children)
? React.cloneElement(children as React.ReactElement, {
id,
"aria-invalid": !!error || undefined,
"aria-describedby": describedBy,
})
: children}
{hint && !error && (
<p id={`${id}-hint`} className="text-[12px] text-slate-500">
{hint}
</p>
)}
{error && (
<p id={`${id}-error`} className="text-[12px] text-red-600">
{error}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,115 @@
import React, { useEffect, useRef } from "react";
import { hexToRgba } from "../../lib/utils";
export default function TechLinkedParticlesBG({ theme, speed = 2 }) {
const canvasRef = useRef(null);
const DPR = typeof window !== "undefined" ? Math.min(2, window.devicePixelRatio || 1) : 1;
const mouse = useRef({ x: 0, y: 0 });
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
let W = (canvas.width = window.innerWidth * DPR);
let H = (canvas.height = window.innerHeight * DPR);
// 3 layers: near (sharp), mid (blur), far (more blur)
const layers = [
{ count: 60, size: [1.8, 2.6], speed: 0.22, blur: 0, linkDist: 130, color: theme.primary, alpha: 0.28 },
{ count: 70, size: [2.2, 3.6], speed: 0.12, blur: 4, linkDist: 170, color: theme.primary, alpha: 0.18 },
{ count: 60, size: [2.8, 4.6], speed: 0.06, blur: 10, linkDist: 210, color: "#64748b", alpha: 0.12 },
];
const particles = layers.map((L, li) =>
Array.from({ length: L.count }).map(() => ({
x: Math.random() * W,
y: Math.random() * H,
r: (Math.random() * (L.size[1] - L.size[0]) + L.size[0]) * DPR,
vx: (Math.random() - 0.5) * L.speed * speed * DPR,
vy: (Math.random() - 0.5) * L.speed * speed * DPR,
layer: li,
tick: Math.random() * 1000,
}))
);
const onResize = () => {
W = canvas.width = window.innerWidth * DPR;
H = canvas.height = window.innerHeight * DPR;
};
const onMouseMove = (e) => {
mouse.current.x = (e.clientX / window.innerWidth - 0.5) * 2;
mouse.current.y = (e.clientY / window.innerHeight - 0.5) * 2;
};
window.addEventListener("resize", onResize);
window.addEventListener("mousemove", onMouseMove);
let raf;
const draw = () => {
// background gradient
ctx.clearRect(0, 0, W, H);
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, theme.bgFrom);
g.addColorStop(1, theme.bgTo);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
layers.forEach((L, li) => {
// update + draw particles
ctx.filter = L.blur ? `blur(${L.blur}px)` : "none";
particles[li].forEach((p) => {
p.x += p.vx; p.y += p.vy; p.tick += 0.002 + li * 0.001;
// gentle breathing scale for DoF feel
const breathe = 0.85 + Math.sin(p.tick * 2) * 0.15;
// wrap
if (p.x < -20) p.x = W + 20;
if (p.x > W + 20) p.x = -20;
if (p.y < -20) p.y = H + 20;
if (p.y > H + 20) p.y = -20;
// parallax by layer
const par = (li + 1) * 5;
const ox = mouse.current.x * par * DPR;
const oy = mouse.current.y * par * DPR;
ctx.beginPath();
ctx.fillStyle = hexToRgba(L.color, L.alpha);
ctx.arc(p.x + ox, p.y + oy, p.r * breathe, 0, Math.PI * 2);
ctx.fill();
});
// linked lines within layer (dynamic)
// (do this after drawing points to keep blur affecting only nodes)
ctx.filter = "none";
for (let i = 0; i < particles[li].length; i++) {
const a = particles[li][i];
for (let j = i + 1; j < particles[li].length; j++) {
const b = particles[li][j];
const dx = a.x - b.x, dy = a.y - b.y;
const dist = Math.hypot(dx, dy);
if (dist < L.linkDist * DPR) {
const par = (li + 1) * 5;
const ox = mouse.current.x * par * DPR;
const oy = mouse.current.y * par * DPR;
const alpha = (1 - dist / (L.linkDist * DPR)) * L.alpha * 0.9;
ctx.strokeStyle = hexToRgba(L.color, alpha);
ctx.lineWidth = (li === 0 ? 1.2 : li === 1 ? 1 : 0.8) * DPR;
ctx.beginPath();
ctx.moveTo(a.x + ox, a.y + oy);
ctx.lineTo(b.x + ox, b.y + oy);
ctx.stroke();
}
}
}
});
raf = requestAnimationFrame(draw);
};
draw();
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
window.removeEventListener("mousemove", onMouseMove);
};
}, [DPR, theme, speed]);
return <canvas ref={canvasRef} className="fixed inset-0 -z-10" style={{ width: "100vw", height: "100vh" }} />;
}

View File

@@ -0,0 +1,2 @@
// ganti ke webhook kamu
export const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL || "https://your-n8n.example/webhook/auth";

View File

@@ -0,0 +1,92 @@
export const T = {
ID: {
title: "Welcome Back 👋",
subtitle: "Akses akunmu dengan aman.",
login: "Masuk",
signup: "Buat Akun",
verify: "Verifikasi OTP",
forgot: "Lupa Password",
email: "Email",
password: "Password",
whatsapp: "WhatsApp",
viaEmail: "Via Email",
viaWA: "Via WhatsApp",
sendOTPWA: "Kirim OTP via WhatsApp",
loginBtn: "Login",
signupBtn: "Daftar",
verifyBtn: "Verifikasi",
resetBtn: "Kirim Reset",
notReceive: "Tidak menerima kode?",
resend: "Kirim ulang",
haveAcc: "Sudah punya akun?",
toLogin: "Masuk",
toSignup: "Buat akun",
backToLogin: "Kembali ke",
name: "Nama Lengkap",
confirmPass: "Konfirmasi Password",
otpDesc: "Masukkan kode 6 digit yang kami kirim.",
waHint: "Gunakan format 62xxxxxxxxxx (tanpa 0 di depan)",
enterEmailOrWA: "Isi salah satu: Email atau WhatsApp.",
tosNote: "Dengan masuk, kamu menyetujui Ketentuan Layanan & Kebijakan Privasi.",
// errors
errEmail: "Format email tidak valid.",
errPassReq: "Password wajib diisi.",
errNameReq: "Nama wajib diisi.",
errWAFormat: "Nomor WA harus format 62xxxxxxxxxx.",
errWADigits: "Nomor WA hanya boleh angka.",
errPassMismatch: "Konfirmasi password tidak cocok.",
errPassWeak: "Password belum memenuhi semua kriteria.",
errOTP: "Kode OTP harus 6 digit angka.",
// success
okLogin: "Login email berhasil.",
okSendOTP: "OTP dikirim ke WhatsApp.",
okSignup: "Signup berhasil. OTP dikirim untuk verifikasi.",
okVerify: "Verifikasi berhasil.",
okReset: "Tautan/kode reset dikirim.",
},
EN: {
title: "Welcome Back 👋",
subtitle: "Securely access your account.",
login: "Login",
signup: "Sign Up",
verify: "Verify OTP",
forgot: "Forgot Password",
email: "Email",
password: "Password",
whatsapp: "WhatsApp",
viaEmail: "Via Email",
viaWA: "Via WhatsApp",
sendOTPWA: "Send OTP via WhatsApp",
loginBtn: "Login",
signupBtn: "Create Account",
verifyBtn: "Verify",
resetBtn: "Send Reset",
notReceive: "Didn't receive the code?",
resend: "Resend",
haveAcc: "Already have an account?",
toLogin: "Login",
toSignup: "Create one",
backToLogin: "Back to",
name: "Full Name",
confirmPass: "Confirm Password",
otpDesc: "Enter the 6digit code we sent you.",
waHint: "Use 62xxxxxxxxxx format (no leading 0)",
enterEmailOrWA: "Fill one: Email or WhatsApp.",
tosNote: "By signing in, you agree to our Terms & Privacy.",
// errors
errEmail: "Invalid email format.",
errPassReq: "Password is required.",
errNameReq: "Name is required.",
errWAFormat: "Phone must be in 62xxxxxxxxxx format.",
errWADigits: "Phone number must be digits only.",
errPassMismatch: "Passwords do not match.",
errPassWeak: "Password does not meet all requirements.",
errOTP: "OTP must be 6 digits.",
// success
okLogin: "Logged in with email.",
okSendOTP: "OTP sent via WhatsApp.",
okSignup: "Sign up successful. OTP sent for verification.",
okVerify: "Verification successful.",
okReset: "Reset link/code has been sent.",
},
};

View File

@@ -0,0 +1,26 @@
export const THEME = {
light: {
bgFrom: "#f6fbff",
bgTo: "#eaf5ff",
text: "#0b1220",
glass: "rgba(255,255,255,0.9)",
border: "#E5E7EB",
primary: "#2563EB",
primaryHover: "#1E4FD7",
},
dark: {
bgFrom: "#0b1220",
bgTo: "#0f172a",
text: "#e6eefc",
glass: "rgba(255,255,255,0.08)",
border: "rgba(255,255,255,0.12)",
primary: "#4F86FF",
primaryHover: "#3B6FE6",
},
};
export const COLORS = {
success: "#22C55E",
danger: "#EF4444",
amber: "#F59E0B",
};

View File

@@ -0,0 +1,54 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Theme tokens */
:root {
--bg: 255 255 255;
--surface: 248 250 252;
--text: 17 24 39;
--primary: 255 115 0;
--accent: 14 165 233;
--muted: 100 116 139;
}
.dark {
--bg: 2 6 23;
--surface: 15 23 42;
--text: 241 245 249;
--primary: 255 140 66;
--accent: 56 189 248;
--muted: 148 163 184;
}
@layer base {
html { font-family: Inter, system-ui, ui-sans-serif, sans-serif; }
body { @apply bg-[rgb(var(--bg))] text-[rgb(var(--text))] antialiased; }
}
@layer components {
.card { @apply rounded-2xl bg-[rgb(var(--surface))]/80 backdrop-blur-xl shadow-lg; }
/* Consistent glassmorphism card for light/dark */
/* More transparent + stronger blur for true glass effect */
.glass-card { @apply rounded-2xl backdrop-blur-2xl backdrop-saturate-150 shadow-xl border bg-white/25 dark:bg-white/5 border-white/30 dark:border-white/10; }
/* Solid card (no transparency), uses theme surface */
.solid-card { @apply rounded-2xl bg-[rgb(var(--surface))] shadow-xl border border-slate-200/70 dark:border-white/10; }
.btn { @apply inline-flex items-center justify-center rounded-xl px-4 py-2 font-medium transition; }
.btn-primary { @apply btn bg-[rgb(var(--primary))] text-white hover:brightness-110 active:brightness-90; }
.input { @apply w-full rounded-xl border border-slate-200/60 bg-white/60 px-3 py-2 outline-none focus:ring-2 focus:ring-[rgb(var(--accent))]; }
}
/* micro animations used by alerts */
@keyframes shakeX {
0%,100%{transform:translateX(0)}
20%{transform:translateX(-2px)}
40%{transform:translateX(2px)}
60%{transform:translateX(-2px)}
80%{transform:translateX(2px)}
}
.alert-anim {
animation: fadeIn .18s ease, shakeX .24s ease;
}
@keyframes fadeIn {
from{opacity:0; transform:translateY(-2px)}
to{opacity:1; transform:translateY(0)}
}

View File

@@ -0,0 +1,22 @@
import { N8N_WEBHOOK_URL } from "../config/constants";
type Payload = Record<string, unknown> & {
action: string;
meta?: Record<string, unknown>;
data?: Record<string, unknown>;
};
export async function postToN8N(payload: Payload) {
const res = await fetch(N8N_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error((await res.text()) || "Request failed");
try {
return (await res.json()) as unknown;
} catch {
return {} as unknown;
}
}

View File

@@ -0,0 +1,4 @@
export function cn(...classes: Array<string | undefined | null | false>) {
return classes.filter(Boolean).join(' ');
}

View File

@@ -0,0 +1,22 @@
export const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/i;
export const onlyDigits = (s: string = "") => (s || "").replace(/\D+/g, "");
export function normalizeIDPhone(input: string) {
let d = onlyDigits(input);
if (!d) return "";
if (d.startsWith("0")) d = "62" + d.slice(1);
else if (d.startsWith("8")) d = "62" + d;
return d;
}
export function hexToRgba(hex: string, alpha: number = 1) {
if (hex.startsWith("rgb")) return hex;
const h = hex.replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const bigint = parseInt(full, 16);
const r = (bigint >> 16) & 255,
g = (bigint >> 8) & 255,
b = bigint & 255;
return `rgba(${r},${g},${b},${alpha})`;
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import AuthPage from './pages/AuthPage'
import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<AuthPage />
</React.StrictMode>,
)

View File

@@ -0,0 +1,403 @@
import React, { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import { THEME } from "../config/theme";
import { T } from "../config/i18n";
import { normalizeIDPhone, emailRegex } from "../lib/utils";
import { postToN8N } from "../lib/api";
import LoginForm from "../components/Auth/LoginForm";
import SignupForm from "../components/Auth/SignupForm";
import VerifyForm from "../components/Auth/VerifyForm";
import ForgotForm from "../components/Auth/ForgotForm";
import TechParticles from "../components/Background/TechParticles";
import ktpLogo from "../assets/kediri-technopark-logo.webp";
import ktpLogoWhite from "../assets/kediri-technopark-logo-white.webp";
type Lang = "ID" | "EN";
type View = "login" | "signup" | "verify" | "forgot";
export default function AuthPage() {
const [lang, setLang] = useState<Lang>("ID");
const [dark, setDark] = useState(false);
const palette = dark ? THEME.dark : THEME.light;
const t = T[lang];
useEffect(() => {
const el = document.documentElement;
if (dark) el.classList.add("dark");
else el.classList.remove("dark");
}, [dark]);
const [view, setView] = useState<View>("login");
const cardRef = useRef<HTMLDivElement | null>(null);
const [shellH, setShellH] = useState<string>("auto");
useEffect(() => {
const node = cardRef.current;
const ro = new ResizeObserver(() => {
if (!node) return;
const h = node.getBoundingClientRect().height;
setShellH(h + "px");
});
if (node) ro.observe(node);
return () => ro.disconnect();
}, [view]);
const go = (next: View) => {
cardRef.current?.setAttribute("data-transition", "changing");
setTimeout(() => {
setView(next);
requestAnimationFrame(() => {
cardRef.current?.setAttribute("data-transition", "idle");
});
}, 0);
};
// OTP state
const [otp, setOtp] = useState<string[]>(Array(6).fill(""));
const [loading, setLoading] = useState(false);
const [globalErr, setGlobalErr] = useState("");
const [globalOk, setGlobalOk] = useState("");
const [shakeKey, setShakeKey] = useState(0);
const triggerShake = () => setShakeKey((k) => k + 1);
// Handlers: now accept validated data from child forms
async function handleLoginEmail(data: { email: string; password: string }) {
setGlobalErr("");
setGlobalOk("");
if (!emailRegex.test(data.email)) {
setGlobalErr(t.errEmail);
return triggerShake();
}
if (!data.password) {
setGlobalErr(t.errPassReq);
return triggerShake();
}
setLoading(true);
try {
await postToN8N({
action: "login_email",
meta: { channel: "web", brand: "kloowear", lang, dark },
data: { email: data.email, password: data.password },
});
setGlobalOk(t.okLogin);
} catch (err) {
setGlobalErr((err as Error).message || "Error");
triggerShake();
} finally {
setLoading(false);
}
}
async function handleLoginWARequestOTP(data: { whatsapp: string }) {
setGlobalErr("");
setGlobalOk("");
const wa = normalizeIDPhone(data.whatsapp);
if (!wa.startsWith("62")) {
setGlobalErr(t.errWAFormat);
return triggerShake();
}
if (!/^\d+$/.test(wa)) {
setGlobalErr(t.errWADigits);
return triggerShake();
}
setLoading(true);
try {
await postToN8N({
action: "login_whatsapp_request_otp",
meta: { channel: "web", brand: "kloowear", lang, dark },
data: { whatsapp: wa },
});
setGlobalOk(t.okSendOTP);
go("verify");
} catch (err) {
setGlobalErr((err as Error).message || "Error");
triggerShake();
} finally {
setLoading(false);
}
}
function checkPasswordStrengthAll(pw: string) {
const rules = {
length: pw.length >= 8,
upper: /[A-Z]/.test(pw),
lower: /[a-z]/.test(pw),
number: /\d/.test(pw),
symbol: /[^A-Za-z0-9]/.test(pw),
};
return Object.values(rules).every(Boolean);
}
async function handleSignup(data: { name: string; email: string; whatsapp: string; password: string; confirm: string }) {
setGlobalErr("");
setGlobalOk("");
if (!data.name.trim()) {
setGlobalErr(t.errNameReq);
return triggerShake();
}
if (!emailRegex.test(data.email)) {
setGlobalErr(t.errEmail);
return triggerShake();
}
const wa = normalizeIDPhone(data.whatsapp);
if (!wa.startsWith("62")) {
setGlobalErr(t.errWAFormat);
return triggerShake();
}
if (!/^\d+$/.test(wa)) {
setGlobalErr(t.errWADigits);
return triggerShake();
}
if (data.password !== data.confirm) {
setGlobalErr(t.errPassMismatch);
return triggerShake();
}
if (!checkPasswordStrengthAll(data.password)) {
setGlobalErr(t.errPassWeak);
return triggerShake();
}
setLoading(true);
try {
await postToN8N({
action: "signup",
meta: { channel: "web", brand: "kloowear", lang, dark },
data: {
name: data.name.trim(),
email: data.email.trim(),
whatsapp: wa,
password: data.password,
},
});
setGlobalOk(t.okSignup);
go("verify");
} catch (err) {
setGlobalErr((err as Error).message || "Error");
triggerShake();
} finally {
setLoading(false);
}
}
async function handleVerify() {
setGlobalErr("");
setGlobalOk("");
const code = otp.join("");
if (!/^\d{6}$/.test(code)) {
setGlobalErr(t.errOTP);
return triggerShake();
}
setLoading(true);
try {
await postToN8N({
action: "verify_otp",
meta: { channel: "web", brand: "kloowear", lang, dark },
data: { otp: code },
});
setGlobalOk(t.okVerify);
} catch (err) {
setGlobalErr((err as Error).message || "Error");
triggerShake();
} finally {
setLoading(false);
}
}
async function handleForgot(data: { email?: string; whatsapp?: string }) {
setGlobalErr("");
setGlobalOk("");
if (data.email) {
if (!data.email.trim() || !emailRegex.test(data.email)) {
setGlobalErr(t.errEmail);
return triggerShake();
}
}
if (data.whatsapp) {
const wa = normalizeIDPhone(data.whatsapp);
if (!wa.startsWith("62")) {
setGlobalErr(t.errWAFormat);
return triggerShake();
}
if (!/^\d+$/.test(wa)) {
setGlobalErr(t.errWADigits);
return triggerShake();
}
}
setLoading(true);
try {
await postToN8N({
action: "forgot_password_request",
meta: { channel: "web", brand: "kloowear", lang, dark },
data: {
email: data.email?.trim(),
whatsapp: data.whatsapp ? normalizeIDPhone(data.whatsapp) : undefined,
},
});
setGlobalOk(t.okReset);
} catch (err) {
setGlobalErr((err as Error).message || "Error");
triggerShake();
} finally {
setLoading(false);
}
}
const renderView = () => {
switch (view) {
case "signup":
return (
<SignupForm
t={t}
palette={palette}
loading={loading}
globalOk={globalOk}
globalErr={globalErr}
shakeKey={shakeKey}
onSignup={handleSignup}
go={go}
lang={lang}
/>
);
case "verify":
return (
<VerifyForm
t={t}
palette={palette}
loading={loading}
globalOk={globalOk}
globalErr={globalErr}
shakeKey={shakeKey}
otp={otp}
setOtp={setOtp}
handleVerify={handleVerify}
go={go}
/>
);
case "forgot":
return (
<ForgotForm
t={t}
palette={palette}
loading={loading}
globalOk={globalOk}
globalErr={globalErr}
shakeKey={shakeKey}
onForgot={handleForgot}
go={go}
/>
);
default:
return (
<LoginForm
t={t}
palette={palette}
loading={loading}
globalOk={globalOk}
globalErr={globalErr}
shakeKey={shakeKey}
onLoginEmail={handleLoginEmail}
onLoginWARequestOTP={handleLoginWARequestOTP}
go={go}
/>
);
}
};
return (
<div className="min-h-screen relative overflow-hidden flex items-center justify-center px-4">
{/* Background gradient layer (responsive to light/dark palette) */}
<div
aria-hidden
className="absolute inset-0 -z-10"
style={{
background: `radial-gradient(800px circle at 50% -10%, ${palette.bgTo} 0%, transparent 60%), linear-gradient(180deg, ${palette.bgFrom} 0%, ${palette.bgTo} 100%)`,
}}
/>
{/* Particle background with depth-of-field */}
<TechParticles palette={palette} />
{/* toggles moved inside card for consistency */}
<div className="w-full max-w-md">
<div className="mb-6 text-center flex flex-col items-center gap-2">
<img
src={dark ? ktpLogoWhite : ktpLogo}
alt="Kediri Technopark"
className="h-10 md:h-12 w-auto select-none"
draggable={false}
/>
</div>
<div
className="relative"
style={{
height: shellH,
transition: "height 280ms cubic-bezier(.2,.8,.2,1)",
}}
>
<motion.div
ref={cardRef}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.24, ease: [0.2, 0.8, 0.2, 1] }}
className="solid-card p-6 md:p-7"
style={{
transformOrigin: "top center",
transition: "transform 220ms ease, opacity 200ms ease",
}}
data-transition="idle"
>
<div className="flex items-center justify-between gap-3 mb-4">
<h2 id="auth-title" className="text-lg md:text-xl font-semibold tracking-tight">
{view === "login" && t.login}
{view === "signup" && t.signup}
{view === "verify" && t.verify}
{view === "forgot" && t.forgot}
</h2>
<div className="flex items-center gap-2">
<button
type="button"
aria-label="Toggle language"
className="px-3 h-9 rounded-lg border text-sm focus:outline-none focus:ring-2"
style={{
borderColor: palette.border,
background: dark ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.6)",
}}
onClick={() => setLang((p) => (p === "ID" ? "EN" : "ID"))}
>
{lang}
</button>
<button
type="button"
aria-label="Toggle theme"
className="px-3 h-9 rounded-lg border text-sm focus:outline-none focus:ring-2 inline-flex items-center justify-center"
style={{
borderColor: palette.border,
background: dark ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.6)",
}}
onClick={() => setDark((d) => !d)}
>
{dark ? (
// Sun icon for dark mode (indicates switch to light)
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M12 4V2M12 22v-2M4.93 4.93 3.51 3.51M20.49 20.49l-1.42-1.42M4 12H2M22 12h-2M4.93 19.07 3.51 20.49M20.49 3.51l-1.42 1.42" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="1.5"/>
</svg>
) : (
// Moon icon for light mode (indicates switch to dark)
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" strokeWidth="1.5" fill="none"/>
</svg>
)}
</button>
</div>
</div>
{renderView()}
</motion.div>
</div>
<p className="text-[11px] mt-4 text-center text-muted">
{t.tosNote}
</p>
</div>
<style>{`
[data-transition="changing"]{ transform: scaleY(0.985); opacity:.99; }
[data-transition="idle"]{ transform: scaleY(1); opacity:1; }
`}</style>
</div>
);
}