reformat event checkin code
This commit is contained in:
parent
79b34b97de
commit
371fb98d4d
2 changed files with 670 additions and 595 deletions
|
@ -2,6 +2,7 @@
|
|||
import { Icon } from "astro-icon/components";
|
||||
import JSZip from "jszip";
|
||||
import FilePreview from "./universal/FilePreview";
|
||||
import EventCheckIn from "./EventsSection/EventCheckIn";
|
||||
---
|
||||
|
||||
<div id="eventsSection" class="dashboard-section hidden">
|
||||
|
@ -12,74 +13,17 @@ import FilePreview from "./universal/FilePreview";
|
|||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Event Check-in Card -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">Event Check-in</h3>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text"
|
||||
>Enter event code to check in</span
|
||||
>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Enter code"
|
||||
class="input input-bordered flex-1"
|
||||
/>
|
||||
<button class="btn btn-primary min-w-[90px]"
|
||||
>Check In</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Food Selection Modal -->
|
||||
<dialog id="foodSelectionModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">Food Selection</h3>
|
||||
<form id="foodSelectionForm" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text"
|
||||
>What food would you like?</span
|
||||
>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="foodInput"
|
||||
name="foodInput"
|
||||
class="input input-bordered"
|
||||
placeholder="Enter your food choice or 'none'"
|
||||
required
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-info"
|
||||
>Enter 'none' if you don't want any food</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="submit" class="btn btn-primary"
|
||||
>Submit</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
onclick="foodSelectionModal.close()">Cancel</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<EventCheckIn client:load />
|
||||
|
||||
<!-- Event Registration Card -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl border border-base-200 opacity-50 cursor-not-allowed relative group"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-base-100 opacity-0 group-hover:opacity-90 transition-opacity duration-300 flex items-center justify-center z-10"
|
||||
>
|
||||
<span class="text-base-content font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">Event Registration</h3>
|
||||
<div class="form-control w-full">
|
||||
|
@ -89,14 +33,16 @@ import FilePreview from "./universal/FilePreview";
|
|||
>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<select class="select select-bordered flex-1">
|
||||
<select class="select select-bordered flex-1" disabled>
|
||||
<option disabled selected>Pick an event</option>
|
||||
<option>Technical Workshop - Web Development</option
|
||||
>
|
||||
<option>Professional Development Workshop</option>
|
||||
<option>Social Event - Game Night</option>
|
||||
</select>
|
||||
<button class="btn btn-primary">Register</button>
|
||||
<button class="btn btn-primary" disabled
|
||||
>Register</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -210,10 +156,8 @@ import FilePreview from "./universal/FilePreview";
|
|||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick="window.closeFilePreviewEvents()"
|
||||
onclick="window.closeFilePreviewEvents()">Close</button
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<h3 class="font-bold text-lg truncate" id="previewFileName">
|
||||
</h3>
|
||||
</div>
|
||||
|
@ -371,487 +315,7 @@ import FilePreview from "./universal/FilePreview";
|
|||
food: string;
|
||||
}
|
||||
|
||||
let currentCheckInEvent: Event | null = null;
|
||||
|
||||
async function handleEventCheckIn(eventCode: string): Promise<void> {
|
||||
try {
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
const currentUser = auth.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
throw new Error("You must be logged in to check in to events");
|
||||
}
|
||||
|
||||
// Find the event with the given code
|
||||
const event = await get.getFirst<Event>(
|
||||
"events",
|
||||
`event_code = "${eventCode}"`
|
||||
);
|
||||
if (!event) {
|
||||
throw new Error("Invalid event code");
|
||||
}
|
||||
|
||||
// Check if user is already checked in
|
||||
if (
|
||||
event.attendees.some(
|
||||
(entry) => entry.user_id === currentUser.id
|
||||
)
|
||||
) {
|
||||
throw new Error("You have already checked in to this event");
|
||||
}
|
||||
|
||||
// Check if the event hasn't ended yet
|
||||
const eventEndDate = new Date(event.end_date);
|
||||
if (eventEndDate < new Date()) {
|
||||
throw new Error("This event has already ended");
|
||||
}
|
||||
|
||||
// If event has food, show food selection modal
|
||||
if (event.has_food) {
|
||||
currentCheckInEvent = event;
|
||||
const modal = document.getElementById(
|
||||
"foodSelectionModal"
|
||||
) as HTMLDialogElement;
|
||||
modal.showModal();
|
||||
} else {
|
||||
// If no food, complete check-in directly
|
||||
await completeCheckIn(event, null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
createToast(
|
||||
error?.message || "Failed to check in to event",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add food selection form handler
|
||||
const foodSelectionForm = document.getElementById(
|
||||
"foodSelectionForm"
|
||||
) as HTMLFormElement;
|
||||
if (foodSelectionForm) {
|
||||
foodSelectionForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const modal = document.getElementById(
|
||||
"foodSelectionModal"
|
||||
) as HTMLDialogElement;
|
||||
const foodInput = document.getElementById(
|
||||
"foodInput"
|
||||
) as HTMLInputElement;
|
||||
|
||||
try {
|
||||
if (currentCheckInEvent) {
|
||||
await completeCheckIn(
|
||||
currentCheckInEvent,
|
||||
foodInput.value.trim()
|
||||
);
|
||||
modal.close();
|
||||
foodInput.value = ""; // Reset input
|
||||
currentCheckInEvent = null;
|
||||
}
|
||||
} catch (error: any) {
|
||||
createToast(
|
||||
error?.message || "Failed to check in to event",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function completeCheckIn(
|
||||
event: Event,
|
||||
foodSelection: string | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
const auth = Authentication.getInstance();
|
||||
const update = Update.getInstance();
|
||||
const logger = SendLog.getInstance();
|
||||
|
||||
const currentUser = auth.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
throw new Error("You must be logged in to check in to events");
|
||||
}
|
||||
|
||||
// Create attendee entry with check-in details
|
||||
const attendeeEntry: AttendeeEntry = {
|
||||
user_id: currentUser.id,
|
||||
time_checked_in: new Date().toISOString(),
|
||||
food: foodSelection || "none",
|
||||
};
|
||||
|
||||
// Get existing attendees or initialize empty array
|
||||
const existingAttendees = event.attendees || [];
|
||||
|
||||
// Check if user is already checked in
|
||||
if (
|
||||
existingAttendees.some(
|
||||
(entry) => entry.user_id === currentUser.id
|
||||
)
|
||||
) {
|
||||
throw new Error("You have already checked in to this event");
|
||||
}
|
||||
|
||||
// Add new attendee entry to the array
|
||||
const updatedAttendees = [...existingAttendees, attendeeEntry];
|
||||
|
||||
// Update attendees array with the new entry
|
||||
await update.updateField(
|
||||
"events",
|
||||
event.id,
|
||||
"attendees",
|
||||
updatedAttendees
|
||||
);
|
||||
|
||||
// If food selection was made, log it
|
||||
if (foodSelection) {
|
||||
await logger.send(
|
||||
"update",
|
||||
"event check-in",
|
||||
`Food selection for ${event.event_name}: ${foodSelection}`
|
||||
);
|
||||
}
|
||||
|
||||
// Award points to user if available
|
||||
if (event.points_to_reward > 0) {
|
||||
const userPoints = currentUser.points || 0;
|
||||
await update.updateField(
|
||||
"users",
|
||||
currentUser.id,
|
||||
"points",
|
||||
userPoints + event.points_to_reward
|
||||
);
|
||||
|
||||
// Log the points award
|
||||
await logger.send(
|
||||
"update",
|
||||
"event check-in",
|
||||
`Awarded ${event.points_to_reward} points for checking in to ${event.event_name}`
|
||||
);
|
||||
}
|
||||
|
||||
// Show success message with points if awarded
|
||||
createToast(
|
||||
`Successfully checked in to ${event.event_name}${
|
||||
event.points_to_reward > 0
|
||||
? ` (+${event.points_to_reward} points!)`
|
||||
: ""
|
||||
}`,
|
||||
"success"
|
||||
);
|
||||
|
||||
// Log the check-in
|
||||
await logger.send(
|
||||
"check_in",
|
||||
"events",
|
||||
`User ${currentUser.name} (${currentUser.graduation_year}) checked in to event ${event.event_name}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
createToast(
|
||||
error?.message || "Failed to check in to event",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener to check-in button
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const checkInForm = document.querySelector(".form-control");
|
||||
const checkInInput = checkInForm?.querySelector("input");
|
||||
const checkInButton = checkInForm?.querySelector("button");
|
||||
|
||||
if (checkInForm && checkInInput && checkInButton) {
|
||||
checkInButton.addEventListener("click", async () => {
|
||||
const eventCode = checkInInput.value.trim();
|
||||
if (!eventCode) {
|
||||
createToast("Please enter an event code", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
checkInButton.classList.add("btn-disabled");
|
||||
const loadingSpinner = document.createElement("span");
|
||||
loadingSpinner.className = "loading loading-spinner loading-xs";
|
||||
const originalText = checkInButton.textContent;
|
||||
checkInButton.textContent = "";
|
||||
checkInButton.appendChild(loadingSpinner);
|
||||
|
||||
await handleEventCheckIn(eventCode);
|
||||
|
||||
checkInButton.classList.remove("btn-disabled");
|
||||
checkInButton.removeChild(loadingSpinner);
|
||||
checkInButton.textContent = originalText;
|
||||
checkInInput.value = "";
|
||||
});
|
||||
|
||||
// Allow Enter key to submit
|
||||
checkInInput.addEventListener("keypress", async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
checkInButton.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
// Show skeletons first
|
||||
const upcomingEventsContainer = document.getElementById(
|
||||
"upcomingEventsContainer"
|
||||
);
|
||||
const ongoingEventsContainer = document.getElementById(
|
||||
"ongoingEventsContainer"
|
||||
);
|
||||
const pastEventsContainer = document.getElementById(
|
||||
"pastEventsContainer"
|
||||
);
|
||||
if (
|
||||
!upcomingEventsContainer ||
|
||||
!pastEventsContainer ||
|
||||
!ongoingEventsContainer
|
||||
)
|
||||
return;
|
||||
|
||||
// Add 3 skeleton cards to each container initially
|
||||
const createSkeletonCard = () => {
|
||||
const skeletonCard = document.createElement("div");
|
||||
skeletonCard.className =
|
||||
"card bg-base-200 shadow-lg animate-pulse";
|
||||
skeletonCard.innerHTML = `
|
||||
<div class="card-body p-5">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div class="flex-1">
|
||||
<div class="skeleton h-6 w-3/4 mb-2"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="skeleton h-5 w-16"></div>
|
||||
<div class="skeleton h-5 w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
<div class="skeleton h-5 w-24 mb-1"></div>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skeleton h-4 w-full mb-3"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="skeleton h-4 w-4"></div>
|
||||
<div class="skeleton h-4 w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return skeletonCard;
|
||||
};
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
upcomingEventsContainer.appendChild(createSkeletonCard());
|
||||
ongoingEventsContainer.appendChild(createSkeletonCard());
|
||||
pastEventsContainer.appendChild(createSkeletonCard());
|
||||
}
|
||||
|
||||
const get = Get.getInstance();
|
||||
const events = await get.getAll<Event>(
|
||||
"events",
|
||||
"published = true",
|
||||
"-start_date"
|
||||
); // Sort by start date descending
|
||||
|
||||
// Clear skeletons
|
||||
upcomingEventsContainer.innerHTML = "";
|
||||
ongoingEventsContainer.innerHTML = "";
|
||||
pastEventsContainer.innerHTML = "";
|
||||
|
||||
// Split events into upcoming, ongoing, and past based on start and end dates
|
||||
const now = new Date();
|
||||
const { upcoming, ongoing, past } = events.reduce(
|
||||
(acc, event) => {
|
||||
// Convert UTC dates to local time
|
||||
const startDate = new Date(event.start_date);
|
||||
const endDate = new Date(event.end_date);
|
||||
|
||||
// Set both dates and now to midnight for date-only comparison
|
||||
const startLocal = new Date(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate(),
|
||||
startDate.getHours(),
|
||||
startDate.getMinutes()
|
||||
);
|
||||
const endLocal = new Date(
|
||||
endDate.getFullYear(),
|
||||
endDate.getMonth(),
|
||||
endDate.getDate(),
|
||||
endDate.getHours(),
|
||||
endDate.getMinutes()
|
||||
);
|
||||
const nowLocal = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours(),
|
||||
now.getMinutes()
|
||||
);
|
||||
|
||||
if (startLocal > nowLocal) {
|
||||
// If start date is in future, it's upcoming
|
||||
acc.upcoming.push(event);
|
||||
} else if (endLocal < nowLocal) {
|
||||
// If end date is in past, it's past
|
||||
acc.past.push(event);
|
||||
} else {
|
||||
// If start date is past but end date is future, it's ongoing
|
||||
acc.ongoing.push(event);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
upcoming: [] as Event[],
|
||||
ongoing: [] as Event[],
|
||||
past: [] as Event[],
|
||||
}
|
||||
);
|
||||
|
||||
// Sort upcoming events by start date (closest first)
|
||||
upcoming.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_date).getTime() -
|
||||
new Date(b.start_date).getTime()
|
||||
);
|
||||
|
||||
// Sort ongoing events by end date (ending soonest first)
|
||||
ongoing.sort(
|
||||
(a, b) =>
|
||||
new Date(a.end_date).getTime() -
|
||||
new Date(b.end_date).getTime()
|
||||
);
|
||||
|
||||
// Sort past events by end date (most recent first)
|
||||
past.sort(
|
||||
(a, b) =>
|
||||
new Date(b.end_date).getTime() -
|
||||
new Date(a.end_date).getTime()
|
||||
);
|
||||
|
||||
const renderEventCard = (event: Event, container: HTMLElement) => {
|
||||
const startDate = new Date(event.start_date);
|
||||
const endDate = new Date(event.end_date);
|
||||
const now = new Date();
|
||||
const isPastEvent = endDate < now;
|
||||
|
||||
// Store event data in window object with unique ID
|
||||
const eventDataId = `event_${event.id}`;
|
||||
window[eventDataId] = event;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className =
|
||||
"card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden";
|
||||
card.innerHTML = `
|
||||
<div class="card-body p-5">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div class="flex-1">
|
||||
<h3 class="card-title text-lg font-semibold mb-1 line-clamp-2">${event.event_name}</h3>
|
||||
<div class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<div class="badge badge-primary badge-sm">${event.points_to_reward} pts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0 text-base-content/80">
|
||||
<div class="text-sm font-medium">
|
||||
${startDate.toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs mt-0.5 opacity-75">
|
||||
${startDate.toLocaleTimeString(
|
||||
"en-US",
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-base-content/70 mb-3 line-clamp-2">
|
||||
${event.description || "No description available"}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-start gap-2.5 text-base-content/80">
|
||||
<Icon name="mdi:map-marker" class="w-4 h-4 text-primary shrink-0 mt-0.5" />
|
||||
<span class="text-sm leading-tight truncate max-w-[200px]">${event.location}</span>
|
||||
</div>
|
||||
${
|
||||
isPastEvent &&
|
||||
event.files &&
|
||||
event.files.length > 0
|
||||
? `
|
||||
<button onclick="window.openDetailsModal(window['event_${event.id}'])" class="btn btn-sm btn-primary w-[90px] inline-flex items-center justify-center">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span>Files</span>
|
||||
</div>
|
||||
</button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
};
|
||||
|
||||
// Render all event types
|
||||
upcoming.forEach((event) =>
|
||||
renderEventCard(event, upcomingEventsContainer)
|
||||
);
|
||||
ongoing.forEach((event) =>
|
||||
renderEventCard(event, ongoingEventsContainer)
|
||||
);
|
||||
past.forEach((event) =>
|
||||
renderEventCard(event, pastEventsContainer)
|
||||
);
|
||||
|
||||
// Hide sections if they have no events
|
||||
if (upcoming.length === 0)
|
||||
upcomingEventsContainer.parentElement?.classList.add("hidden");
|
||||
if (ongoing.length === 0)
|
||||
ongoingEventsContainer.parentElement?.classList.add("hidden");
|
||||
if (past.length === 0)
|
||||
pastEventsContainer.parentElement?.classList.add("hidden");
|
||||
} catch (error) {
|
||||
console.error("Failed to load events:", error);
|
||||
// You might want to show an error message to the user here
|
||||
}
|
||||
}
|
||||
|
||||
// Load events when the section becomes visible
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
loadEvents();
|
||||
observer.disconnect(); // Only load once
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const eventsSection = document.getElementById("eventsSection");
|
||||
if (eventsSection) {
|
||||
observer.observe(eventsSection);
|
||||
}
|
||||
let currentEventId = "";
|
||||
|
||||
// Add helper functions for file preview
|
||||
function getFileType(filename: string): string {
|
||||
|
@ -875,8 +339,6 @@ import FilePreview from "./universal/FilePreview";
|
|||
return mimeTypes[extension || ""] || "application/octet-stream";
|
||||
}
|
||||
|
||||
let currentEventId = "";
|
||||
|
||||
function showLoading() {
|
||||
const spinner = document.getElementById("loadingSpinner");
|
||||
if (spinner) spinner.classList.remove("hidden");
|
||||
|
@ -887,28 +349,6 @@ import FilePreview from "./universal/FilePreview";
|
|||
if (spinner) spinner.classList.add("hidden");
|
||||
}
|
||||
|
||||
function isPreviewableType(fileType: string): boolean {
|
||||
return (
|
||||
fileType.startsWith("image/") ||
|
||||
fileType.startsWith("video/") ||
|
||||
fileType.startsWith("audio/") ||
|
||||
fileType === "application/pdf" ||
|
||||
fileType.startsWith("text/") ||
|
||||
fileType === "application/json"
|
||||
);
|
||||
}
|
||||
|
||||
function backToFileList() {
|
||||
const filePreviewSection =
|
||||
document.getElementById("filePreviewSection");
|
||||
const filesContent = document.getElementById("filesContent");
|
||||
const modalTitle = document.getElementById("modalTitle");
|
||||
|
||||
if (filePreviewSection) filePreviewSection.classList.add("hidden");
|
||||
if (filesContent) filesContent.classList.remove("hidden");
|
||||
if (modalTitle) modalTitle.textContent = "Event Files";
|
||||
}
|
||||
|
||||
function showFilePreview(file: {
|
||||
url: string;
|
||||
type: string;
|
||||
|
@ -1175,4 +615,264 @@ import FilePreview from "./universal/FilePreview";
|
|||
[key: string]: any;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
// Show skeletons first
|
||||
const upcomingEventsContainer = document.getElementById(
|
||||
"upcomingEventsContainer"
|
||||
);
|
||||
const ongoingEventsContainer = document.getElementById(
|
||||
"ongoingEventsContainer"
|
||||
);
|
||||
const pastEventsContainer = document.getElementById(
|
||||
"pastEventsContainer"
|
||||
);
|
||||
if (
|
||||
!upcomingEventsContainer ||
|
||||
!pastEventsContainer ||
|
||||
!ongoingEventsContainer
|
||||
)
|
||||
return;
|
||||
|
||||
// Add 3 skeleton cards to each container initially
|
||||
const createSkeletonCard = () => {
|
||||
const skeletonCard = document.createElement("div");
|
||||
skeletonCard.className =
|
||||
"card bg-base-200 shadow-lg animate-pulse";
|
||||
skeletonCard.innerHTML = `
|
||||
<div class="card-body p-5">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div class="flex-1">
|
||||
<div class="skeleton h-6 w-3/4 mb-2"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="skeleton h-5 w-16"></div>
|
||||
<div class="skeleton h-5 w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
<div class="skeleton h-5 w-24 mb-1"></div>
|
||||
<div class="skeleton h-4 w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skeleton h-4 w-full mb-3"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="skeleton h-4 w-4"></div>
|
||||
<div class="skeleton h-4 w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return skeletonCard;
|
||||
};
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
upcomingEventsContainer.appendChild(createSkeletonCard());
|
||||
ongoingEventsContainer.appendChild(createSkeletonCard());
|
||||
pastEventsContainer.appendChild(createSkeletonCard());
|
||||
}
|
||||
|
||||
const get = Get.getInstance();
|
||||
const events = await get.getAll<Event>(
|
||||
"events",
|
||||
"published = true",
|
||||
"-start_date"
|
||||
); // Sort by start date descending
|
||||
|
||||
// Clear skeletons
|
||||
upcomingEventsContainer.innerHTML = "";
|
||||
ongoingEventsContainer.innerHTML = "";
|
||||
pastEventsContainer.innerHTML = "";
|
||||
|
||||
// Split events into upcoming, ongoing, and past based on start and end dates
|
||||
const now = new Date();
|
||||
const { upcoming, ongoing, past } = events.reduce(
|
||||
(acc, event) => {
|
||||
// Convert UTC dates to local time
|
||||
const startDate = new Date(event.start_date);
|
||||
const endDate = new Date(event.end_date);
|
||||
|
||||
// Set both dates and now to midnight for date-only comparison
|
||||
const startLocal = new Date(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate(),
|
||||
startDate.getHours(),
|
||||
startDate.getMinutes()
|
||||
);
|
||||
const endLocal = new Date(
|
||||
endDate.getFullYear(),
|
||||
endDate.getMonth(),
|
||||
endDate.getDate(),
|
||||
endDate.getHours(),
|
||||
endDate.getMinutes()
|
||||
);
|
||||
const nowLocal = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours(),
|
||||
now.getMinutes()
|
||||
);
|
||||
|
||||
if (startLocal > nowLocal) {
|
||||
// If start date is in future, it's upcoming
|
||||
acc.upcoming.push(event);
|
||||
} else if (endLocal < nowLocal) {
|
||||
// If end date is in past, it's past
|
||||
acc.past.push(event);
|
||||
} else {
|
||||
// If start date is past but end date is future, it's ongoing
|
||||
acc.ongoing.push(event);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
upcoming: [] as Event[],
|
||||
ongoing: [] as Event[],
|
||||
past: [] as Event[],
|
||||
}
|
||||
);
|
||||
|
||||
// Sort upcoming events by start date (closest first)
|
||||
upcoming.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_date).getTime() -
|
||||
new Date(b.start_date).getTime()
|
||||
);
|
||||
|
||||
// Sort ongoing events by end date (ending soonest first)
|
||||
ongoing.sort(
|
||||
(a, b) =>
|
||||
new Date(a.end_date).getTime() -
|
||||
new Date(b.end_date).getTime()
|
||||
);
|
||||
|
||||
// Sort past events by end date (most recent first)
|
||||
past.sort(
|
||||
(a, b) =>
|
||||
new Date(b.end_date).getTime() -
|
||||
new Date(a.end_date).getTime()
|
||||
);
|
||||
|
||||
const renderEventCard = (event: Event, container: HTMLElement) => {
|
||||
const startDate = new Date(event.start_date);
|
||||
const endDate = new Date(event.end_date);
|
||||
const now = new Date();
|
||||
const isPastEvent = endDate < now;
|
||||
|
||||
// Store event data in window object with unique ID
|
||||
const eventDataId = `event_${event.id}`;
|
||||
window[eventDataId] = event;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className =
|
||||
"card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden";
|
||||
card.innerHTML = `
|
||||
<div class="card-body p-5">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div class="flex-1">
|
||||
<h3 class="card-title text-lg font-semibold mb-1 line-clamp-2">${event.event_name}</h3>
|
||||
<div class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<div class="badge badge-primary badge-sm">${event.points_to_reward} pts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0 text-base-content/80">
|
||||
<div class="text-sm font-medium">
|
||||
${startDate.toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs mt-0.5 opacity-75">
|
||||
${startDate.toLocaleTimeString(
|
||||
"en-US",
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-base-content/70 mb-3 line-clamp-2">
|
||||
${event.description || "No description available"}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-start gap-2.5 text-base-content/80">
|
||||
<Icon name="mdi:map-marker" class="w-4 h-4 text-primary shrink-0 mt-0.5" />
|
||||
<span class="text-sm leading-tight truncate max-w-[200px]">${event.location}</span>
|
||||
</div>
|
||||
${
|
||||
isPastEvent &&
|
||||
event.files &&
|
||||
event.files.length > 0
|
||||
? `
|
||||
<button onclick="window.openDetailsModal(window['event_${event.id}'])" class="btn btn-sm btn-primary w-[90px] inline-flex items-center justify-center">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span>Files</span>
|
||||
</div>
|
||||
</button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
};
|
||||
|
||||
// Render all event types
|
||||
upcoming.forEach((event) =>
|
||||
renderEventCard(event, upcomingEventsContainer)
|
||||
);
|
||||
ongoing.forEach((event) =>
|
||||
renderEventCard(event, ongoingEventsContainer)
|
||||
);
|
||||
past.forEach((event) =>
|
||||
renderEventCard(event, pastEventsContainer)
|
||||
);
|
||||
|
||||
// Hide sections if they have no events
|
||||
if (upcoming.length === 0)
|
||||
upcomingEventsContainer.parentElement?.classList.add("hidden");
|
||||
if (ongoing.length === 0)
|
||||
ongoingEventsContainer.parentElement?.classList.add("hidden");
|
||||
if (past.length === 0)
|
||||
pastEventsContainer.parentElement?.classList.add("hidden");
|
||||
} catch (error) {
|
||||
console.error("Failed to load events:", error);
|
||||
// You might want to show an error message to the user here
|
||||
}
|
||||
}
|
||||
|
||||
// Load events when the section becomes visible
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
loadEvents();
|
||||
observer.disconnect(); // Only load once
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const eventsSection = document.getElementById("eventsSection");
|
||||
if (eventsSection) {
|
||||
observer.observe(eventsSection);
|
||||
}
|
||||
</script>
|
||||
|
|
375
src/components/dashboard/EventsSection/EventCheckIn.tsx
Normal file
375
src/components/dashboard/EventsSection/EventCheckIn.tsx
Normal file
|
@ -0,0 +1,375 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Get } from "../../../scripts/pocketbase/Get";
|
||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||
import { Update } from "../../../scripts/pocketbase/Update";
|
||||
import { SendLog } from "../../../scripts/pocketbase/SendLog";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
event_name: string;
|
||||
event_code: string;
|
||||
location: string;
|
||||
points_to_reward: number;
|
||||
attendees: AttendeeEntry[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
has_food: boolean;
|
||||
description: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
interface AttendeeEntry {
|
||||
user_id: string;
|
||||
time_checked_in: string;
|
||||
food: string;
|
||||
}
|
||||
|
||||
// Toast management system
|
||||
const createToast = (
|
||||
message: string,
|
||||
type: "success" | "error" | "warning" = "success"
|
||||
) => {
|
||||
let toastContainer = document.querySelector(".toast-container");
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.createElement("div");
|
||||
toastContainer.className = "toast-container fixed bottom-4 right-4 z-50";
|
||||
document.body.appendChild(toastContainer);
|
||||
}
|
||||
|
||||
const existingToasts = document.querySelectorAll(".toast-container .toast");
|
||||
if (existingToasts.length >= 2) {
|
||||
const oldestToast = existingToasts[0];
|
||||
oldestToast.classList.add("toast-exit");
|
||||
setTimeout(() => oldestToast.remove(), 150);
|
||||
}
|
||||
|
||||
// Update positions of existing toasts
|
||||
existingToasts.forEach((t) => {
|
||||
const toast = t as HTMLElement;
|
||||
const currentIndex = parseInt(toast.getAttribute("data-index") || "0");
|
||||
toast.setAttribute("data-index", (currentIndex + 1).toString());
|
||||
});
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast translate-x-full";
|
||||
toast.setAttribute("data-index", "0");
|
||||
|
||||
// Update alert styling based on type
|
||||
const alertClass =
|
||||
type === "success"
|
||||
? "alert-success bg-success text-success-content"
|
||||
: type === "error"
|
||||
? "alert-error bg-error text-error-content"
|
||||
: "alert-warning bg-warning text-warning-content";
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="alert ${alertClass} shadow-lg min-w-[300px]">
|
||||
<div class="flex items-center gap-2">
|
||||
${type === "success"
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
|
||||
: type === "error"
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
|
||||
: '<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>'
|
||||
}
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Force a reflow to ensure the animation triggers
|
||||
toast.offsetHeight;
|
||||
|
||||
// Add the transition class and remove transform
|
||||
toast.classList.add("transition-all", "duration-300", "ease-out");
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.remove("translate-x-full");
|
||||
});
|
||||
|
||||
// Setup exit animation
|
||||
setTimeout(() => {
|
||||
toast.classList.add("toast-exit");
|
||||
setTimeout(() => toast.remove(), 150);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const EventCheckIn = () => {
|
||||
const [currentCheckInEvent, setCurrentCheckInEvent] = useState<Event | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [foodInput, setFoodInput] = useState("");
|
||||
|
||||
async function handleEventCheckIn(eventCode: string): Promise<void> {
|
||||
try {
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
const currentUser = auth.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
throw new Error("You must be logged in to check in to events");
|
||||
}
|
||||
|
||||
// Find the event with the given code
|
||||
const event = await get.getFirst<Event>(
|
||||
"events",
|
||||
`event_code = "${eventCode}"`
|
||||
);
|
||||
if (!event) {
|
||||
throw new Error("Invalid event code");
|
||||
}
|
||||
|
||||
// Check if user is already checked in
|
||||
if (event.attendees.some((entry) => entry.user_id === currentUser.id)) {
|
||||
throw new Error("You have already checked in to this event");
|
||||
}
|
||||
|
||||
// Check if the event hasn't ended yet
|
||||
const eventEndDate = new Date(event.end_date);
|
||||
if (eventEndDate < new Date()) {
|
||||
throw new Error("This event has already ended");
|
||||
}
|
||||
|
||||
// If event has food, show food selection modal
|
||||
if (event.has_food) {
|
||||
setCurrentCheckInEvent(event);
|
||||
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
|
||||
modal.showModal();
|
||||
} else {
|
||||
// If no food, complete check-in directly
|
||||
await completeCheckIn(event, null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
createToast(error?.message || "Failed to check in to event", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function completeCheckIn(event: Event, foodSelection: string | null): Promise<void> {
|
||||
try {
|
||||
const auth = Authentication.getInstance();
|
||||
const update = Update.getInstance();
|
||||
const logger = SendLog.getInstance();
|
||||
|
||||
const currentUser = auth.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
throw new Error("You must be logged in to check in to events");
|
||||
}
|
||||
|
||||
// Create attendee entry with check-in details
|
||||
const attendeeEntry: AttendeeEntry = {
|
||||
user_id: currentUser.id,
|
||||
time_checked_in: new Date().toISOString(),
|
||||
food: foodSelection || "none",
|
||||
};
|
||||
|
||||
// Get existing attendees or initialize empty array
|
||||
const existingAttendees = event.attendees || [];
|
||||
|
||||
// Check if user is already checked in
|
||||
if (existingAttendees.some((entry) => entry.user_id === currentUser.id)) {
|
||||
throw new Error("You have already checked in to this event");
|
||||
}
|
||||
|
||||
// Add new attendee entry to the array
|
||||
const updatedAttendees = [...existingAttendees, attendeeEntry];
|
||||
|
||||
// Update attendees array with the new entry
|
||||
await update.updateField("events", event.id, "attendees", updatedAttendees);
|
||||
|
||||
// If food selection was made, log it
|
||||
if (foodSelection) {
|
||||
await logger.send(
|
||||
"update",
|
||||
"event check-in",
|
||||
`Food selection for ${event.event_name}: ${foodSelection}`
|
||||
);
|
||||
}
|
||||
|
||||
// Award points to user if available
|
||||
if (event.points_to_reward > 0) {
|
||||
const userPoints = currentUser.points || 0;
|
||||
await update.updateField(
|
||||
"users",
|
||||
currentUser.id,
|
||||
"points",
|
||||
userPoints + event.points_to_reward
|
||||
);
|
||||
|
||||
// Log the points award
|
||||
await logger.send(
|
||||
"update",
|
||||
"event check-in",
|
||||
`Awarded ${event.points_to_reward} points for checking in to ${event.event_name}`
|
||||
);
|
||||
}
|
||||
|
||||
// Show success message with points if awarded
|
||||
createToast(
|
||||
`Successfully checked in to ${event.event_name}${event.points_to_reward > 0
|
||||
? ` (+${event.points_to_reward} points!)`
|
||||
: ""
|
||||
}`,
|
||||
"success"
|
||||
);
|
||||
|
||||
// Log the check-in
|
||||
await logger.send(
|
||||
"check_in",
|
||||
"events",
|
||||
`User ${currentUser.name} (${currentUser.graduation_year}) checked in to event ${event.event_name}`
|
||||
);
|
||||
|
||||
// Close the food selection modal if it's open
|
||||
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
|
||||
if (modal) {
|
||||
modal.close();
|
||||
setFoodInput("");
|
||||
}
|
||||
} catch (error: any) {
|
||||
createToast(error?.message || "Failed to check in to event", "error");
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (currentCheckInEvent) {
|
||||
await completeCheckIn(currentCheckInEvent, foodInput.trim());
|
||||
setCurrentCheckInEvent(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title text-lg mb-4">Event Check-in</h3>
|
||||
<div className="form-control w-full">
|
||||
<label className="label">
|
||||
<span className="label-text">Enter event code to check in</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Enter code"
|
||||
className="input input-bordered flex-1"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.value.trim()) {
|
||||
setIsLoading(true);
|
||||
handleEventCheckIn(input.value.trim()).finally(() => {
|
||||
setIsLoading(false);
|
||||
input.value = "";
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className={`btn btn-primary min-w-[90px] ${isLoading ? "loading" : ""}`}
|
||||
onClick={(e) => {
|
||||
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
|
||||
if (input.value.trim()) {
|
||||
setIsLoading(true);
|
||||
handleEventCheckIn(input.value.trim()).finally(() => {
|
||||
setIsLoading(false);
|
||||
input.value = "";
|
||||
});
|
||||
} else {
|
||||
createToast("Please enter an event code", "warning");
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
) : (
|
||||
"Check In"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="foodSelectionModal" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-4">Food Selection</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">What food would you like?</span>
|
||||
<span className="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={foodInput}
|
||||
onChange={(e) => setFoodInput(e.target.value)}
|
||||
className="input input-bordered"
|
||||
placeholder="Enter your food choice or 'none'"
|
||||
required
|
||||
/>
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-info">
|
||||
Enter 'none' if you don't want any food
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
|
||||
modal.close();
|
||||
setCurrentCheckInEvent(null);
|
||||
setFoodInput("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>{`
|
||||
.toast-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
.toast-exit {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.toast.translate-x-full {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.toast-container .toast {
|
||||
transform: translateY(calc((1 - attr(data-index number)) * -0.25rem));
|
||||
}
|
||||
.toast-container .toast[data-index="0"] {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.toast-container .toast[data-index="1"] {
|
||||
transform: translateY(-0.025rem);
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventCheckIn;
|
Loading…
Reference in a new issue