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:
103
kediritechnopark-app/src/components/Auth/ForgotForm.tsx
Normal file
103
kediritechnopark-app/src/components/Auth/ForgotForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
kediritechnopark-app/src/components/Auth/LoginForm.tsx
Normal file
151
kediritechnopark-app/src/components/Auth/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
kediritechnopark-app/src/components/Auth/PasswordMeter.tsx
Normal file
41
kediritechnopark-app/src/components/Auth/PasswordMeter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
172
kediritechnopark-app/src/components/Auth/SignupForm.tsx
Normal file
172
kediritechnopark-app/src/components/Auth/SignupForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
kediritechnopark-app/src/components/Auth/VerifyForm.tsx
Normal file
68
kediritechnopark-app/src/components/Auth/VerifyForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
kediritechnopark-app/src/components/Background/TechParticles.tsx
Normal file
213
kediritechnopark-app/src/components/Background/TechParticles.tsx
Normal 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;
|
||||
35
kediritechnopark-app/src/components/UI/AnimatedAlerts.tsx
Normal file
35
kediritechnopark-app/src/components/UI/AnimatedAlerts.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
38
kediritechnopark-app/src/components/UI/Field.tsx
Normal file
38
kediritechnopark-app/src/components/UI/Field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
115
kediritechnopark-app/src/components/UI/TechLinkedParticlesBG.jsx
Normal file
115
kediritechnopark-app/src/components/UI/TechLinkedParticlesBG.jsx
Normal 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" }} />;
|
||||
}
|
||||
Reference in New Issue
Block a user