Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21176e1e0b | ||
|
|
b4cd140168 | ||
|
|
5167706d1b | ||
|
|
047f61b627 | ||
|
|
1815f78c85 | ||
|
|
29036d0612 | ||
|
|
c07e9c928a |
+19
-1
@@ -7,6 +7,23 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-07-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- At The Door: new "Check-In" tab (before "Tickets" in the tab order) for quickly redeeming a registration's Main Tickets by quantity (e.g. checking in 2 of 3 people on a booking) without scanning each QR code individually. Like the Payment tab, it only ever shows the currently opened registration — reached via the "Open" button, not a general search.
|
||||||
|
- Checking in a Main Ticket (via the new Check-In tab or the existing QR camera scanner) now automatically sends the ticket holder a confirmation email and WhatsApp message (respecting their notification preference) stating how many were checked in and how many remain.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- At The Door: the "Open" button on a registration now jumps to the Payment tab only when a balance is still outstanding; fully paid registrations jump straight to the new Check-In tab instead.
|
||||||
|
- At The Door: recording a payment that fully settles a registration's balance now jumps straight to the Check-In tab instead of generating and print-previewing a paper ticket — tickets are already emailed/WhatsApped to the attendee automatically once the registration is paid, so a physical print is no longer forced on this path.
|
||||||
|
|
||||||
## [1.2.0] - 2026-07-27
|
## [1.2.0] - 2026-07-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -57,7 +74,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).
|
- 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.2.0...main
|
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...main
|
||||||
|
[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.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.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
|
||||||
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
|
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "event-management-backend",
|
"name": "event-management-backend",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"description": "Event Management System Backend",
|
"description": "Event Management System Backend",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -241,10 +241,12 @@ const getEventsAll = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const includePast = req.query.includePast === 'true';
|
const includePast = req.query.includePast === 'true';
|
||||||
const includeInactive = req.query.includeInactive === 'true';
|
const includeInactive = req.query.includeInactive === 'true';
|
||||||
|
const excludeClosed = req.query.excludeClosed === 'true';
|
||||||
|
|
||||||
const where = {};
|
const where = {};
|
||||||
if (!includeInactive) where.isActive = true;
|
if (!includeInactive) where.isActive = true;
|
||||||
if (!includePast) where.endDate = { gte: new Date() };
|
if (!includePast) where.endDate = { gte: new Date() };
|
||||||
|
if (excludeClosed) where.cashupStatus = { not: 'closed' };
|
||||||
|
|
||||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ const scanTicket = async (req, res) => {
|
|||||||
e.title AS "eventTitle",
|
e.title AS "eventTitle",
|
||||||
eo.id AS "eventOptionId",
|
eo.id AS "eventOptionId",
|
||||||
eo.name AS "eventOptionName",
|
eo.name AS "eventOptionName",
|
||||||
|
eo."isMainTicket" AS "isMainTicket",
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
@@ -352,7 +353,7 @@ const scanTicket = async (req, res) => {
|
|||||||
isUsed: r.isUsed,
|
isUsed: r.isUsed,
|
||||||
eventId: r.eventId,
|
eventId: r.eventId,
|
||||||
event: { title: r.eventTitle },
|
event: { title: r.eventTitle },
|
||||||
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } },
|
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
|
||||||
usages: r.usages || []
|
usages: r.usages || []
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -398,6 +399,14 @@ const scanTicket = async (req, res) => {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
|
||||||
|
// the QR camera flow are unaffected)
|
||||||
|
if (ticket.registrationOption?.eventOption?.isMainTicket) {
|
||||||
|
require('../utils/notifications')
|
||||||
|
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
|
||||||
|
.catch(e => console.error('Failed to send check-in notification:', e));
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
||||||
ticketUsage,
|
ticketUsage,
|
||||||
|
|||||||
@@ -482,13 +482,24 @@ const checkUserExists = async (req, res) => {
|
|||||||
|
|
||||||
const existingUser = await prisma.user.findFirst({
|
const existingUser = await prisma.user.findFirst({
|
||||||
where: { OR: searchClauses },
|
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({
|
res.json({
|
||||||
exists: !!existingUser,
|
exists: !!existingUser,
|
||||||
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'),
|
hasEmail,
|
||||||
hasPhone: !!existingUser?.phoneNumber,
|
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) {
|
} catch (error) {
|
||||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ async function loadRegistrationFull(registrationId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTicketFull(ticketId) {
|
||||||
|
return prisma.ticket.findUnique({
|
||||||
|
where: { id: ticketId },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
|
||||||
|
event: true,
|
||||||
|
registrationOption: { include: { eventOption: true, variant: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPaymentFull(paymentId) {
|
async function loadPaymentFull(paymentId) {
|
||||||
return prisma.payment.findUnique({
|
return prisma.payment.findUnique({
|
||||||
where: { id: paymentId },
|
where: { id: paymentId },
|
||||||
@@ -488,6 +499,33 @@ function buildPaymentReceipt(payment) {
|
|||||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
|
||||||
|
|
||||||
|
function buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining) {
|
||||||
|
const org = getOrg();
|
||||||
|
const eventTitle = ticket.event?.title || 'the event';
|
||||||
|
const optionName = ticket.registrationOption?.eventOption?.name || 'Main Ticket';
|
||||||
|
const fullyCheckedIn = remaining <= 0;
|
||||||
|
const subject = `Checked in – ${eventTitle}`;
|
||||||
|
const preheader = `${qtyRedeemed} checked in for ${eventTitle}.`;
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">You're checked in! ✅</p>
|
||||||
|
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">See you inside</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${ticket.user?.name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||||
|
<strong>${qtyRedeemed}</strong> ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for <strong>${eventTitle}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${callout(`<strong style="font-size:15px">${totalRedeemed} of ${ticket.quantity || 1} checked in</strong>${
|
||||||
|
fullyCheckedIn ? '' : `<br/><span style="font-size:13px">${remaining} remaining on this ticket</span>`
|
||||||
|
}`, fullyCheckedIn ? 'success' : 'info')}`;
|
||||||
|
|
||||||
|
const text = `You're checked in!\n\nHi ${ticket.user?.name || 'there'},\n\n${qtyRedeemed} ${optionName}(s) just checked in for ${eventTitle}.\n\n${totalRedeemed} of ${ticket.quantity || 1} checked in${fullyCheckedIn ? '' : `, ${remaining} remaining`}.\n\n${org.name} — ${org.email}`;
|
||||||
|
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Donation applied to a registration ────────────────────────────────────────
|
// ─── Donation applied to a registration ────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
|
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
|
||||||
@@ -893,6 +931,32 @@ async function sendRefundEmail(refundPaymentId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining) {
|
||||||
|
try {
|
||||||
|
const ticket = await loadTicketFull(ticketId);
|
||||||
|
if (!ticket) return;
|
||||||
|
const user = ticket.user;
|
||||||
|
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||||
|
const { buildWACheckIn } = require('./waMessages');
|
||||||
|
|
||||||
|
const sends = [];
|
||||||
|
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||||
|
if (hasValidEmail && shouldEmail(user)) {
|
||||||
|
const msg = buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining);
|
||||||
|
sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||||
|
}
|
||||||
|
const waMsg = buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining);
|
||||||
|
if (hasValidEmail) {
|
||||||
|
sends.push(waText(user, waMsg));
|
||||||
|
} else {
|
||||||
|
sends.push(waTextAny(user, waMsg));
|
||||||
|
}
|
||||||
|
await Promise.all(sends);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send check-in emails:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sent when staff apply a donation to someone's registration. Only the registrant is
|
// Sent when staff apply a donation to someone's registration. Only the registrant is
|
||||||
// notified (anonymously, per design) — the donor already received their donation-received
|
// notified (anonymously, per design) — the donor already received their donation-received
|
||||||
// notification when the donation was originally made, so they are deliberately not emailed
|
// notification when the donation was originally made, so they are deliberately not emailed
|
||||||
@@ -981,4 +1045,6 @@ module.exports = {
|
|||||||
sendRefundEmail,
|
sendRefundEmail,
|
||||||
sendSelfServiceRegistrationEmails,
|
sendSelfServiceRegistrationEmails,
|
||||||
sendDonationAssignmentEmails,
|
sendDonationAssignmentEmails,
|
||||||
|
sendCheckInEmails,
|
||||||
|
buildCheckInConfirmation,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,6 +107,26 @@ function buildWAPayment(payment) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
|
||||||
|
|
||||||
|
function buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining) {
|
||||||
|
const org = getOrg();
|
||||||
|
const eventTitle = ticket.event?.title || 'the event';
|
||||||
|
const name = ticket.user?.name || 'there';
|
||||||
|
const fullyCheckedIn = remaining <= 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`✅ *Checked In*`,
|
||||||
|
'',
|
||||||
|
`Hi ${name},`,
|
||||||
|
'',
|
||||||
|
`*${qtyRedeemed}* checked in for *${eventTitle}*.`,
|
||||||
|
`*${totalRedeemed} of ${ticket.quantity || 1}* checked in${fullyCheckedIn ? '' : `, *${remaining}* remaining`}.`,
|
||||||
|
'',
|
||||||
|
`_${org.name}_ | ${org.url}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Login notification ───────────────────────────────────────────────────────
|
// ─── Login notification ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildWALogin({ name, when, location, userAgent }) {
|
function buildWALogin({ name, when, location, userAgent }) {
|
||||||
@@ -255,4 +275,5 @@ module.exports = {
|
|||||||
buildWAWelcome,
|
buildWAWelcome,
|
||||||
buildWAAccountClosed,
|
buildWAAccountClosed,
|
||||||
buildWATicketCaption,
|
buildWATicketCaption,
|
||||||
|
buildWACheckIn,
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import { apiFetch } from "@/lib/api";
|
|||||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
|
||||||
type Mode = "registration" | "payment" | "tickets" | "refund";
|
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<Mode, string> = {
|
||||||
|
registration: "REGISTRATION",
|
||||||
|
payment: "PAYMENT",
|
||||||
|
checkin: "CHECK-IN",
|
||||||
|
tickets: "TICKETS",
|
||||||
|
refund: "REFUND",
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -159,8 +167,7 @@ export default function AtTheDoorPage() {
|
|||||||
const [error, setError] = useDismissingState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
const handleRegistrationCreated = (registration: any) => {
|
const handleRegistrationCreated = (registration: any) => {
|
||||||
setActiveRegistration(registration);
|
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
|
||||||
setMode("payment"); // 🚀 Jump automatically
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegistrationSelected = (registration: any) => {
|
const handleRegistrationSelected = (registration: any) => {
|
||||||
@@ -171,7 +178,9 @@ export default function AtTheDoorPage() {
|
|||||||
if (!activeRegistration) return;
|
if (!activeRegistration) return;
|
||||||
|
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
setMode("payment");
|
// Fully paid → head straight to check-in; otherwise there's still a
|
||||||
|
// balance to collect, so go capture payment first.
|
||||||
|
setMode(activeRegistration.status === "paid" ? "checkin" : "payment");
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
@@ -317,28 +326,12 @@ export default function AtTheDoorPage() {
|
|||||||
|
|
||||||
const reg = registration || activeRegistration;
|
const reg = registration || activeRegistration;
|
||||||
|
|
||||||
try {
|
// Tickets are generated and emailed/WhatsApped automatically server-side
|
||||||
const res = await apiFetch<any>("/api/tickets/generate", {
|
// once a registration reaches "paid" (see paymentController) — no need to
|
||||||
method: "POST",
|
// fetch/print them here, just move straight to checking the attendee in.
|
||||||
authToken: token,
|
setActiveRegistration(reg);
|
||||||
body: {
|
setInfo("Payment recorded — tickets sent. Ready to check in.");
|
||||||
registrationId: reg.id
|
setMode("checkin");
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const tickets = res?.tickets || [];
|
|
||||||
|
|
||||||
if (tickets.length) {
|
|
||||||
printTickets(tickets);
|
|
||||||
}
|
|
||||||
|
|
||||||
setInfo("Payment recorded & tickets printed");
|
|
||||||
setActiveRegistration(null);
|
|
||||||
setMode("registration");
|
|
||||||
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message || "Tickets failed to generate");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDonation = (user?: any) => {
|
const handleDonation = (user?: any) => {
|
||||||
@@ -346,97 +339,6 @@ export default function AtTheDoorPage() {
|
|||||||
setShowDonationModal(true);
|
setShowDonationModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildTicketHtmlCard = (t: any) => {
|
|
||||||
const eventTitle =
|
|
||||||
t.event?.title ||
|
|
||||||
t.registrationOption?.registration?.event?.title ||
|
|
||||||
"Event";
|
|
||||||
|
|
||||||
const ticketType = ticketLabel(t);
|
|
||||||
const qty = t.quantity || 1;
|
|
||||||
const holder =
|
|
||||||
t.user?.name ||
|
|
||||||
t.registrationOption?.registration?.user?.name ||
|
|
||||||
"";
|
|
||||||
const qrValue = t.qrCode || t.id;
|
|
||||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrValue)}`;
|
|
||||||
|
|
||||||
return `<div class="ticket">
|
|
||||||
<div class="top">
|
|
||||||
<div class="evt">${eventTitle}</div>
|
|
||||||
<div class="type">${ticketType}</div>
|
|
||||||
${holder ? `<div class="holder">${holder}</div>` : ""}
|
|
||||||
<div class="qty">Qty: <strong>${qty}</strong></div>
|
|
||||||
</div>
|
|
||||||
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
|
|
||||||
<div class="meta">${t.id}</div>
|
|
||||||
</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openPrintWindow = (cardsHtml: string) => {
|
|
||||||
const w = window.open("", "_blank");
|
|
||||||
if (!w) return;
|
|
||||||
w.document.write(`<!doctype html><html><head><style>
|
|
||||||
@page { size: A4; margin: 8mm; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; background: #fff; }
|
|
||||||
.page {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
grid-template-rows: 1fr 1fr;
|
|
||||||
width: 194mm;
|
|
||||||
height: 281mm;
|
|
||||||
gap: 4mm;
|
|
||||||
page-break-after: always;
|
|
||||||
break-after: page;
|
|
||||||
}
|
|
||||||
.page:last-child { page-break-after: avoid; break-after: avoid; }
|
|
||||||
.ticket {
|
|
||||||
border: 1.5px solid #222;
|
|
||||||
border-radius: 3mm;
|
|
||||||
padding: 5mm;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.top { flex: 0 0 auto; }
|
|
||||||
.evt { font-weight: bold; font-size: 13pt; line-height: 1.2; }
|
|
||||||
.type { font-size: 11pt; margin-top: 2mm; color: #333; }
|
|
||||||
.holder { font-size: 10pt; margin-top: 1mm; color: #555; }
|
|
||||||
.qty { font-size: 10pt; margin-top: 1mm; }
|
|
||||||
.qrwrap { display: flex; justify-content: center; align-items: center; flex: 1 1 auto; padding: 3mm 0; }
|
|
||||||
.qrwrap img { width: 48mm; height: 48mm; display: block; }
|
|
||||||
.meta { font-size: 7pt; color: #777; text-align: center; flex: 0 0 auto; word-break: break-all; }
|
|
||||||
</style></head><body>${cardsHtml}<script>
|
|
||||||
(function(){
|
|
||||||
function go(){
|
|
||||||
var imgs=Array.prototype.slice.call(document.images);
|
|
||||||
if(!imgs.length){window.print();return;}
|
|
||||||
var n=imgs.length;
|
|
||||||
function done(){if(--n<=0)setTimeout(function(){window.print();},150);}
|
|
||||||
imgs.forEach(function(i){if(i.complete)done();else{i.addEventListener('load',done,{once:true});i.addEventListener('error',done,{once:true});}});
|
|
||||||
}
|
|
||||||
if(document.readyState==='complete')go();else window.addEventListener('load',go);
|
|
||||||
})();
|
|
||||||
</script></body></html>`);
|
|
||||||
w.document.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const printTickets = (tickets: any[]) => {
|
|
||||||
if (!tickets?.length) return;
|
|
||||||
// 4 per A4 page (2 columns × 2 rows)
|
|
||||||
const PAGE_SIZE = 4;
|
|
||||||
const pages: string[] = [];
|
|
||||||
for (let i = 0; i < tickets.length; i += PAGE_SIZE) {
|
|
||||||
const chunk = tickets.slice(i, i + PAGE_SIZE);
|
|
||||||
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
|
|
||||||
}
|
|
||||||
openPrintWindow(pages.join(""));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
||||||
|
|
||||||
@@ -482,7 +384,7 @@ export default function AtTheDoorPage() {
|
|||||||
|
|
||||||
{/* Mode Buttons */}
|
{/* Mode Buttons */}
|
||||||
<div className="flex gap-2 mb-4 flex-wrap">
|
<div className="flex gap-2 mb-4 flex-wrap">
|
||||||
{(["registration", "payment", "tickets", "refund"] as Mode[]).map(m => (
|
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
onClick={() => setMode(m)}
|
onClick={() => setMode(m)}
|
||||||
@@ -494,7 +396,7 @@ export default function AtTheDoorPage() {
|
|||||||
: "bg-white hover:bg-gray-50"
|
: "bg-white hover:bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{m.toUpperCase()}
|
{MODE_LABELS[m]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -522,6 +424,10 @@ export default function AtTheDoorPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === "checkin" && (
|
||||||
|
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === "tickets" && (
|
{mode === "tickets" && (
|
||||||
<DoorTicketsPanel token={token} eventId={eventId} />
|
<DoorTicketsPanel token={token} eventId={eventId} />
|
||||||
)}
|
)}
|
||||||
@@ -1081,6 +987,148 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
|
||||||
|
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
|
||||||
|
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!token || !eventId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||||
|
const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []);
|
||||||
|
setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket));
|
||||||
|
} catch {
|
||||||
|
setTicketsForEvent([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [token, eventId]);
|
||||||
|
|
||||||
|
if (!registration) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-xl p-6 bg-white shadow-sm text-sm text-gray-500">
|
||||||
|
No registration selected
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const myTickets = ticketsForEvent
|
||||||
|
.filter(t => t.registrationOption?.registration?.id === registration.id)
|
||||||
|
.map(t => {
|
||||||
|
const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
|
||||||
|
const remaining = (t.quantity || 1) - totalRedeemed;
|
||||||
|
return { ...t, totalRedeemed, remaining };
|
||||||
|
});
|
||||||
|
|
||||||
|
const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
|
||||||
|
const setQtyFor = (t: any, n: number) =>
|
||||||
|
setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) }));
|
||||||
|
|
||||||
|
const commit = async (ticket: any) => {
|
||||||
|
const qty = qtyFor(ticket);
|
||||||
|
try {
|
||||||
|
setSubmittingId(ticket.id);
|
||||||
|
const res = await apiFetch<any>(
|
||||||
|
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
|
||||||
|
{ method: "POST", authToken: token, body: { qty } }
|
||||||
|
);
|
||||||
|
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
|
||||||
|
res?.remaining > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
|
||||||
|
}. Confirmation sent.`);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || "Check-in failed");
|
||||||
|
} finally {
|
||||||
|
setSubmittingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-xl p-6 bg-white shadow-sm">
|
||||||
|
|
||||||
|
<div className="text-xl font-semibold mb-4">Check-In</div>
|
||||||
|
|
||||||
|
{/* ✅ User */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-lg font-semibold">
|
||||||
|
{registration.user?.name || "Guest"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm text-gray-500">Loading…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && myTickets.length === 0 && (
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
No Main Tickets on this registration
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{myTickets.map(t => {
|
||||||
|
const qty = qtyFor(t);
|
||||||
|
return (
|
||||||
|
<div key={t.id} className="border rounded-lg p-4">
|
||||||
|
<div className="text-sm font-medium mb-3">{ticketLabel(t)}</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||||
|
<div className="border rounded-lg p-3 text-center">
|
||||||
|
<div className="text-xs text-gray-500">TOTAL</div>
|
||||||
|
<div className="text-lg font-semibold">{t.quantity || 1}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-3 text-center">
|
||||||
|
<div className="text-xs text-gray-500">CHECKED IN</div>
|
||||||
|
<div className="text-lg font-semibold">{t.totalRedeemed}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-3 text-center bg-emerald-50">
|
||||||
|
<div className="text-xs text-gray-500">REMAINING</div>
|
||||||
|
<div className="text-2xl font-bold text-emerald-600">{t.remaining}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{t.remaining > 0 ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setQtyFor(t, qty - 1)}
|
||||||
|
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold"
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<div className="w-10 text-center text-lg font-semibold">{qty}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setQtyFor(t, qty + 1)}
|
||||||
|
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => commit(t)}
|
||||||
|
disabled={submittingId === t.id}
|
||||||
|
className="ml-auto px-4 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-emerald-600 font-medium">Fully checked in ✓</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
|
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ function fmtCurrency(val: number) {
|
|||||||
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
|
||||||
export default function SelfServicePage() {
|
export default function SelfServicePage() {
|
||||||
const [screen, setScreen] = useState<Screen>("setup");
|
const [screen, setScreen] = useState<Screen>("setup");
|
||||||
@@ -113,8 +120,12 @@ export default function SelfServicePage() {
|
|||||||
const [createAccount, setCreateAccount] = useState(false);
|
const [createAccount, setCreateAccount] = useState(false);
|
||||||
const [visitorPassword, setVisitorPassword] = useState("");
|
const [visitorPassword, setVisitorPassword] = useState("");
|
||||||
const [accountExists, setAccountExists] = useState(false);
|
const [accountExists, setAccountExists] = useState(false);
|
||||||
const [checkingAccount, setCheckingAccount] = useState(false);
|
|
||||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
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 [formLoading, setFormLoading] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -180,7 +191,7 @@ export default function SelfServicePage() {
|
|||||||
setEventsLoading(true);
|
setEventsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data: KioskEvent[] = await apiFetch(
|
const data: KioskEvent[] = await apiFetch(
|
||||||
"/api/events/all?includePast=false&includeInactive=false",
|
"/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
|
||||||
{ authToken: token }
|
{ authToken: token }
|
||||||
);
|
);
|
||||||
setEvents(data);
|
setEvents(data);
|
||||||
@@ -416,6 +427,8 @@ export default function SelfServicePage() {
|
|||||||
setCreateAccount(false);
|
setCreateAccount(false);
|
||||||
setVisitorPassword("");
|
setVisitorPassword("");
|
||||||
setAccountExists(false);
|
setAccountExists(false);
|
||||||
|
setLookupQuery("");
|
||||||
|
setLookupMessage(null);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setCurrentRegistrationId(null);
|
setCurrentRegistrationId(null);
|
||||||
setCurrentUserId(null);
|
setCurrentUserId(null);
|
||||||
@@ -431,33 +444,66 @@ export default function SelfServicePage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─── Check whether an account already exists for the entered email/phone ──
|
// ─── Explicit account lookup — only runs when the operator submits the search
|
||||||
useEffect(() => {
|
// (Enter or the Search button), never on keystroke. Matches exactly against a
|
||||||
const email = visitorEmail.trim();
|
// single email or phone value and only ever returns that one matched account
|
||||||
const phone = visitorPhone.trim();
|
// (or nothing) — never a broader/fuzzy match. ──
|
||||||
if (!supervisorToken || (!email && !phone)) {
|
async function handleLookup() {
|
||||||
setAccountExists(false);
|
const q = lookupQuery.trim();
|
||||||
return;
|
if (!q || !supervisorToken) return;
|
||||||
}
|
setLookupLoading(true);
|
||||||
const handle = setTimeout(async () => {
|
setLookupMessage(null);
|
||||||
setCheckingAccount(true);
|
try {
|
||||||
try {
|
// Classify the query as email- or phone-shaped and send it as only that param —
|
||||||
const params = new URLSearchParams();
|
// sending the same raw string as both could let digits embedded in an email
|
||||||
if (email) params.set("email", email);
|
// (e.g. a numeric local-part) get misread as an unrelated phone number.
|
||||||
if (phone) params.set("phone", phone);
|
const isEmailLike = q.includes("@");
|
||||||
const data = await apiFetch<{ exists: boolean }>(
|
const params = new URLSearchParams();
|
||||||
`/api/users/check-exists?${params.toString()}`,
|
if (isEmailLike) params.set("email", q);
|
||||||
{ authToken: supervisorToken }
|
else params.set("phone", q);
|
||||||
);
|
const data = await apiFetch<{
|
||||||
setAccountExists(!!data?.exists);
|
exists: boolean;
|
||||||
} catch {
|
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);
|
setAccountExists(false);
|
||||||
} finally {
|
setVisitorName("");
|
||||||
setCheckingAccount(false);
|
setNotificationPref("email");
|
||||||
|
if (isEmailLike) {
|
||||||
|
setVisitorEmail(q);
|
||||||
|
setVisitorPhone("");
|
||||||
|
} else if (looksLikePhone(q)) {
|
||||||
|
setVisitorPhone(q);
|
||||||
|
setVisitorEmail("");
|
||||||
|
} else {
|
||||||
|
setVisitorEmail("");
|
||||||
|
setVisitorPhone("");
|
||||||
|
}
|
||||||
|
setLookupMessage("No matching account found.");
|
||||||
}
|
}
|
||||||
}, 400);
|
} catch {
|
||||||
return () => clearTimeout(handle);
|
setLookupMessage("Lookup failed. Please try again.");
|
||||||
}, [visitorEmail, visitorPhone, supervisorToken]);
|
} 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
|
// Existing accounts are linked automatically — never show the "create account" toggle for them
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -628,6 +674,34 @@ export default function SelfServicePage() {
|
|||||||
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
|
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
|
||||||
</div>
|
</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">
|
<form onSubmit={handleRegister} className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-800">Create an account</p>
|
<p className="font-medium text-gray-800">Create an account</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">Save your details for future events</p>
|
||||||
{checkingAccount ? "Checking for an existing account…" : "Save your details for future events"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
{createAccount && (
|
{createAccount && (
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && npm run dev",
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user