Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a75c9155b | ||
|
|
8850984055 | ||
|
|
21176e1e0b | ||
|
|
b4cd140168 | ||
|
|
5167706d1b | ||
|
|
047f61b627 |
+10
-1
@@ -7,6 +7,14 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.1] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Self-service kiosk: closed events no longer appear in the event picker — only open events are selectable.
|
||||
- Self-service kiosk: added a "Look up existing account" search field (by email or phone, triggered only by Enter or the Search button — never as-you-type) that autofills a returning visitor's name, contact details, and notification preference from their exact matching account, instead of requiring staff to re-enter details already on file.
|
||||
- Self-service kiosk: when the account lookup finds no match, the typed query now carries over into whichever of Email/Phone it resembles (instead of being discarded), while Name and everything else resets blank for a fresh entry.
|
||||
|
||||
## [1.3.0] - 2026-07-27
|
||||
|
||||
### Added
|
||||
@@ -69,7 +77,8 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
|
||||
|
||||
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...main
|
||||
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.1...main
|
||||
[1.3.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...v1.3.1
|
||||
[1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0
|
||||
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
|
||||
[1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -241,10 +241,12 @@ const getEventsAll = async (req, res) => {
|
||||
try {
|
||||
const includePast = req.query.includePast === 'true';
|
||||
const includeInactive = req.query.includeInactive === 'true';
|
||||
const excludeClosed = req.query.excludeClosed === 'true';
|
||||
|
||||
const where = {};
|
||||
if (!includeInactive) where.isActive = true;
|
||||
if (!includePast) where.endDate = { gte: new Date() };
|
||||
if (excludeClosed) where.cashupStatus = { not: 'closed' };
|
||||
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
|
||||
@@ -482,13 +482,24 @@ const checkUserExists = async (req, res) => {
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: { OR: searchClauses },
|
||||
select: { email: true, phoneNumber: true },
|
||||
select: { name: true, email: true, phoneNumber: true, notificationPreference: true },
|
||||
});
|
||||
|
||||
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
|
||||
const hasPhone = !!existingUser?.phoneNumber;
|
||||
|
||||
res.json({
|
||||
exists: !!existingUser,
|
||||
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'),
|
||||
hasPhone: !!existingUser?.phoneNumber,
|
||||
hasEmail,
|
||||
hasPhone,
|
||||
// Safe-to-display fields only, for autofilling a lookup form — never the password.
|
||||
// Guest placeholder emails are withheld the same way hasEmail already treats them.
|
||||
user: existingUser ? {
|
||||
name: existingUser.name,
|
||||
email: hasEmail ? existingUser.email : null,
|
||||
phoneNumber: hasPhone ? existingUser.phoneNumber : null,
|
||||
notificationPreference: existingUser.notificationPreference,
|
||||
} : null,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -85,6 +85,13 @@ function fmtCurrency(val: number) {
|
||||
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Loose "does this look like a mobile number" check — covers 0821234567 (10, leading 0),
|
||||
// 821234567 (9, no leading 0), and 27821234567 / +27821234567 (11 digits, country code).
|
||||
function looksLikePhone(s: string): boolean {
|
||||
const digits = s.replace(/\D/g, "");
|
||||
return digits.length >= 9 && digits.length <= 11;
|
||||
}
|
||||
|
||||
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
|
||||
export default function SelfServicePage() {
|
||||
const [screen, setScreen] = useState<Screen>("setup");
|
||||
@@ -113,8 +120,12 @@ export default function SelfServicePage() {
|
||||
const [createAccount, setCreateAccount] = useState(false);
|
||||
const [visitorPassword, setVisitorPassword] = useState("");
|
||||
const [accountExists, setAccountExists] = useState(false);
|
||||
const [checkingAccount, setCheckingAccount] = useState(false);
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
|
||||
// ── Account lookup (search-by-email/phone) state ──────────────────
|
||||
const [lookupQuery, setLookupQuery] = useState("");
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [lookupMessage, setLookupMessage] = useState<string | null>(null);
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
@@ -180,7 +191,7 @@ export default function SelfServicePage() {
|
||||
setEventsLoading(true);
|
||||
try {
|
||||
const data: KioskEvent[] = await apiFetch(
|
||||
"/api/events/all?includePast=false&includeInactive=false",
|
||||
"/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
|
||||
{ authToken: token }
|
||||
);
|
||||
setEvents(data);
|
||||
@@ -416,6 +427,8 @@ export default function SelfServicePage() {
|
||||
setCreateAccount(false);
|
||||
setVisitorPassword("");
|
||||
setAccountExists(false);
|
||||
setLookupQuery("");
|
||||
setLookupMessage(null);
|
||||
setFormError(null);
|
||||
setCurrentRegistrationId(null);
|
||||
setCurrentUserId(null);
|
||||
@@ -431,33 +444,66 @@ export default function SelfServicePage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ─── Check whether an account already exists for the entered email/phone ──
|
||||
useEffect(() => {
|
||||
const email = visitorEmail.trim();
|
||||
const phone = visitorPhone.trim();
|
||||
if (!supervisorToken || (!email && !phone)) {
|
||||
setAccountExists(false);
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(async () => {
|
||||
setCheckingAccount(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (email) params.set("email", email);
|
||||
if (phone) params.set("phone", phone);
|
||||
const data = await apiFetch<{ exists: boolean }>(
|
||||
`/api/users/check-exists?${params.toString()}`,
|
||||
{ authToken: supervisorToken }
|
||||
);
|
||||
setAccountExists(!!data?.exists);
|
||||
} catch {
|
||||
// ─── Explicit account lookup — only runs when the operator submits the search
|
||||
// (Enter or the Search button), never on keystroke. Matches exactly against a
|
||||
// single email or phone value and only ever returns that one matched account
|
||||
// (or nothing) — never a broader/fuzzy match. ──
|
||||
async function handleLookup() {
|
||||
const q = lookupQuery.trim();
|
||||
if (!q || !supervisorToken) return;
|
||||
setLookupLoading(true);
|
||||
setLookupMessage(null);
|
||||
try {
|
||||
// Classify the query as email- or phone-shaped and send it as only that param —
|
||||
// sending the same raw string as both could let digits embedded in an email
|
||||
// (e.g. a numeric local-part) get misread as an unrelated phone number.
|
||||
const isEmailLike = q.includes("@");
|
||||
const params = new URLSearchParams();
|
||||
if (isEmailLike) params.set("email", q);
|
||||
else params.set("phone", q);
|
||||
const data = await apiFetch<{
|
||||
exists: boolean;
|
||||
user?: { name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" } | null;
|
||||
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
|
||||
if (data?.exists && data.user) {
|
||||
const found = data.user;
|
||||
setVisitorName(found.name);
|
||||
setVisitorEmail(found.email || "");
|
||||
setVisitorPhone(found.phoneNumber || "");
|
||||
setNotificationPref(found.notificationPreference);
|
||||
setAccountExists(true);
|
||||
setLookupMessage("Account found — details filled in below.");
|
||||
} else {
|
||||
// No match — still save the retype: carry the query into whichever field it
|
||||
// resembles, and leave everything else blank for a fresh entry.
|
||||
setAccountExists(false);
|
||||
} finally {
|
||||
setCheckingAccount(false);
|
||||
setVisitorName("");
|
||||
setNotificationPref("email");
|
||||
if (isEmailLike) {
|
||||
setVisitorEmail(q);
|
||||
setVisitorPhone("");
|
||||
} else if (looksLikePhone(q)) {
|
||||
setVisitorPhone(q);
|
||||
setVisitorEmail("");
|
||||
} else {
|
||||
setVisitorEmail("");
|
||||
setVisitorPhone("");
|
||||
}
|
||||
setLookupMessage("No matching account found.");
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(handle);
|
||||
}, [visitorEmail, visitorPhone, supervisorToken]);
|
||||
} catch {
|
||||
setLookupMessage("Lookup failed. Please try again.");
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLookupKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleLookup();
|
||||
}
|
||||
}
|
||||
|
||||
// Existing accounts are linked automatically — never show the "create account" toggle for them
|
||||
useEffect(() => {
|
||||
@@ -628,6 +674,34 @@ export default function SelfServicePage() {
|
||||
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 border border-gray-200 rounded-xl p-4 bg-gray-50">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Look up existing account</label>
|
||||
<p className="text-xs text-gray-500 mb-2">Search by email or phone number to fill in a returning visitor's details.</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={lookupQuery}
|
||||
onChange={(e) => setLookupQuery(e.target.value)}
|
||||
onKeyDown={handleLookupKeyDown}
|
||||
className="flex-1 border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Email or phone number"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLookup}
|
||||
disabled={lookupLoading || !lookupQuery.trim()}
|
||||
className="px-5 rounded-xl bg-gray-700 text-white font-medium hover:bg-gray-800 disabled:opacity-50 transition"
|
||||
>
|
||||
{lookupLoading ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{lookupMessage && (
|
||||
<p className={`text-sm mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
|
||||
{lookupMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
||||
@@ -808,9 +882,7 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Create an account</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{checkingAccount ? "Checking for an existing account…" : "Save your details for future events"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Save your details for future events</p>
|
||||
</div>
|
||||
</label>
|
||||
{createAccount && (
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
@@ -8,7 +8,8 @@
|
||||
"start:backend": "cd backend && npm run start",
|
||||
"start:frontend": "cd frontend && npm run start",
|
||||
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\""
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
Reference in New Issue
Block a user