/* global React, ReactDOM, Icon, useTweaks, TweaksPanel, TweakSection, TweakColor, TweakToggle, NAV_URLS */
const { useState, useEffect } = React;
const TW_DEFAULTS = /*EDITMODE-BEGIN*/{
"brand": ["#0F623F", "#07432A"],
"dark": false
}/*EDITMODE-END*/;
/* ---------------- backend API helpers ---------------- */
function csrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.content : "";
}
async function apiPost(url, body) {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-TOKEN": csrfToken(),
},
body: JSON.stringify(body),
});
let data = {};
try { data = await res.json(); } catch (e) { /* no body */ }
return { ok: res.ok, status: res.status, data };
}
/* chuẩn hoá + validate SĐT VN */
function normalizePhone(raw) { return raw.replace(/[\s.\-]/g, ""); }
function validatePhone(raw) {
const p = normalizePhone(raw);
if (!p) return { ok: false, msg: "Vui lòng nhập số điện thoại" };
if (!/^0\d{9}$/.test(p)) return { ok: false, msg: "SĐT phải gồm 10 số, bắt đầu bằng 0" };
if (!/^(03|05|07|08|09)\d{8}$/.test(p)) return { ok: false, msg: "Đầu số không hợp lệ (03/05/07/08/09)" };
return { ok: true };
}
function validateEmail(raw) {
if (!raw.trim()) return { ok: false, msg: "Vui lòng nhập email" };
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw.trim()) ? { ok: true } : { ok: false, msg: "Email không hợp lệ" };
}
/* Ngày sinh: nhập tay dd/mm/yyyy, tự chèn dấu "/" */
function formatDobInput(raw) {
const d = raw.replace(/\D/g, "").slice(0, 8);
if (d.length <= 2) return d;
if (d.length <= 4) return d.slice(0, 2) + "/" + d.slice(2);
return d.slice(0, 2) + "/" + d.slice(2, 4) + "/" + d.slice(4);
}
function validateDob(raw) {
if (!raw.trim()) return { ok: true }; // optional
const m = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (!m) return { ok: false, msg: "Nhập đủ ngày sinh theo dạng dd/mm/yyyy" };
const d = +m[1], mo = +m[2], y = +m[3];
const dt = new Date(y, mo - 1, d);
const valid = dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d;
if (!valid || y < 1900 || dt >= new Date()) return { ok: false, msg: "Ngày sinh không hợp lệ" };
return { ok: true };
}
function dobToISO(raw) {
const m = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
return m ? `${m[3]}-${m[2]}-${m[1]}` : "";
}
/* Bảng chọn ngày sinh: 3 ô chọn Ngày / Tháng / Năm — chọn thẳng năm, không phải lùi lịch */
function DobPicker({ value, onPick, onClose }) {
const thisYear = new Date().getFullYear();
const parsed = (() => {
const m = (value || "").match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
return m ? { d: +m[1], mo: +m[2], y: +m[3] } : { d: 1, mo: 1, y: 2000 };
})();
const [d, setD] = useState(parsed.d);
const [mo, setMo] = useState(parsed.mo);
const [y, setY] = useState(parsed.y);
const daysInMonth = new Date(y, mo, 0).getDate();
const day = Math.min(d, daysInMonth);
const years = [];
for (let yy = thisYear; yy >= 1925; yy--) years.push(yy);
const selStyle = { flex: 1, padding: "11px 8px", borderRadius: 10, border: "1.5px solid var(--line, #ddd)", background: "var(--card, #fff)", color: "inherit", fontSize: 16, fontFamily: "inherit", outline: "none" };
const confirm = () => {
onPick(`${String(day).padStart(2, "0")}/${String(mo).padStart(2, "0")}/${y}`);
onClose();
};
return (
<>
e.stopPropagation()}>
>
);
}
function validatePassword(raw) {
if (!raw) return { ok: false, msg: "Vui lòng nhập mật khẩu" };
if (raw.length < 6) return { ok: false, msg: "Mật khẩu phải có ít nhất 6 ký tự" };
return { ok: true };
}
function validatePasswordConfirm(pw, confirm) {
if (!confirm) return { ok: false, msg: "Vui lòng nhập lại mật khẩu" };
if (pw !== confirm) return { ok: false, msg: "Mật khẩu xác nhận không khớp" };
return { ok: true };
}
/* ---------------- Step 1: form ---------------- */
function FormStep({ data, setData, onNext }) {
const [touched, setTouched] = useState({});
const [serverError, setServerError] = useState("");
const [loading, setLoading] = useState(false);
const [showPw, setShowPw] = useState(false);
const [showPw2, setShowPw2] = useState(false);
const [dobPicker, setDobPicker] = useState(false);
const phoneRes = validatePhone(data.phone);
const emailRes = validateEmail(data.email);
const dobRes = validateDob(data.dob);
const pwRes = validatePassword(data.password);
const pw2Res = validatePasswordConfirm(data.password, data.password_confirmation);
const nameOk = data.name.trim().length >= 2;
const canSubmit = phoneRes.ok && emailRes.ok && dobRes.ok && nameOk && pwRes.ok && pw2Res.ok;
const submit = async () => {
setTouched({ phone: true, name: true, email: true, dob: true, password: true, password_confirmation: true });
setServerError("");
if (!canSubmit) return;
setLoading(true);
const { ok, data: res } = await apiPost("/register", {
phone: normalizePhone(data.phone), name: data.name, email: data.email, dob: dobToISO(data.dob),
password: data.password, password_confirmation: data.password_confirmation,
});
setLoading(false);
if (!ok) { setServerError(res.message || "Có lỗi xảy ra, vui lòng thử lại."); return; }
onNext(res.redirect);
};
return (
<>
Tạo tài khoản
Đăng ký thành viên Laboong để tích điểm và đổi quà.
{ setData({ ...data, phone: e.target.value.replace(/[^\d\s.\-]/g, "") }); setServerError(""); }}
onBlur={() => setTouched(t => ({ ...t, phone: true }))} />
{phoneRes.ok && data.phone && !serverError && }
{serverError
?
{serverError}
: touched.phone && !phoneRes.ok
?
{phoneRes.msg}
:
Dùng để đăng nhập vào tài khoản của bạn.
}
setData({ ...data, name: e.target.value })}
onBlur={() => setTouched(t => ({ ...t, name: true }))} />
{touched.name && !nameOk &&
Vui lòng nhập họ tên
}
setData({ ...data, password: e.target.value })}
onBlur={() => setTouched(t => ({ ...t, password: true }))} />
{touched.password && !pwRes.ok &&
{pwRes.msg}
}
setData({ ...data, password_confirmation: e.target.value })}
onBlur={() => setTouched(t => ({ ...t, password_confirmation: true }))} />
{touched.password_confirmation && !pw2Res.ok &&
{pw2Res.msg}
}
setData({ ...data, email: e.target.value })}
onBlur={() => setTouched(t => ({ ...t, email: true }))} />
{emailRes.ok && data.email && }
{touched.email && !emailRes.ok &&
{emailRes.msg}
}
setDobPicker(true)}
onChange={e => setData({ ...data, dob: formatDobInput(e.target.value) })}
onBlur={() => setTouched(t => ({ ...t, dob: true }))} />
{dobRes.ok && data.dob.length === 10 && }
{dobPicker && (
{ setData({ ...data, dob: v }); setTouched(t => ({ ...t, dob: true })); }}
onClose={() => setDobPicker(false)} />
)}
{touched.dob && !dobRes.ok
? {dobRes.msg}
: Bấm để chọn ngày hoặc gõ trực tiếp · Nhận quà sinh nhật từ Laboong 🎂
}
>
);
}
/* ---------------- Step 2: success ---------------- */
function SuccessStep({ data, redirect }) {
useEffect(() => { const id = setTimeout(() => { location.href = redirect || NAV_URLS.home; }, 2200); return () => clearTimeout(id); }, [redirect]);
return (
Chào mừng, {data.name.trim().split(/\s+/).slice(-1)[0]}! 🎉
Tài khoản của bạn đã được tạo thành công.
Quà chào mừng thành viên mới
+50 điểm
Đang tự động đăng nhập…
);
}
/* ---------------- Push notification demo ---------------- */
function PushDemo({ onClose }) {
return (
L
Laboong
bây giờ
Voucher sắp hết hạn ⏰
Voucher “Giảm 30.000đ” của bạn sẽ hết hạn sau 3 ngày. Mở app để sử dụng ngay nhé!
);
}
/* ---------------- App ---------------- */
function App() {
const [tw, setTweak] = useTweaks(TW_DEFAULTS);
const [step, setStep] = useState(0);
const [data, setData] = useState({ phone: "", name: "", email: "", dob: "", password: "", password_confirmation: "" });
const [redirect, setRedirect] = useState(null);
const [push, setPush] = useState(false);
useEffect(() => {
const r = document.documentElement;
const [b, d] = Array.isArray(tw.brand) ? tw.brand : [tw.brand, tw.brand];
r.style.setProperty("--brand", b);
r.style.setProperty("--brand-deep", d);
r.setAttribute("data-theme", tw.dark ? "dark" : "light");
}, [tw.brand, tw.dark]);
const STEPS = ["Thông tin", "Hoàn tất"];
return (
L
Laboong
Victoria Văn Phú · Thẻ thành viên
{STEPS.map((s, i) => (
{i < step ? : i + 1}
{s}
{i < STEPS.length - 1 && }
))}
{step === 0 &&
{ setRedirect(redirectUrl); setStep(1); }} />}
{step === 1 && }
{step === 0 &&
}
{push &&
setPush(false)} />}
setTweak("brand", v)} />
setTweak("dark", v)} />
);
}
ReactDOM.createRoot(document.getElementById("root")).render();