Compare commits

..

No commits in common. "main" and "shing/fixBug" have entirely different histories.

168 changed files with 10288 additions and 45048 deletions

View file

@ -1 +0,0 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)

3
.gitignore vendored
View file

@ -3,9 +3,6 @@ dist/
# generated types
.astro/
.cursor
final_review_gate.py
# dependencies
node_modules/

View file

@ -1,7 +0,0 @@
{
"workbench.colorCustomizations": {
"activityBar.background": "#221489",
"titleBar.activeBackground": "#301DC0",
"titleBar.activeForeground": "#F9F9FE"
}
}

View file

@ -1,55 +0,0 @@
# Use the official Bun image
FROM oven/bun:1.1
# Install dependencies for Puppeteer and Chrome/Chromium
RUN apt-get update && \
apt-get install -y \
wget \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libx11-xcb1 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
xdg-utils \
chromium \
gnupg \
--no-install-recommends && \
# Install Google Chrome stable
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - && \
echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
apt-get update && \
apt-get install -y google-chrome-stable && \
rm -rf /var/lib/apt/lists/*
# Set Puppeteer executable path (prefer google-chrome-stable, fallback to chromium)
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
# Set working directory
WORKDIR /app
# Copy package files and install dependencies
COPY bun.lock package.json ./
RUN bun install
# Copy the rest of your app
COPY . .
# Build the application
RUN bun run build
# Expose the port your app runs on (change if needed)
EXPOSE 4321
# Start the server
CMD ["bun", "run", "start"]

View file

@ -15,29 +15,9 @@ import icon from "astro-icon";
// https://astro.build/config
export default defineConfig({
output: "server",
integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()],
integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()],
adapter: node({
mode: "standalone",
}),
// Define environment variables that should be available to client components
vite: {
define: {
"import.meta.env.LOGTO_APP_ID": JSON.stringify(process.env.LOGTO_APP_ID),
"import.meta.env.LOGTO_APP_SECRET": JSON.stringify(
process.env.LOGTO_APP_SECRET,
),
"import.meta.env.LOGTO_ENDPOINT": JSON.stringify(
process.env.LOGTO_ENDPOINT,
),
"import.meta.env.LOGTO_TOKEN_ENDPOINT": JSON.stringify(
process.env.LOGTO_TOKEN_ENDPOINT,
),
"import.meta.env.LOGTO_API_ENDPOINT": JSON.stringify(
process.env.LOGTO_API_ENDPOINT,
),
},
},
adapter: node({
mode: "standalone",
}),
});

1543
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -1,12 +0,0 @@
version: "3.8"
services:
web:
build: .
ports:
- "4321:4321"
environment:
- NODE_ENV=production
- PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
- PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
- PUPPETEER_DISABLE_SANDBOX=true

View file

@ -1,4 +1,4 @@
[phases.setup]
nixPkgs = ["nodejs_20", "bun"]
nixPkgs = ["nodejs_18", "bun"]
aptPkgs = ["curl", "wget"]

207
notes.md
View file

@ -1,207 +0,0 @@
# Event Request Form:
Prior Notes:
Whether you are or aren't requesting AS Funding or physical flyers, you MUST submit this request form at least 6 weeks before your event. We can create both digital and physical flyers for your event, advertise your event on social media (Facebook, Instagram, Discord), advertise your event on newsletters (IEEE, ECE, IDEA), take pictures at your event and edit them (we highly recommend this!), and livestream your event on Facebook. After submitting this form, please @-pr and/or @-coordinators in #-events on Slack.
Please note that if you submit your request late, we may deny your request.
Also note that if you're requesting AS Funding, please don't forget to check the Funding Guide or the Google Calendar for the funding request deadlines.
### Do you need graphics from our design team?
Possible Answers:
- Yes (Go to Section 2)
- No (Go to Section 3)
## Section 2: PR
If you need PR Materials, please don't forget that this form MUST be submitted at least 6 weeks in advance even if you aren't requesting AS funding or physical flyers. Also, please remember to ping PR in #-events on Slack once you've submitted this form.
### Type of material needed?
Feel free to add what else you need as well as where else you want your event advertised (if needed) in the other option.
- Digital flyer (with social media advertising: Facebook, Instagram, Discord)
- Digital flyer (with NO social media advertising)
- Physical flyer (with advertising)
- Physical flyer (with NO advertising)
- Newsletter (IEEE, ECE, IDEA)
- Other
### If you chose to have your flyer advertised, when do you need us to start advertising?
- DATETIME
### Logos Required?
[ ] IEEE
[ ] AS (required if funded by AS)
[ ] HKN
[ ] TESC
[ ] PIB
[ ] TNT
[ ] SWE
[ ] OTHER (please upload transparent logo files to the next question)
### Please share your logo files here:
FILEUPLOAD
### What format do you need it to be in?
- PNG
- PDF
- JPG
- DOES NOT MATTER
### Any other specifications and requests (color scheme, overall design, etc)? Feel free to link us to any examples you want us to consider in designing your promotional material. (i.e. past FB covers from events, etc)
TEXTBOX
### Photography Needed?
- Yes
- No
## Section 3: Event Details
Please remember to ping @Coordinators in #-events on Slack once you've submitted this form so that they can fill out a TAP form for you.
### Event Name:
TEXTBOX
### Event Description:
TEXTBOX
### Event Start Date:
DATETIME
### Event End Date:
DATETIME
### Event Location:
TEXTBOX
### Do you/will you have a room booking for this event?
- Yes
- No
## Section 4: TAP Form
Please ensure you have ALL sections completed, if something is not available, let the coordinators know and be advised on how to proceed.
### Expected attendance? Include a number NOT a range please.
(
PROGRAMMING FUNDS
EVENTS FUNDED BY PROGRAMMING FUNDS MAY ONLY ADMIT UC SAN DIEGO STUDENTS, STAFF OR FACULTY AS GUESTS.
ONLY UC SAN DIEGO UNDERGRADUATE STUDENTS MAY RECEIVE ITEMS FUNDED BY THE ASSOCIATED STUDENTS.
EVENT FUNDING IS GRANTED UP TO A MAXIMUM OF $10.00 PER EXPECTED STUDENT ATTENDEE AND $5,000 PER EVENT
)
NUMBER
### Upload your room booking here. Ensure your file size fits within this size. Please use the following naming format: EventName_LocationOfEvent_DateOfEvent
i.e. ArduinoWorkshop_Qualcomm_01/06/2025
FILEUPLOAD
### Do you need AS Funding? (food/flyers)
- Yes
- No
### Will you be serving food/drinks at your event?
- Yes
- No
## Section 5: AS Funding
Please make sure the restaurant is a valid AS Funding food vendor! An invoice can be an unofficial receipt. Just make sure that the restaurant name and location, desired pickup or delivery date and time, all the items ordered plus their prices, discount/fees/tax/tip, and total are on the invoice! We don't recommend paying out of pocket because reimbursements can be a hassle when you're not a Principal Member.
### Please put your invoice information in the following format: quantity + item name + unit cost + discounts/fees/tax/tip + total + vendor.
(e.g. 3-Chicken Cutlet with Gravy Regular, white rice, and mac salad x14.95 each | 3-Garlic Shrimp Regular with white rice and mac salad x15.45 each | 10-Spam Musubi x2.95 each | Tax = 9.35 | Tip = 18.10 | Total = 148.15 from L&L Hawaiian Barbeque)
TEXTBOX
### Be sure to share a screenshot of your order/your official food invoice here. Official food invoices will be required 2 weeks before the start of your event. Please use the following naming format: EventName_OrderLocation_DateOfEvent
i.e. QPWorkathon#1_PapaJohns_01/06/2025
FILEUPLOAD
Pocketbase Collection Schema:
```json
{
"collectionId": "pbc_1475615553",
"collectionName": "event_request",
"id": "test",
"requested_user": "RELATION_RECORD_ID",
"name": "test",
"location": "test",
"start_date_time": "2022-01-01 10:00:00.123Z",
"end_date_time": "2022-01-01 10:00:00.123Z",
"flyers_needed": true,
"flyer_type": [
"digital_with_social",
"digital_no_social",
"physical_with_advertising",
"physical_no_advertising",
"newsletter",
"other"
],
"other_flyer_type": "test",
"flyer_advertising_start_date": "test",
"flyer_additional_requests": "test",
"photography_needed": true,
"required_logos": ["IEEE", "AS", "HKN", "TESC", "PIB", "TNT", "SWE", "OTHER"],
"other_logos": ["filename.jpg"],
"advertising_format": "pdf",
"will_or_have_room_booking": true,
"expected_attendance": 123,
"room_booking": "filename.jpg",
"as_funding_required": true,
"food_drinks_being_served": true,
"itemized_invoice": "JSON",
"invoice": "filename.jpg",
"created": "2022-01-01 10:00:00.123Z",
"updated": "2022-01-01 10:00:00.123Z"
}
```
Possible Flyer Types:
- digital_with_social
- digital_no_social
- physical_with_advertising
- physical_no_advertising
- newsletter
- other
Possible Logos:
[ ] IEEE
[ ] AS
[ ] HKN
[ ] TESC
[ ] PIB
[ ] TNT
[ ] SWE
[ ] OTHER
Possible Advertising Formats:
- pdf
- jpeg
- png
- does_not_matter

9385
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,9 +2,6 @@
"name": "ieeeucsd-dev",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=18.20.8"
},
"scripts": {
"dev": "astro dev",
"start": "node ./dist/server/entry.mjs",
@ -13,49 +10,23 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^4.2.3",
"@astrojs/node": "^9.1.3",
"@astrojs/react": "^4.2.3",
"@astrojs/tailwind": "^6.0.2",
"@heroui/react": "^2.7.5",
"@iconify-json/heroicons": "^1.2.2",
"@iconify-json/mdi": "^1.2.3",
"@iconify/react": "^5.2.0",
"@types/highlight.js": "^10.1.0",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.15",
"@types/puppeteer": "^7.0.4",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.1",
"astro": "^5.5.6",
"astro-expressive-code": "^0.40.2",
"astro-icon": "^1.1.5",
"chart.js": "^4.4.7",
"dexie": "^4.0.11",
"framer-motion": "^12.6.2",
"highlight.js": "^11.11.1",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
"@astrojs/mdx": "4.0.3",
"@astrojs/node": "^9.0.0",
"@astrojs/react": "4.1.2",
"@astrojs/tailwind": "5.1.4",
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.2",
"astro": "5.1.1",
"astro-expressive-code": "^0.38.3",
"astro-icon": "^1.1.4",
"motion": "^11.15.0",
"next": "^15.1.2",
"pocketbase": "^0.25.1",
"prismjs": "^1.29.0",
"puppeteer": "^24.10.1",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
"react-hot-toast": "^2.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"rehype-expressive-code": "^0.40.2",
"resend": "^4.5.1",
"tailwindcss": "^3.4.16",
"typescript": "^5.8.3"
"tailwindcss": "^3.4.16"
},
"devDependencies": {
"@types/prismjs": "^1.26.5",
"daisyui": "^4.12.23",
"prettier": "^3.4.2",
"prettier-plugin-astro": "^0.14.1",
"tailwindcss-animated": "^1.1.2",

View file

@ -1,19 +1,17 @@
---
import { LiaDotCircle } from "react-icons/lia";
const { title, text } = Astro.props;
const {title, text} = Astro.props;
---
<div class="flex flex-col items-center text-white my-[10%]">
<div class="flex items-center text-[4.5vw] md:text-[2.5vw] mb-[3%]">
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
<p
class="text-transparent bg-clip-text bg-gradient-to-b from-white via-white to-ieee-black"
>
{title}
</p>
</div>
<div class="flex items-center text-[4.5vw] md:text-[2.5vw] mb-[3%]">
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
<p class="text-transparent bg-clip-text bg-gradient-to-b from-white via-white to-ieee-black">
{title}
</p>
</div>
<p class="w-[70%] md:text-[1.4vw] text-[2vw] font-light">
{text}
</p>
</div>
<p class="w-[70%] md:text-[1.4vw] text-[2vw] font-light ">
{text}
</p>
</div>

View file

@ -1,22 +1,18 @@
<script>
const observer = new IntersectionObserver(
(entries) => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("in-view");
entry.target.classList.remove("opacity-0");
// console.log("Added 'in-view' class to:", entry.target);
console.log("Added 'in-view' class to:", entry.target);
} else {
entry.target.classList.remove("in-view");
entry.target.classList.add("opacity-0");
// console.log("Removed 'in-view' class from:", entry.target);
console.log("Removed 'in-view' class from:", entry.target);
}
});
},
{ threshold: 0.2 },
);
document
.querySelectorAll("[data-inview]")
.forEach((el) => observer.observe(el));
</script>
}, { threshold: 0.2 });
document.querySelectorAll("[data-inview]").forEach((el) => observer.observe(el));
</script>

View file

@ -24,42 +24,19 @@ import pages from "../../data/pages.json";
<!-- Desktop Navigation -->
<div class="hidden md:flex md:w-[55%] md:justify-between">
{
pages.map((page) =>
page.subpages ? (
<div class="relative group">
<a
href={page.path}
class="uppercase rounded-full duration-300 px-[1.5vw] py-[0.2vw] text-[1.2vw] text-nowrap text-white border-white hover:opacity-50 border-[0.1vw] font-light inline-block"
>
{page.name}
</a>
<div class="absolute left-1/2 transform -translate-x-1/2 w-[12vw] top-full z-50">
<div class="rounded-lg bg-ieee-black border border-white shadow-lg hidden group-hover:block animate-fade-down animate-duration-200 animate-ease-in-out">
{page.subpages.map((subpage) => (
<a
href={subpage.path}
class="font-light py-[4%] block text-white hover:bg-gray-700 text-[1.3vw] first:rounded-t-lg last:rounded-b-lg text-center whitespace-nowrap transition-colors duration-200"
>
{subpage.name}
</a>
))}
</div>
</div>
</div>
) : (
<a
href={page.path}
class={`uppercase rounded-full duration-300 px-[1.5vw] py-[0.2vw] text-[1.2vw] text-nowrap
${
page.name === "Dashboard"
? "bg-ieee-yellow text-black hover:opacity-70"
: "text-white border-white hover:opacity-50 border-[0.1vw] font-light"
}`}
>
{page.name}
</a>
),
)
pages.map((page) => (
<a
href={page.path}
class={`uppercase rounded-full duration-300 px-[1.5vw] py-[0.2vw] text-[1.2vw] text-nowrap
${
page.name === "Online Store"
? "bg-ieee-yellow text-black hover:opacity-70"
: "text-white border-white hover:opacity-50 border-[0.1vw] font-light"
}`}
>
{page.name}
</a>
))
}
</div>
@ -101,66 +78,25 @@ import pages from "../../data/pages.json";
<!-- Mobile Menu -->
<div
id="mobile-menu"
class="fixed inset-0 z-[51] hidden xl:hidden motion-safe:transition-transform motion-safe:duration-300 translate-x-full bg-black"
class="fixed inset-0 z-[51] hidden xl:hidden motion-safe:transition-transform motion-safe:duration-300 translate-x-full"
>
<div
class="flex flex-col items-center min-h-screen w-full justify-center py-20 px-4 space-y-6 overflow-y-auto"
class="flex flex-col items-center min-h-screen justify-center bg-black py-20 px-4 space-y-8"
>
{
pages.map((page) =>
page.subpages ? (
<div class="w-full max-w-md space-y-4 relative dropdown-container">
<div class="flex gap-2">
<a
href={page.path}
class="flex-1 block py-4 px-12 text-center rounded-[3rem] motion-safe:transition-colors motion-safe:duration-200 uppercase font-bold text-2xl text-white hover:text-gray-300 border-white border-2"
>
{page.name}
</a>
<button
class="mobile-dropdown-toggle py-4 px-6 text-center rounded-[3rem] motion-safe:transition-all motion-safe:duration-200 uppercase font-bold text-2xl text-white hover:text-gray-300 border-white border-2 flex items-center justify-center"
aria-expanded="false"
>
<svg
class="w-6 h-6 transform transition-transform duration-200 dropdown-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
<div class="mobile-dropdown-content mt-2 space-y-2 pl-4 absolute w-full animate-duration-200 animate-ease-in-out">
{page.subpages.map((subpage) => (
<a
href={subpage.path}
class="block py-3 px-8 text-center rounded-[2rem] motion-safe:transition-all motion-safe:duration-200 uppercase font-medium text-lg w-full text-white hover:text-gray-300 border-white border bg-[#111111] hover:bg-[#222222]"
>
{subpage.name}
</a>
))}
</div>
</div>
) : (
<a
href={page.path}
class={`block py-4 px-12 text-center rounded-[3rem] motion-safe:transition-colors motion-safe:duration-200 uppercase font-bold text-2xl w-full max-w-md
${
page.name === "Dashboard"
? "bg-[#f3c135] text-black border-[#f3c135] hover:bg-[#dba923] hover:border-[#dba923]"
: "text-white hover:text-gray-300 border-white border-2"
}`}
>
{page.name}
</a>
),
)
pages.map((page) => (
<a
href={page.path}
class={`block py-4 px-12 text-center rounded-[3rem] motion-safe:transition-colors motion-safe:duration-200 uppercase font-bold text-2xl w-full max-w-md
${
page.name === "Online Store"
? "bg-[#f3c135] text-black border-[#f3c135] hover:bg-[#dba923] hover:border-[#dba923]"
: "text-white hover:text-gray-300 border-white border-2"
}`}
>
{page.name}
</a>
))
}
</div>
</div>
@ -170,111 +106,6 @@ import pages from "../../data/pages.json";
#mobile-menu.show {
@apply translate-x-0;
}
.mobile-dropdown-content {
opacity: 0;
pointer-events: none;
z-index: 30;
width: calc(100% - 1rem) !important;
animation: fadeOut 0.2s ease-in-out forwards;
}
.mobile-dropdown-content.show {
opacity: 1;
pointer-events: all;
backdrop-filter: none;
animation: fadeIn 0.2s ease-in-out forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-10px);
}
}
.mobile-dropdown-content a {
margin-inline: auto;
width: 100% !important;
max-width: calc(100% - 1rem);
}
.dropdown-icon.rotated {
transform: rotate(180deg);
}
/* Add styles for focus effect */
.dropdown-container {
transition: all 0.3s ease;
position: relative;
isolation: isolate;
}
.dropdown-container.active {
transform: scale(1.02);
z-index: 25;
backdrop-filter: none;
filter: none;
transition: all 0.2s ease;
}
.dropdown-container.active .mobile-dropdown-toggle,
.dropdown-container.active > div > a {
position: relative;
z-index: 25;
pointer-events: all;
backdrop-filter: none;
filter: none;
transition: all 0.2s ease;
}
.dropdown-container.active::after {
content: "";
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 15;
pointer-events: none;
transition: all 0.3s ease;
}
#mobile-menu.has-active-dropdown > div > *:not(.dropdown-container.active) {
opacity: 0.2;
transform: scale(0.98);
filter: blur(2px);
transition: all 0.2s ease;
pointer-events: none;
}
#mobile-menu.has-active-dropdown .dropdown-container.active {
pointer-events: all;
filter: none;
transition: all 0.2s ease;
}
#mobile-menu.has-active-dropdown
.dropdown-container.active
.mobile-dropdown-content.show {
filter: none;
backdrop-filter: none;
background: transparent;
}
</style>
<script>
@ -282,7 +113,6 @@ import pages from "../../data/pages.json";
const mobileMenu = document.getElementById("mobile-menu");
const menuIcon = document.querySelector(".menu-icon");
const closeIcon = document.querySelector(".close-icon");
const dropdownToggles = document.querySelectorAll(".mobile-dropdown-toggle");
function toggleMenu(show: boolean) {
if (show) {
@ -300,23 +130,9 @@ import pages from "../../data/pages.json";
closeIcon?.classList.add("hidden");
document.body.style.overflow = "";
// First wait for the navbar to slide out
setTimeout(() => {
mobileMenu?.classList.add("hidden");
// Then reset all dropdowns and focus states
document.querySelectorAll(".dropdown-container").forEach((el) => {
const dropdownContent = el.querySelector(".mobile-dropdown-content");
const dropdownToggle = el.querySelector(".mobile-dropdown-toggle");
const dropdownIcon = dropdownToggle?.querySelector(".dropdown-icon");
el.classList.remove("active");
dropdownContent?.classList.remove("show");
dropdownIcon?.classList.remove("rotated");
dropdownToggle?.setAttribute("aria-expanded", "false");
});
mobileMenu?.classList.remove("has-active-dropdown");
}, 300);
}, 100);
}
}
@ -325,57 +141,7 @@ import pages from "../../data/pages.json";
toggleMenu(isMenuHidden);
});
// Handle dropdown toggles
dropdownToggles.forEach((toggle) => {
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const container = toggle.closest(".dropdown-container");
const content = toggle.parentElement?.nextElementSibling as HTMLElement;
const icon = toggle.querySelector(".dropdown-icon");
const isExpanded = toggle.getAttribute("aria-expanded") === "true";
// If clicking an already active dropdown, close it and restore interactions
if (isExpanded) {
// First close the dropdown
content?.classList.remove("show");
icon?.classList.remove("rotated");
// Then wait for animation to complete plus a delay before removing focus
setTimeout(() => {
container?.classList.remove("active");
mobileMenu?.classList.remove("has-active-dropdown");
toggle.setAttribute("aria-expanded", "false");
}, 300); // 200ms for animation + 100ms delay
return;
}
// Remove active state from all dropdowns first
document.querySelectorAll(".dropdown-container").forEach((el) => {
const dropdownContent = el.querySelector(".mobile-dropdown-content");
const dropdownToggle = el.querySelector(".mobile-dropdown-toggle");
const dropdownIcon = dropdownToggle?.querySelector(".dropdown-icon");
dropdownContent?.classList.remove("show");
dropdownIcon?.classList.remove("rotated");
dropdownToggle?.setAttribute("aria-expanded", "false");
});
// First focus the new dropdown
container?.classList.add("active");
mobileMenu?.classList.add("has-active-dropdown");
toggle.setAttribute("aria-expanded", "true");
icon?.classList.add("rotated");
// Then show the content with animation after a short delay
setTimeout(() => {
requestAnimationFrame(() => {
content?.classList.add("show");
});
}, 100);
});
});
// Close menu when clicking outside ~ Doesnt really work at the moment
// Close menu when clicking outside
document.addEventListener("click", (e) => {
if (
!mobileMenu?.contains(e.target as Node) &&

View file

@ -1,11 +1,11 @@
---
const { title } = Astro.props;
const {title} = Astro.props;
import { LiaDotCircle } from "react-icons/lia";
---
<div class="flex items-center md:text-[2.5vw] text-[4vw] mb-[5%]">
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
<p>
{title}
</p>
</div>
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
<p>
{title}
</p>
</div>

View file

@ -1,3 +0,0 @@
---
---

View file

@ -1,533 +0,0 @@
---
import FilePreview from "./universal/FilePreview";
import EventCheckIn from "./EventsSection/EventCheckIn";
import EventLoad from "./EventsSection/EventLoad";
---
<div id="" class="">
<div class="mb-4 sm:mb-6 px-4 sm:px-6">
<h2 class="text-xl sm:text-2xl font-bold">Events</h2>
<p class="opacity-70 text-sm sm:text-base">
View and manage your IEEE UCSD events
</p>
</div>
<div
class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 mb-4 sm:mb-6 px-4 sm:px-6"
>
<!-- Event Check-in Card -->
<div class="w-full">
<EventCheckIn client:load />
</div>
<!-- Event Registration Card -->
<div class="w-full">
<div
class="card bg-base-100 shadow-xl border border-base-200 opacity-50 cursor-not-allowed relative group h-full"
>
<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 text-sm sm:text-base"
>Coming Soon</span
>
</div>
<div class="card-body p-4 sm:p-6">
<h3 class="card-title text-base sm:text-lg mb-3 sm:mb-4">
Event Registration
</h3>
<div class="form-control w-full">
<label class="label">
<span class="label-text text-sm sm:text-base"
>Select an event to register</span
>
</label>
<div class="flex flex-col sm:flex-row gap-2">
<select
class="select select-bordered flex-1 text-sm sm:text-base h-10 min-h-[2.5rem] w-full"
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 text-sm sm:text-base h-10 min-h-[2.5rem] w-full sm:w-[100px]"
disabled>Register</button
>
</div>
</div>
</div>
</div>
</div>
</div>
<EventLoad client:load />
</div>
<!-- Event Details Modal -->
<dialog id="eventDetailsModal" class="modal">
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
<div class="flex justify-between items-center mb-3 sm:mb-4">
<div class="flex items-center gap-2 sm:gap-3">
<h3 class="font-bold text-base sm:text-lg" id="modalTitle">
Event Files
</h3>
<button
id="downloadAllBtn"
class="btn btn-primary btn-sm gap-1 text-xs sm:text-sm"
onclick="window.downloadAllFiles()"
>
<iconify-icon
icon="heroicons:arrow-down-tray-20-solid"
className="h-3 w-3 sm:h-4 sm:w-4"></iconify-icon>
Download All
</button>
</div>
<button
class="btn btn-circle btn-ghost btn-sm sm:btn-md"
onclick="window.closeEventDetailsModal()"
>
<iconify-icon icon="heroicons:x-mark" className="h-4 w-4 sm:h-6 sm:w-6"
></iconify-icon>
</button>
</div>
<div id="filesContent" class="space-y-3 sm:space-y-4">
<!-- Files list will be populated here -->
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick="window.closeEventDetailsModal()">close</button>
</form>
</dialog>
<!-- Universal File Preview Modal -->
<dialog id="filePreviewModal" class="modal">
<div class="modal-box max-w-[90vw] sm:max-w-4xl p-4 sm:p-6">
<div class="flex justify-between items-center mb-3 sm:mb-4">
<div class="flex items-center gap-2 sm:gap-3">
<button
class="btn btn-ghost btn-sm text-xs sm:text-sm"
onclick="window.closeFilePreviewEvents()">Close</button
>
<h3
class="font-bold text-base sm:text-lg truncate"
id="previewFileName"
>
</h3>
</div>
</div>
<div class="relative" id="previewContainer">
<div
id="previewLoadingSpinner"
class="absolute inset-0 flex items-center justify-center bg-base-200 bg-opacity-50 hidden"
>
<span class="loading loading-spinner loading-md sm:loading-lg"></span>
</div>
<div id="previewContent" class="w-full">
<FilePreview client:load isModal={true} />
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick="window.closeFilePreviewEvents()">close</button>
</form>
</dialog>
<script>
import { toast } from "react-hot-toast";
import JSZip from "jszip";
// Add styles to the document
const style = document.createElement("style");
style.textContent = `
/* Custom styles for the event details modal */
.event-details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 640px) {
.event-details-grid {
grid-template-columns: 1fr;
}
}
/* Remove custom toast styles since we're using react-hot-toast */
`;
document.head.appendChild(style);
// Add helper functions for file preview
function getFileType(filename: string): string {
const extension = filename.split(".").pop()?.toLowerCase();
const mimeTypes: { [key: string]: string } = {
pdf: "application/pdf",
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
mp4: "video/mp4",
mp3: "audio/mpeg",
txt: "text/plain",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
json: "application/json",
};
return mimeTypes[extension || ""] || "application/octet-stream";
}
// Universal file preview function for events section
window.previewFileEvents = function (url: string, filename: string) {
// console.log("previewFileEvents called with:", { url, filename });
// console.log("URL type:", typeof url, "URL length:", url?.length || 0);
// console.log(
// "Filename type:",
// typeof filename,
// "Filename length:",
// filename?.length || 0
// );
// Validate inputs
if (!url || typeof url !== "string") {
console.error("Invalid URL provided to previewFileEvents:", url);
toast.error("Cannot preview file: Invalid URL");
return;
}
if (!filename || typeof filename !== "string") {
console.error(
"Invalid filename provided to previewFileEvents:",
filename,
);
toast.error("Cannot preview file: Invalid filename");
return;
}
// Ensure URL is properly formatted
if (!url.startsWith("http")) {
console.warn("URL doesn't start with http, attempting to fix:", url);
if (url.startsWith("/")) {
url = `https://pocketbase.ieeeucsd.org${url}`;
} else {
url = `https://pocketbase.ieeeucsd.org/${url}`;
}
// console.log("Fixed URL:", url);
}
const modal = document.getElementById(
"filePreviewModal",
) as HTMLDialogElement;
const previewFileName = document.getElementById("previewFileName");
const previewContent = document.getElementById("previewContent");
const loadingSpinner = document.getElementById("previewLoadingSpinner");
if (modal && previewFileName && previewContent) {
// console.log("Found all required elements");
// Show loading spinner
if (loadingSpinner) {
loadingSpinner.classList.remove("hidden");
}
// Update the filename display
previewFileName.textContent = filename;
// Show the modal
modal.showModal();
// Test the URL with a fetch before dispatching the event
fetch(url, { method: "HEAD" })
.then((response) => {
// console.log(
// "URL test response:",
// response.status,
// response.ok
// );
if (!response.ok) {
console.warn("URL might not be accessible:", url);
toast(
"File might not be accessible. Attempting to preview anyway.",
{
icon: "⚠️",
style: {
borderRadius: "10px",
background: "#FFC107",
color: "#000",
},
},
);
}
})
.catch((err) => {
console.error("Error testing URL:", err);
})
.finally(() => {
// Dispatch state change event to update the FilePreview component
// console.log(
// "Dispatching filePreviewStateChange event with:",
// { url, filename }
// );
window.dispatchEvent(
new CustomEvent("filePreviewStateChange", {
detail: { url, filename },
}),
);
});
// Hide loading spinner after a short delay
setTimeout(() => {
if (loadingSpinner) {
loadingSpinner.classList.add("hidden");
}
}, 1000); // Increased delay to allow for URL testing
} else {
console.error("Missing required elements for file preview");
toast.error("Could not initialize file preview");
}
};
// Close file preview for events section
window.closeFilePreviewEvents = function () {
// console.log("closeFilePreviewEvents called");
const modal = document.getElementById(
"filePreviewModal",
) as HTMLDialogElement;
const previewFileName = document.getElementById("previewFileName");
const previewContent = document.getElementById("previewContent");
const loadingSpinner = document.getElementById("previewLoadingSpinner");
if (loadingSpinner) {
loadingSpinner.classList.add("hidden");
}
if (modal && previewFileName && previewContent) {
// console.log("Resetting preview and closing modal");
// First reset the preview state by dispatching an event with empty values
// This ensures the FilePreview component clears its internal state
window.dispatchEvent(
new CustomEvent("filePreviewStateChange", {
detail: { url: "", filename: "" },
}),
);
// Reset the UI
previewFileName.textContent = "";
// Close the modal
modal.close();
// console.log("File preview modal closed and state reset");
} else {
console.error("Could not find elements to close file preview");
}
};
// Update the showFilePreview function for events section
window.showFilePreviewEvents = function (file: {
url: string;
name: string;
}) {
// console.log("showFilePreviewEvents called with:", file);
if (!file || !file.url || !file.name) {
console.error("Invalid file data:", file);
toast.error("Could not preview file: missing file information");
return;
}
window.previewFileEvents(file.url, file.name);
};
// Update the openDetailsModal function to use the events-specific preview
window.openDetailsModal = function (event: any) {
const modal = document.getElementById(
"eventDetailsModal",
) as HTMLDialogElement;
const filesContent = document.getElementById(
"filesContent",
) as HTMLDivElement;
// Check if event has ended
const eventEndDate = new Date(event.end_date);
const now = new Date();
if (eventEndDate > now) {
toast("Files are only available after the event has ended.", {
icon: "⚠️",
style: {
borderRadius: "10px",
background: "#FFC107",
color: "#000",
},
});
return;
}
// Reset state
window.currentEventId = event.id;
if (filesContent) filesContent.classList.remove("hidden");
// Populate files content
if (event.files && Array.isArray(event.files) && event.files.length > 0) {
const baseUrl = "https://pocketbase.ieeeucsd.org";
const collectionId = "events";
const recordId = event.id;
filesContent.innerHTML = `
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>File Name</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
${event.files
.map((file: string) => {
// Ensure the file URL is properly formatted
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${file}`;
const fileType = getFileType(file);
// Properly escape the data for the onclick handler
const fileData = {
url: fileUrl,
name: file,
};
return `
<tr>
<td>${file}</td>
<td class="text-right">
<button class="btn btn-ghost btn-sm" onclick="window.showFilePreviewEvents({'url': '${fileUrl}', 'name': '${file}'})">
<iconify-icon icon="heroicons:document" className="h-4 w-4" />
</button>
<a href="${fileUrl}" download="${file}" class="btn btn-ghost btn-sm">
<iconify-icon icon="heroicons:arrow-down-tray-20-solid" className="h-4 w-4" />
</a>
</td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
`;
} else {
filesContent.innerHTML = `
<div class="text-center py-8 text-base-content/70">
<iconify-icon icon="heroicons:document-duplicate" className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No files attached to this event</p>
</div>
`;
}
modal.showModal();
};
// Add downloadAllFiles function
window.downloadAllFiles = async function () {
const downloadBtn = document.getElementById(
"downloadAllBtn",
) as HTMLButtonElement;
if (!downloadBtn) return;
const originalBtnContent = downloadBtn.innerHTML;
try {
// Show loading state
downloadBtn.innerHTML =
'<span class="loading loading-spinner loading-xs"></span> Preparing...';
downloadBtn.disabled = true;
const zip = new JSZip();
// Get current event files
const baseUrl = "https://pocketbase.ieeeucsd.org";
const collectionId = "events";
const recordId = window.currentEventId;
// Get the current event from the window object
const eventDataId = `event_${window.currentEventId}`;
const event = window[eventDataId];
if (!event || !event.files || event.files.length === 0) {
throw new Error("No files available to download");
}
// Download each file and add to zip
const filePromises = event.files.map(async (filename: string) => {
const fileUrl = `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to download ${filename}`);
}
const blob = await response.blob();
zip.file(filename, blob);
});
await Promise.all(filePromises);
// Generate and download zip
const zipBlob = await zip.generateAsync({ type: "blob" });
const downloadUrl = URL.createObjectURL(zipBlob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = `${event.event_name}_files.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
// Show success message
toast.success("Files downloaded successfully!");
} catch (error: any) {
console.error("Failed to download files:", error);
toast.error(
error?.message || "Failed to download files. Please try again.",
);
} finally {
// Reset button state
downloadBtn.innerHTML = originalBtnContent;
downloadBtn.disabled = false;
}
};
// Close event details modal
window.closeEventDetailsModal = function () {
const modal = document.getElementById(
"eventDetailsModal",
) as HTMLDialogElement;
const filesContent = document.getElementById("filesContent");
if (modal) {
// Reset the files content
if (filesContent) {
filesContent.innerHTML = "";
}
// Reset any other state if needed
window.currentEventId = "";
// Close the modal
modal.close();
}
};
// Make helper functions available globally
window.showFilePreview = window.showFilePreviewEvents;
window.handlePreviewError = function () {
const previewContent = document.getElementById("previewContent");
if (previewContent) {
previewContent.innerHTML = `
<div class="alert alert-error">
<Icon icon="heroicons:x-circle" className="h-6 w-6" />
<span>Failed to load file preview</span>
</div>
`;
}
};
</script>

View file

@ -1,567 +0,0 @@
import { useState, useEffect } 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";
import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema";
import { Icon } from "@iconify/react";
import toast from "react-hot-toast";
import type { Event, EventAttendee, LimitedUser } from "../../../schemas/pocketbase";
// Extended Event interface with additional properties needed for this component
interface ExtendedEvent extends Event {
description?: string; // This component uses 'description' but schema has 'event_description'
event_type: string; // Add event_type field from schema
}
// Note: Date conversion is now handled automatically by the Get and Update classes.
// When fetching events, UTC dates are converted to local time.
// When saving events, local dates are converted back to UTC.
const EventCheckIn = () => {
const [currentCheckInEvent, setCurrentCheckInEvent] = useState<Event | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [foodInput, setFoodInput] = useState("");
// SECURITY FIX: Purge event codes when component mounts
useEffect(() => {
const dataSync = DataSyncService.getInstance();
dataSync.purgeEventCodes().catch(err => {
console.error("Error purging event codes:", err);
});
}, []);
async function handleEventCheckIn(eventCode: string): Promise<void> {
try {
const get = Get.getInstance();
const auth = Authentication.getInstance();
const dataSync = DataSyncService.getInstance();
const logger = SendLog.getInstance();
const currentUser = auth.getCurrentUser();
if (!currentUser) {
await logger.send(
"error",
"event_check_in",
"Check-in failed: User not logged in"
);
toast.error("You must be logged in to check in to events");
return;
}
// Log the check-in attempt
await logger.send(
"info",
"event_check_in",
`Attempting to check in with code: ${eventCode}`
);
// Validate event code
if (!eventCode || eventCode.trim() === "") {
await logger.send(
"error",
"event_check_in",
"Check-in failed: Empty event code"
);
toast.error("Please enter an event code");
return;
}
// Get event by code
const events = await get.getList<Event>(
Collections.EVENTS,
1,
1,
`event_code="${eventCode}"`
);
if (events.totalItems === 0) {
await logger.send(
"error",
"event_check_in",
`Check-in failed: Invalid event code: ${eventCode}`
);
toast.error("Invalid event code. Please try again.");
return;
}
const event = events.items[0];
// Check if event is published
if (!event.published) {
await logger.send(
"error",
"event_check_in",
`Check-in failed: Event not published: ${event.event_name}`
);
toast.error("This event is not currently available for check-in");
return;
}
// Check if the event is active (has started and hasn't ended yet)
const currentTime = new Date();
const eventStartDate = new Date(event.start_date);
const eventEndDate = new Date(event.end_date);
if (currentTime < eventStartDate) {
await logger.send(
"error",
"event_check_in",
`Check-in failed: Event has not started yet: ${event.event_name}`
);
toast.error(`This event hasn't started yet. It begins on ${eventStartDate.toLocaleDateString()} at ${eventStartDate.toLocaleTimeString()}`);
return;
}
if (currentTime > eventEndDate) {
await logger.send(
"error",
"event_check_in",
`Check-in failed: Event has already ended: ${event.event_name}`
);
toast.error("This event has already ended");
return;
}
// Check if user is already checked in - IMPROVED VALIDATION
const attendees = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
50, // Increased limit to ensure we catch all possible duplicates
`user="${currentUser.id}" && event="${event.id}"`
);
if (attendees.totalItems > 0) {
const lastCheckIn = new Date(attendees.items[0].time_checked_in);
const timeSinceLastCheckIn = Date.now() - lastCheckIn.getTime();
const hoursAgo = Math.round(timeSinceLastCheckIn / (1000 * 60 * 60));
await logger.send(
"error",
"event_check_in",
`Check-in failed: Already checked in to event: ${event.event_name} (${hoursAgo} hours ago)`
);
toast.error(`You have already checked in to this event (${hoursAgo} hours ago)`);
return;
}
// Set current event for check-in
setCurrentCheckInEvent(event);
// Log successful event lookup
await logger.send(
"info",
"event_check_in",
`Found event for check-in: ${event.event_name}`
);
// Store event code in local storage for offline check-in
try {
await dataSync.storeEventCode(eventCode);
} catch (syncError) {
// Log the error but don't show a toast to the user
console.error("Error storing event code locally:", syncError);
}
// Show event details toast only for non-food events
// For food events, we'll show the toast after food selection
if (!event.has_food) {
toast.success(
<div>
<strong>Event found!</strong>
<p className="text-sm mt-1">{event.event_name}</p>
<p className="text-xs mt-1">
{event.points_to_reward > 0 ? `${event.points_to_reward} points` : "No points"}
</p>
</div>,
{ duration: 5000 }
);
}
// If event has food, show food selection modal
if (event.has_food) {
// Show food-specific toast
toast.success(
<div>
<strong>Event with food found!</strong>
<p className="text-sm mt-1">{event.event_name}</p>
<p className="text-xs mt-1">Please select the food you ate (or will eat) at the event!</p>
</div>,
{ duration: 5000 }
);
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
if (modal) modal.showModal();
} else {
// If no food, show confirmation modal
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
if (modal) modal.showModal();
}
} catch (error: any) {
console.error("Error checking in:", error);
toast.error(error.message || "An error occurred during check-in");
}
}
async function completeCheckIn(event: Event, foodSelection: string | null): Promise<void> {
try {
setIsLoading(true);
const auth = Authentication.getInstance();
const update = Update.getInstance();
const logger = SendLog.getInstance();
const dataSync = DataSyncService.getInstance();
const get = Get.getInstance();
const currentUser = auth.getCurrentUser();
if (!currentUser) {
throw new Error("You must be logged in to check in to events");
}
const userId = currentUser.id;
const eventId = event.id;
// Double-check for existing check-ins with improved validation
const existingAttendees = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
50, // Increased limit to ensure we catch all possible duplicates
`user="${userId}" && event="${eventId}"`
);
if (existingAttendees.totalItems > 0) {
const lastCheckIn = new Date(existingAttendees.items[0].time_checked_in);
const timeSinceLastCheckIn = Date.now() - lastCheckIn.getTime();
const hoursAgo = Math.round(timeSinceLastCheckIn / (1000 * 60 * 60));
await logger.send(
"error",
"event_check_in",
`Check-in failed: Already checked in to event: ${event.event_name} (${hoursAgo} hours ago)`
);
throw new Error(`You have already checked in to this event (${hoursAgo} hours ago)`);
}
// Create new attendee record with transaction to prevent race conditions
const attendeeData = {
user: userId,
event: eventId,
food_ate: foodSelection || "",
time_checked_in: new Date().toISOString(),
points_earned: event.points_to_reward || 0
};
try {
// Create the attendee record in PocketBase
const newAttendee = await update.create(Collections.EVENT_ATTENDEES, attendeeData);
// Update user's total points
// First, get all the user's attendance records to calculate total points
const userAttendance = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
1000,
`user="${userId}"`
);
// Calculate total points
let totalPoints = 0;
userAttendance.items.forEach(attendee => {
totalPoints += attendee.points_earned || 0;
});
// Update the LimitedUser record with the new points total
try {
// Try to get the LimitedUser record to check if it exists
let limitedUserExists = false;
try {
const limitedUser = await get.getOne(Collections.LIMITED_USERS, userId);
limitedUserExists = !!limitedUser;
} catch (e) {
// Record doesn't exist
limitedUserExists = false;
}
// Create or update the LimitedUser record
if (limitedUserExists) {
await update.updateFields(Collections.LIMITED_USERS, userId, {
points: JSON.stringify(totalPoints),
total_events_attended: JSON.stringify(userAttendance.totalItems)
});
} else {
// Get user data to create LimitedUser record
const userData = await get.getOne(Collections.USERS, userId);
if (userData) {
await update.create(Collections.LIMITED_USERS, {
id: userId, // Use same ID as user record
name: userData.name || 'Anonymous User',
major: userData.major || '',
points: JSON.stringify(totalPoints),
total_events_attended: JSON.stringify(userAttendance.totalItems)
});
}
}
} catch (error) {
console.error('Failed to update LimitedUser record:', error);
}
// Ensure local data is in sync with backend
// First sync the new attendance record
try {
await dataSync.syncCollection(Collections.EVENT_ATTENDEES);
// Then sync the updated user and LimitedUser data
await dataSync.syncCollection(Collections.USERS);
await dataSync.syncCollection(Collections.LIMITED_USERS);
} catch (syncError) {
// Log the error but don't show a toast to the user
console.error('Local sync failed:', syncError);
}
// Clear event code from local storage
try {
await dataSync.clearEventCode();
} catch (clearError) {
// Log the error but don't show a toast to the user
console.error("Error clearing event code from local storage:", clearError);
}
// Log successful check-in
await logger.send(
"info",
"event_check_in",
`Successfully checked in to event: ${event.event_name}`
);
// Show success message with event name and points
const pointsMessage = event.points_to_reward > 0
? ` (+${event.points_to_reward} points!)`
: "";
toast.success(`Successfully checked in to ${event.event_name}${pointsMessage}`);
// Close any open modals
const foodModal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
if (foodModal) foodModal.close();
const confirmModal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
if (confirmModal) confirmModal.close();
setCurrentCheckInEvent(null);
setFoodInput("");
} catch (createError: any) {
console.error("Error creating attendance record:", createError);
// Check if this is a duplicate record error (race condition handling)
if (createError.status === 400 && createError.data?.data?.user?.code === "validation_not_unique") {
throw new Error("You have already checked in to this event");
}
throw createError;
}
} catch (error: any) {
console.error("Error completing check-in:", error);
toast.error(error.message || "An error occurred during check-in");
} finally {
setIsLoading(false);
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!currentCheckInEvent) return;
try {
const auth = Authentication.getInstance();
const logger = SendLog.getInstance();
const get = Get.getInstance();
const currentUser = auth.getCurrentUser();
if (!currentUser) {
throw new Error("You must be logged in to check in to events");
}
// Additional check to prevent duplicate check-ins right before submission
const existingAttendees = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
1,
`user="${currentUser.id}" && event="${currentCheckInEvent.id}"`
);
// Check if user is already checked in
if (existingAttendees.totalItems > 0) {
throw new Error("You have already checked in to this event");
}
// Complete check-in with food selection
await completeCheckIn(currentCheckInEvent, foodInput);
} catch (error: any) {
console.error("Error submitting check-in:", error);
toast.error(error.message || "An error occurred during check-in");
}
};
return (
<>
<div className="card bg-base-100 shadow-xl border border-base-200 h-full">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Event Check-in</h3>
<div className="form-control w-full">
<label className="label">
<span className="label-text text-sm sm:text-base">Enter event code to check in</span>
</label>
<form onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.querySelector('input') as HTMLInputElement;
if (input.value.trim()) {
setIsLoading(true);
handleEventCheckIn(input.value.trim()).finally(() => {
setIsLoading(false);
input.value = "";
});
} else {
toast("Please enter an event code", {
icon: '⚠️',
style: {
borderRadius: '10px',
background: '#FFC107',
color: '#000',
},
});
}
}}>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="password"
placeholder="Enter code"
className="input input-bordered flex-1 text-sm sm:text-base h-10 min-h-[2.5rem] w-full"
onKeyPress={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
/>
<button
type="submit"
className="btn btn-primary h-10 min-h-[2.5rem] text-sm sm:text-base w-full sm:w-auto"
disabled={isLoading}
>
{isLoading ? (
<Icon
icon="line-md:loading-twotone-loop"
className="w-5 h-5"
inline={true}
/>
) : (
"Check In"
)}
</button>
</div>
</form>
</div>
</div>
</div>
{/* Food Selection Modal */}
<dialog id="foodSelectionModal" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg mb-2">{currentCheckInEvent?.event_name}</h3>
<div className="text-sm mb-4 opacity-75">
{currentCheckInEvent?.event_description}
</div>
<div className="badge badge-primary mb-4">
{currentCheckInEvent?.points_to_reward} points
</div>
<p className="mb-4">This event has food! Please let us know what you ate (or will eat):</p>
<form onSubmit={handleSubmit}>
<div className="form-control">
<input
type="text"
placeholder="Enter the food you will or are eating"
className="input input-bordered w-full"
value={foodInput}
onChange={(e) => setFoodInput(e.target.value)}
required
/>
</div>
<div className="modal-action">
<button type="button" className="btn" onClick={() => {
const modal = document.getElementById("foodSelectionModal") as HTMLDialogElement;
modal.close();
setCurrentCheckInEvent(null);
}}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={isLoading}>
{isLoading ? (
<Icon
icon="line-md:loading-twotone-loop"
className="w-5 h-5"
inline={true}
/>
) : (
"Check In"
)}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button>close</button>
</form>
</dialog>
{/* Confirmation Modal (for events without food) */}
<dialog id="confirmCheckInModal" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg mb-2">{currentCheckInEvent?.event_name}</h3>
<div className="text-sm mb-4 opacity-75">
{currentCheckInEvent?.event_description}
</div>
<div className="badge badge-primary mb-4">
{currentCheckInEvent?.points_to_reward} points
</div>
<p className="mb-4">Are you sure you want to check in to this event?</p>
<div className="modal-action">
<button
type="button"
className="btn"
onClick={() => {
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
modal.close();
setCurrentCheckInEvent(null);
}}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
disabled={isLoading}
onClick={() => {
if (currentCheckInEvent) {
completeCheckIn(currentCheckInEvent, null);
const modal = document.getElementById("confirmCheckInModal") as HTMLDialogElement;
modal.close();
}
}}
>
{isLoading ? (
<Icon
icon="line-md:loading-twotone-loop"
className="w-5 h-5"
inline={true}
/>
) : (
"Confirm Check In"
)}
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button>close</button>
</form>
</dialog>
</>
);
};
export default EventCheckIn;

View file

@ -1,675 +0,0 @@
import { useEffect, useState } from "react";
import { Icon } from "@iconify/react";
import { Get } from "../../../scripts/pocketbase/Get";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { DexieService } from "../../../scripts/database/DexieService";
import { Collections } from "../../../schemas/pocketbase/schema";
import type { Event, AttendeeEntry, EventAttendee } from "../../../schemas/pocketbase";
// Extended Event interface with additional properties needed for this component
interface ExtendedEvent extends Event {
description?: string; // This component uses 'description' but schema has 'event_description'
event_type: string; // Add event_type field from schema
}
declare global {
interface Window {
openDetailsModal: (event: ExtendedEvent) => void;
downloadAllFiles: () => Promise<void>;
currentEventId: string;
[key: string]: any;
}
}
// Helper function to validate event data integrity
const isValidEvent = (event: any): boolean => {
if (!event || typeof event !== 'object') return false;
// Check required fields
if (!event.id || !event.event_name) return false;
// Validate date fields
try {
const startDate = new Date(event.start_date);
const endDate = new Date(event.end_date);
// Check if dates are valid
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
console.warn(`Event ${event.id} has invalid date format`, {
start: event.start_date,
end: event.end_date
});
return false;
}
return true;
} catch (error) {
console.warn(`Error validating event ${event?.id || 'unknown'}:`, error);
return false;
}
};
const EventLoad = () => {
const [events, setEvents] = useState<{
upcoming: Event[];
ongoing: Event[];
past: Event[];
}>({
upcoming: [],
ongoing: [],
past: [],
});
const [loading, setLoading] = useState(true);
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(new Set());
const toggleDescription = (eventId: string) => {
setExpandedDescriptions(prev => {
const newSet = new Set(prev);
if (newSet.has(eventId)) {
newSet.delete(eventId);
} else {
newSet.add(eventId);
}
return newSet;
});
};
// Function to clear the events cache and force a fresh sync
const refreshEvents = async () => {
try {
setRefreshing(true);
// Get DexieService instance
const dexieService = DexieService.getInstance();
const db = dexieService.getDB();
// Clear events table
if (db && db.events) {
// console.log("Clearing events cache...");
await db.events.clear();
// console.log("Events cache cleared successfully");
}
// Reset sync timestamp for events by updating it to 0
// First get the current record
const currentInfo = await dexieService.getLastSync(Collections.EVENTS);
// Then update it with a timestamp of 0 (forcing a fresh sync)
await dexieService.updateLastSync(Collections.EVENTS);
// console.log("Events sync timestamp reset");
// Reload events
setLoading(true);
await loadEvents();
} catch (error) {
console.error("Error refreshing events:", error);
setErrorMessage("Failed to refresh events. Please try again.");
} finally {
setRefreshing(false);
}
};
useEffect(() => {
loadEvents();
}, []);
const createSkeletonCard = () => (
<div className="card bg-base-200 shadow-lg animate-pulse">
<div className="card-body p-5">
<div className="flex flex-col h-full">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex-1">
<div className="skeleton h-6 w-3/4 mb-2"></div>
<div className="flex items-center gap-2">
<div className="skeleton h-5 w-16"></div>
<div className="skeleton h-5 w-20"></div>
</div>
</div>
<div className="flex flex-col items-end">
<div className="skeleton h-5 w-24 mb-1"></div>
<div className="skeleton h-4 w-16"></div>
</div>
</div>
<div className="skeleton h-4 w-full mb-3"></div>
<div className="flex items-center gap-2">
<div className="skeleton h-4 w-4"></div>
<div className="skeleton h-4 w-1/2"></div>
</div>
</div>
</div>
</div>
);
const renderEventCard = (event: Event) => {
try {
// Get authentication instance
const auth = Authentication.getInstance();
const currentUser = auth.getCurrentUser();
// Check if user has attended this event by querying the event_attendees collection
let hasAttended = false;
if (currentUser) {
// We'll check attendance status when displaying the card
// This will be done asynchronously after rendering
setTimeout(async () => {
try {
const get = Get.getInstance();
const attendees = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
1,
`user="${currentUser.id}" && event="${event.id}"`
);
const hasAttendedEvent = attendees.totalItems > 0;
// Store the attendance status in the window object with the event
const eventDataId = `event_${event.id}`;
if (window[eventDataId]) {
window[eventDataId].hasAttended = hasAttendedEvent;
}
// Update the card UI based on attendance status
const cardElement = document.getElementById(`event-card-${event.id}`);
if (cardElement) {
const attendedBadge = document.getElementById(`attendance-badge-${event.id}`);
if (attendedBadge && hasAttendedEvent) {
attendedBadge.classList.remove('badge-ghost');
attendedBadge.classList.add('badge-success');
// Update the icon and text
const icon = attendedBadge.querySelector('svg');
if (icon) {
icon.setAttribute('icon', 'heroicons:check-circle');
}
// Update the text content
attendedBadge.textContent = '';
// Recreate the icon
const iconElement = document.createElement('span');
iconElement.className = 'h-3 w-3';
iconElement.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10zm-.997-6l7.07-7.071l-1.414-1.414l-5.656 5.657l-2.829-2.829l-1.414 1.414L11.003 16z"/></svg>';
attendedBadge.appendChild(iconElement);
// Add the text
const textNode = document.createTextNode(' Attended');
attendedBadge.appendChild(textNode);
}
}
} catch (error) {
console.error("Error checking attendance status:", error);
}
}, 0);
}
// Store event data in window object with unique ID
const eventDataId = `event_${event.id}`;
window[eventDataId] = event;
const startDate = new Date(event.start_date);
const endDate = new Date(event.end_date);
const now = new Date();
const isPastEvent = endDate < now;
const isExpanded = expandedDescriptions.has(event.id);
const description = event.event_description || "No description available";
return (
<div id={`event-card-${event.id}`} key={event.id} className="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-300 relative overflow-hidden">
<div className="card-body p-4">
{/* Event Header */}
<div className="flex justify-between items-start mb-2">
<h3 className="card-title text-base sm:text-lg font-semibold line-clamp-2">{event.event_name}</h3>
<div className="badge badge-primary badge-sm flex-shrink-0">{event.points_to_reward} pts</div>
</div>
{/* Event Description */}
<div className="mb-3">
<p className={`text-xs sm:text-sm text-base-content/70 ${isExpanded ? '' : 'line-clamp-2'}`}>
{description}
</p>
{description.length > 80 && (
<button
onClick={() => toggleDescription(event.id)}
className="text-xs text-primary hover:text-primary-focus mt-1 flex items-center"
>
{isExpanded ? (
<>
<Icon icon="heroicons:chevron-up" className="h-3 w-3 mr-1" />
Show less
</>
) : (
<>
<Icon icon="heroicons:chevron-down" className="h-3 w-3 mr-1" />
Show more
</>
)}
</button>
)}
</div>
{/* Event Details */}
<div className="grid grid-cols-1 gap-1 mb-3 text-xs sm:text-sm">
<div className="flex items-center gap-2">
<Icon icon="heroicons:calendar" className="h-3.5 w-3.5 text-primary" />
<span>
{startDate.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})}
</span>
</div>
<div className="flex items-center gap-2">
<Icon icon="heroicons:clock" className="h-3.5 w-3.5 text-primary" />
<span>
{startDate.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
})}
</span>
</div>
<div className="flex items-center gap-2">
<Icon icon="heroicons:map-pin" className="h-3.5 w-3.5 text-primary" />
<span className="line-clamp-1">{event.location || "No location specified"}</span>
</div>
<div className="flex items-center gap-2">
<Icon icon="heroicons:tag" className="h-3.5 w-3.5 text-primary" />
<span className="line-clamp-1 capitalize">{event.event_type || "Other"}</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap items-center gap-2 mt-auto">
{event.files && event.files.length > 0 && (
<button
onClick={() => window.openDetailsModal(event as ExtendedEvent)}
className="btn btn-accent btn-sm text-xs sm:text-sm gap-1 h-8 min-h-0 px-3 shadow-md hover:shadow-lg transition-all duration-300 rounded-full"
>
<Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" />
Files ({event.files.length})
</button>
)}
{isPastEvent && (
<div id={`attendance-badge-${event.id}`} className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1 ml-auto`}>
<Icon
icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
className="h-3 w-3"
/>
{hasAttended ? 'Attended' : 'Not Attended'}
</div>
)}
</div>
</div>
</div>
);
} catch (error) {
console.error("Error rendering event card:", error);
return null;
}
};
const loadEvents = async () => {
try {
const get = Get.getInstance();
const dataSync = DataSyncService.getInstance();
const auth = Authentication.getInstance();
// console.log("Starting to load events...");
// Check if user is authenticated
if (!auth.isAuthenticated()) {
// Silently return without error when on dashboard page
if (window.location.pathname.includes('/dashboard')) {
setLoading(false);
return;
}
console.error("User not authenticated, cannot load events");
setLoading(false);
return;
}
// Force sync to ensure we have the latest data
// console.log("Syncing events collection...");
let syncSuccess = false;
let retryCount = 0;
const maxRetries = 3;
while (!syncSuccess && retryCount < maxRetries) {
try {
if (retryCount > 0) {
// console.log(`Retry attempt ${retryCount} of ${maxRetries}...`);
// Add a small delay between retries
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
}
await dataSync.syncCollection(Collections.EVENTS, "published = true", "-start_date");
// console.log("Events collection synced successfully");
syncSuccess = true;
} catch (syncError) {
retryCount++;
console.error(`Error syncing events collection (attempt ${retryCount}/${maxRetries}):`, syncError);
if (retryCount >= maxRetries) {
console.warn("Max retry attempts reached, continuing with local data");
}
}
}
// Get events from IndexedDB
// console.log("Fetching events from IndexedDB...");
const allEvents = await dataSync.getData<Event>(
Collections.EVENTS,
false, // Don't force sync again
"published = true",
"-start_date"
);
// console.log(`Retrieved ${allEvents.length} events from IndexedDB`);
// Filter out invalid events
const validEvents = allEvents.filter(event => isValidEvent(event));
// console.log(`Filtered out ${allEvents.length - validEvents.length} invalid events`);
// If no valid events found in IndexedDB, try fetching directly from PocketBase as fallback
let eventsToProcess = validEvents;
if (allEvents.length === 0) {
// console.log("No events found in IndexedDB, trying direct PocketBase fetch...");
try {
const pbEvents = await get.getAll<Event>(
Collections.EVENTS,
"published = true",
"-start_date"
);
// console.log(`Retrieved ${pbEvents.length} events directly from PocketBase`);
// Filter out invalid events from PocketBase results
const validPbEvents = pbEvents.filter(event => isValidEvent(event));
// console.log(`Filtered out ${pbEvents.length - validPbEvents.length} invalid events from PocketBase`);
eventsToProcess = validPbEvents;
// Store these events in IndexedDB for future use
if (validPbEvents.length > 0) {
const dexieService = DexieService.getInstance();
const db = dexieService.getDB();
if (db && db.events) {
// console.log(`Storing ${validPbEvents.length} valid PocketBase events in IndexedDB...`);
await db.events.bulkPut(validPbEvents);
}
}
} catch (pbError) {
console.error("Error fetching events from PocketBase:", pbError);
}
}
// Split events into upcoming, ongoing, and past based on start and end dates
// console.log("Categorizing events...");
const now = new Date();
const { upcoming, ongoing, past } = eventsToProcess.reduce(
(acc, event) => {
try {
// 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) {
acc.upcoming.push(event);
} else if (endLocal < nowLocal) {
acc.past.push(event);
} else {
acc.ongoing.push(event);
}
} catch (dateError) {
console.error("Error processing event dates:", dateError, event);
// If we can't process dates properly, put in past events as fallback
acc.past.push(event);
}
return acc;
},
{
upcoming: [] as Event[],
ongoing: [] as Event[],
past: [] as Event[],
}
);
// console.log(`Categorized events: ${upcoming.length} upcoming, ${ongoing.length} ongoing, ${past.length} past`);
// Sort events
upcoming.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime());
ongoing.sort((a, b) => new Date(a.end_date).getTime() - new Date(b.end_date).getTime());
past.sort((a, b) => new Date(b.end_date).getTime() - new Date(a.end_date).getTime());
setEvents({
upcoming: upcoming.slice(0, 50), // Limit to 50 events per section
ongoing: ongoing.slice(0, 50),
past: past.slice(0, 50)
});
setLoading(false);
} catch (error) {
console.error("Failed to load events:", error);
// Attempt to diagnose the error
if (error instanceof Error) {
console.error(`Error type: ${error.name}, Message: ${error.message}`);
console.error(`Stack trace: ${error.stack}`);
// Check for network-related errors
if (error.message.includes('network') || error.message.includes('fetch') || error.message.includes('connection')) {
console.error("Network-related error detected");
// Try to load from IndexedDB only as a last resort
try {
// console.log("Attempting to load events from IndexedDB only...");
const dexieService = DexieService.getInstance();
const db = dexieService.getDB();
if (db && db.events) {
const allCachedEvents = await db.events.filter(event => event.published === true).toArray();
// console.log(`Found ${allCachedEvents.length} cached events in IndexedDB`);
// Filter out invalid events
const cachedEvents = allCachedEvents.filter(event => isValidEvent(event));
// console.log(`Filtered out ${allCachedEvents.length - cachedEvents.length} invalid cached events`);
if (cachedEvents.length > 0) {
// Process these events
const now = new Date();
const { upcoming, ongoing, past } = cachedEvents.reduce(
(acc, event) => {
try {
const startDate = new Date(event.start_date);
const endDate = new Date(event.end_date);
if (startDate > now) {
acc.upcoming.push(event);
} else if (endDate < now) {
acc.past.push(event);
} else {
acc.ongoing.push(event);
}
} catch (e) {
acc.past.push(event);
}
return acc;
},
{
upcoming: [] as Event[],
ongoing: [] as Event[],
past: [] as Event[],
}
);
// Sort and set events
upcoming.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime());
ongoing.sort((a, b) => new Date(a.end_date).getTime() - new Date(b.end_date).getTime());
past.sort((a, b) => new Date(b.end_date).getTime() - new Date(a.end_date).getTime());
setEvents({
upcoming: upcoming.slice(0, 50),
ongoing: ongoing.slice(0, 50),
past: past.slice(0, 50)
});
// console.log("Successfully loaded events from cache");
}
}
} catch (cacheError) {
console.error("Failed to load events from cache:", cacheError);
}
}
}
setLoading(false);
}
};
if (loading) {
return (
<>
{/* Ongoing Events */}
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{[...Array(3)].map((_, i) => (
<div key={`ongoing-skeleton-${i}`}>{createSkeletonCard()}</div>
))}
</div>
</div>
</div>
{/* Upcoming Events */}
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{[...Array(3)].map((_, i) => (
<div key={`upcoming-skeleton-${i}`}>{createSkeletonCard()}</div>
))}
</div>
</div>
</div>
{/* Past Events */}
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{[...Array(3)].map((_, i) => (
<div key={`past-skeleton-${i}`}>{createSkeletonCard()}</div>
))}
</div>
</div>
</div>
</>
);
}
// Check if there are no events at all
const noEvents = events.ongoing.length === 0 && events.upcoming.length === 0 && events.past.length === 0;
return (
<>
{/* No Events Message */}
{noEvents && (
<div className="card bg-base-100 shadow-xl border border-base-200 mx-4 sm:mx-6 p-8">
<div className="text-center">
<Icon icon="heroicons:calendar" className="w-16 h-16 mx-auto text-base-content/30 mb-4" />
<h3 className="text-xl font-bold mb-2">No Events Found</h3>
<p className="text-base-content/70 mb-4">
There are currently no events to display. This could be due to:
</p>
<ul className="list-disc text-left max-w-md mx-auto text-base-content/70 mb-6">
<li className="mb-1">No events have been published yet</li>
<li className="mb-1">There might be a connection issue with the event database</li>
<li className="mb-1">The events data might be temporarily unavailable</li>
</ul>
<button
onClick={refreshEvents}
className="btn btn-primary"
disabled={refreshing}
>
{refreshing ? (
<>
<Icon icon="heroicons:arrow-path" className="w-5 h-5 mr-2 animate-spin" />
Refreshing...
</>
) : (
<>
<Icon icon="heroicons:arrow-path" className="w-5 h-5 mr-2" />
Refresh Events
</>
)}
</button>
</div>
</div>
)}
{/* Ongoing Events */}
{events.ongoing.length > 0 && (
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Ongoing Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{events.ongoing.map(renderEventCard)}
</div>
</div>
</div>
)}
{/* Upcoming Events */}
{events.upcoming.length > 0 && (
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-4 sm:mb-6 mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Upcoming Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{events.upcoming.map(renderEventCard)}
</div>
</div>
</div>
)}
{/* Past Events */}
{events.past.length > 0 && (
<div className="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mx-4 sm:mx-6">
<div className="card-body p-4 sm:p-6">
<h3 className="card-title text-base sm:text-lg mb-3 sm:mb-4">Past Events</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{events.past.map(renderEventCard)}
</div>
</div>
</div>
)}
</>
);
};
export default EventLoad;

View file

@ -1,34 +0,0 @@
---
import LeaderboardTable from "./LeaderboardSection/LeaderboardTable";
import LeaderboardStats from "./LeaderboardSection/LeaderboardStats";
---
<div class="grid gap-6">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl mb-4 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-6 h-6 text-[#f6b93b]"
>
<path
fill-rule="evenodd"
d="M5.166 2.621v.858c-1.035.148-2.059.33-3.071.543a.75.75 0 00-.584.859 6.753 6.753 0 006.138 5.6 6.73 6.73 0 002.743 1.346A6.707 6.707 0 019.279 15H8.54c-1.036 0-1.875.84-1.875 1.875V19.5h-.75a2.25 2.25 0 00-2.25 2.25c0 .414.336.75.75.75h15a.75.75 0 00.75-.75 2.25 2.25 0 00-2.25-2.25h-.75v-2.625c0-1.036-.84-1.875-1.875-1.875h-.739a6.706 6.706 0 01-1.112-3.173 6.73 6.73 0 002.743-1.347 6.753 6.753 0 006.139-5.6.75.75 0 00-.585-.858 47.077 47.077 0 00-3.07-.543V2.62a.75.75 0 00-.658-.744 49.22 49.22 0 00-6.093-.377c-2.063 0-4.096.128-6.093.377a.75.75 0 00-.657.744zm0 2.629c0 1.196.312 2.32.857 3.294A5.266 5.266 0 013.16 5.337a45.6 45.6 0 012.006-.343v.256zm13.5 0v-.256c.674.1 1.343.214 2.006.343a5.265 5.265 0 01-2.863 3.207 6.72 6.72 0 00.857-3.294z"
clip-rule="evenodd"></path>
</svg>
Leaderboard
</h2>
<div class="divider mt-0 mb-4"></div>
<!-- Stats cards -->
<div class="mb-6">
<LeaderboardStats client:load />
</div>
<!-- Leaderboard table -->
<LeaderboardTable client:load />
</div>
</div>
</div>

View file

@ -1,189 +0,0 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { LimitedUser } from '../../../schemas/pocketbase/schema';
interface LeaderboardStats {
totalUsers: number;
totalPoints: number;
topScore: number;
yourPoints: number;
yourRank: number | null;
}
export default function LeaderboardStats() {
const [stats, setStats] = useState<LeaderboardStats>({
totalUsers: 0,
totalPoints: 0,
topScore: 0,
yourPoints: 0,
yourRank: null
});
const [loading, setLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const get = Get.getInstance();
const auth = Authentication.getInstance();
// Set the current user ID once on component mount
useEffect(() => {
try {
// Use the Authentication class directly
const isLoggedIn = auth.isAuthenticated();
setIsAuthenticated(isLoggedIn);
if (isLoggedIn) {
const user = auth.getCurrentUser();
if (user && user.id) {
setCurrentUserId(user.id);
} else {
setLoading(false);
}
} else {
setLoading(false);
}
} catch (err) {
console.error('Error checking authentication:', err);
setLoading(false);
}
}, []);
useEffect(() => {
const fetchStats = async () => {
try {
setLoading(true);
// Get all users without sorting - we'll sort on client side
const response = await get.getList(Collections.LIMITED_USERS, 1, 500, '', '', {
fields: ['id', 'name', 'points']
});
// Parse points from JSON string and convert to number
const processedUsers = response.items.map((user: Partial<LimitedUser>) => {
let pointsValue = 0;
try {
if (user.points) {
// Parse the JSON string to get the points value
const pointsData = JSON.parse(user.points);
pointsValue = typeof pointsData === 'number' ? pointsData : 0;
}
} catch (e) {
console.error('Error parsing points data:', e);
}
return {
id: user.id,
name: user.name,
parsedPoints: pointsValue
};
});
// Filter out users with no points for the leaderboard stats
const leaderboardUsers = processedUsers
.filter(user => user.parsedPoints > 0)
// Sort by points descending
.sort((a, b) => b.parsedPoints - a.parsedPoints);
const totalUsers = leaderboardUsers.length;
const totalPoints = leaderboardUsers.reduce((sum: number, user) => sum + user.parsedPoints, 0);
const topScore = leaderboardUsers.length > 0 ? leaderboardUsers[0].parsedPoints : 0;
// Find current user's points and rank - BUT don't filter by points > 0 for the current user
let yourPoints = 0;
let yourRank = null;
if (isAuthenticated && currentUserId) {
// Look for the current user in ALL processed users, not just those with points > 0
const currentUser = processedUsers.find(user => user.id === currentUserId);
if (currentUser) {
yourPoints = currentUser.parsedPoints || 0;
// Only calculate rank if user has points
if (yourPoints > 0) {
// Find user position in the sorted array
for (let i = 0; i < leaderboardUsers.length; i++) {
if (leaderboardUsers[i].id === currentUserId) {
yourRank = i + 1;
break;
}
}
}
}
}
setStats({
totalUsers,
totalPoints,
topScore,
yourPoints,
yourRank
});
} catch (err) {
console.error('Error fetching leaderboard stats:', err);
// Set fallback stats
setStats({
totalUsers: 0,
totalPoints: 0,
topScore: 0,
yourPoints: 0,
yourRank: null
});
} finally {
setLoading(false);
}
};
fetchStats();
}, [get, isAuthenticated, currentUserId]);
if (loading) {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-24 bg-gray-100/50 dark:bg-gray-800/50 animate-pulse rounded-xl">
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-2 mt-4 mx-4"></div>
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mx-4"></div>
</div>
))}
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Members</div>
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalUsers}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">In the leaderboard</div>
</div>
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Points</div>
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalPoints}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Earned by all members</div>
</div>
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Top Score</div>
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">{stats.topScore}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Highest individual points</div>
</div>
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Your Score</div>
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">
{isAuthenticated ? stats.yourPoints : '-'}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{isAuthenticated
? (stats.yourRank ? `Ranked #${stats.yourRank}` : 'Not ranked yet')
: 'Log in to see your rank'
}
</div>
</div>
</div>
);
}

View file

@ -1,362 +0,0 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import type { User, LimitedUser } from '../../../schemas/pocketbase/schema';
import { Collections } from '../../../schemas/pocketbase/schema';
interface LeaderboardUser {
id: string;
name: string;
points: number;
avatar?: string;
major?: string;
}
// Trophy icon SVG for the rankings
const TrophyIcon = ({ className }: { className: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
<path fillRule="evenodd" d="M5.166 2.621v.858c-1.035.148-2.059.33-3.071.543a.75.75 0 00-.584.859 6.753 6.753 0 006.138 5.6 6.73 6.73 0 002.743 1.346A6.707 6.707 0 019.279 15H8.54c-1.036 0-1.875.84-1.875 1.875V19.5h-.75a2.25 2.25 0 00-2.25 2.25c0 .414.336.75.75.75h15a.75.75 0 00.75-.75 2.25 2.25 0 00-2.25-2.25h-.75v-2.625c0-1.036-.84-1.875-1.875-1.875h-.739a6.706 6.706 0 01-1.112-3.173 6.73 6.73 0 002.743-1.347 6.753 6.753 0 006.139-5.6.75.75 0 00-.585-.858 47.077 47.077 0 00-3.07-.543V2.62a.75.75 0 00-.658-.744 49.22 49.22 0 00-6.093-.377c-2.063 0-4.096.128-6.093.377a.75.75 0 00-.657.744zm0 2.629c0 1.196.312 2.32.857 3.294A5.266 5.266 0 013.16 5.337a45.6 45.6 0 012.006-.343v.256zm13.5 0v-.256c.674.1 1.343.214 2.006.343a5.265 5.265 0 01-2.863 3.207 6.72 6.72 0 00.857-3.294z" clipRule="evenodd" />
</svg>
);
export default function LeaderboardTable() {
const [users, setUsers] = useState<LeaderboardUser[]>([]);
const [filteredUsers, setFilteredUsers] = useState<LeaderboardUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentUserRank, setCurrentUserRank] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const usersPerPage = 10;
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const get = Get.getInstance();
const auth = Authentication.getInstance();
// Set the current user ID once on component mount
useEffect(() => {
try {
// Use the Authentication class directly
const isLoggedIn = auth.isAuthenticated();
setIsAuthenticated(isLoggedIn);
if (isLoggedIn) {
const user = auth.getCurrentUser();
if (user && user.id) {
setCurrentUserId(user.id);
} else {
setLoading(false);
}
} else {
setLoading(false);
}
} catch (err) {
console.error('Error checking authentication:', err);
setLoading(false);
}
}, []);
useEffect(() => {
const fetchLeaderboard = async () => {
try {
setLoading(true);
// Fetch users without sorting - we'll sort on client side
const response = await get.getList(Collections.LIMITED_USERS, 1, 100, '', '', {
fields: ['id', 'name', 'points', 'avatar', 'major']
});
// First get the current user separately so we can include them even if they have 0 points
let currentUserData = null;
if (isAuthenticated && currentUserId) {
currentUserData = response.items.find((user: Partial<LimitedUser>) => user.id === currentUserId);
}
// Parse points from JSON string and convert to number
const processedUsers = response.items.map((user: any) => {
let pointsValue = 0;
try {
if (user.points) {
// Parse the JSON string to get the points value
const pointsData = JSON.parse(user.points);
pointsValue = typeof pointsData === 'number' ? pointsData : 0;
}
} catch (e) {
console.error('Error parsing points data:', e);
}
return {
id: user.id,
name: user.name,
major: user.major,
avatar: user.avatar, // Include avatar if it exists
points: user.points,
parsedPoints: pointsValue
};
});
// Filter and map to our leaderboard user format, and sort client-side
let leaderboardUsers = processedUsers
.filter(user => user.parsedPoints > 0)
.sort((a, b) => (b.parsedPoints || 0) - (a.parsedPoints || 0))
.map((user, index: number) => {
// Check if this is the current user
if (isAuthenticated && user.id === currentUserId) {
setCurrentUserRank(index + 1);
}
return {
id: user.id || '',
name: user.name || 'Anonymous User',
points: user.parsedPoints,
avatar: user.avatar,
major: user.major
};
});
// Include current user even if they have 0 points,
// but don't include in ranking if they have no points
if (isAuthenticated && currentUserId) {
// Find current user in processed users
const currentUserProcessed = processedUsers.find(user => user.id === currentUserId);
// If current user exists and isn't already in the leaderboard (has 0 points)
if (currentUserProcessed && !leaderboardUsers.some(user => user.id === currentUserId)) {
leaderboardUsers.push({
id: currentUserProcessed.id || '',
name: currentUserProcessed.name || 'Anonymous User',
points: currentUserProcessed.parsedPoints || 0,
avatar: currentUserProcessed.avatar,
major: currentUserProcessed.major
});
}
}
setUsers(leaderboardUsers);
setFilteredUsers(leaderboardUsers);
} catch (err) {
console.error('Error fetching leaderboard:', err);
setError('Failed to load leaderboard data');
} finally {
setLoading(false);
}
};
fetchLeaderboard();
}, [get, isAuthenticated, currentUserId]);
useEffect(() => {
if (searchQuery.trim() === '') {
setFilteredUsers(users);
setCurrentPage(1);
return;
}
const filtered = users.filter(user =>
user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(user.major && user.major.toLowerCase().includes(searchQuery.toLowerCase()))
);
setFilteredUsers(filtered);
setCurrentPage(1);
}, [searchQuery, users]);
// Get current users for pagination
const indexOfLastUser = currentPage * usersPerPage;
const indexOfFirstUser = indexOfLastUser - usersPerPage;
const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser);
const totalPages = Math.ceil(filteredUsers.length / usersPerPage);
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
if (loading) {
return (
<div className="flex justify-center items-center py-10">
<div className="w-10 h-10 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (error) {
return (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center">
<svg className="w-5 h-5 text-red-600 dark:text-red-400 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
</svg>
<span className="text-red-800 dark:text-red-200">{error}</span>
</div>
</div>
);
}
if (users.length === 0) {
return (
<div className="text-center py-10">
<p className="text-gray-600 dark:text-gray-300">No users with points found</p>
</div>
);
}
return (
<div>
{/* Search bar */}
<div className="relative mb-6">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search by name or major..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
bg-white/90 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 focus:outline-none
focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* Leaderboard table */}
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50/80 dark:bg-gray-800/80">
<tr>
<th scope="col" className="w-16 px-6 py-3 text-center text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
Rank
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
User
</th>
<th scope="col" className="w-24 px-6 py-3 text-center text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
Points
</th>
</tr>
</thead>
<tbody className="bg-white/90 dark:bg-gray-900/90 divide-y divide-gray-200 dark:divide-gray-800">
{currentUsers.map((user, index) => {
const actualRank = user.points > 0 ? indexOfFirstUser + index + 1 : null;
const isCurrentUser = user.id === currentUserId;
return (
<tr key={user.id} className={isCurrentUser ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}>
<td className="px-6 py-4 whitespace-nowrap text-center">
{actualRank ? (
actualRank <= 3 ? (
<span className="inline-flex items-center justify-center w-8 h-8">
{actualRank === 1 && <TrophyIcon className="text-yellow-500 w-6 h-6" />}
{actualRank === 2 && <TrophyIcon className="text-gray-400 w-6 h-6" />}
{actualRank === 3 && <TrophyIcon className="text-amber-700 w-6 h-6" />}
</span>
) : (
<span className="font-medium text-gray-800 dark:text-gray-100">{actualRank}</span>
)
) : (
<span className="text-xs text-gray-500 dark:text-gray-400">Not Ranked</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden relative">
{user.avatar ? (
<img className="h-10 w-10 rounded-full" src={user.avatar} alt={user.name} />
) : (
<span className="absolute inset-0 flex items-center justify-center text-lg font-bold text-gray-700 dark:text-gray-300">
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">
{user.name}
</div>
{user.major && (
<div className="text-sm text-gray-500 dark:text-gray-400">
{user.major}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center font-bold text-indigo-600 dark:text-indigo-400">
{user.points}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center mt-6">
<nav className="flex items-center">
<button
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-700
bg-white/90 dark:bg-gray-800/90 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => paginate(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
<span className="sr-only">Previous</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</button>
{Array.from({ length: totalPages }, (_, i) => (
<button
key={i + 1}
className={`relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-700
bg-white/90 dark:bg-gray-800/90 text-sm font-medium ${currentPage === i + 1
? 'text-indigo-600 dark:text-indigo-400 border-indigo-500 dark:border-indigo-500 z-10'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
onClick={() => paginate(i + 1)}
>
{i + 1}
</button>
))}
<button
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-700
bg-white/90 dark:bg-gray-800/90 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => paginate(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
<span className="sr-only">Next</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
</nav>
</div>
)}
{/* Show current user rank if not in current page */}
{isAuthenticated && currentUserRank && !currentUsers.some(user => user.id === currentUserId) && (
<div className="mt-4 p-3 bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 rounded-lg">
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
Your rank: <span className="font-bold text-indigo-600 dark:text-indigo-400">#{currentUserRank}</span>
</p>
</div>
)}
{/* Current user with 0 points */}
{isAuthenticated && currentUserId &&
!currentUserRank &&
currentUsers.some(user => user.id === currentUserId) && (
<div className="mt-4 p-3 bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 rounded-lg">
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
Participate in events to earn points and get ranked!
</p>
</div>
)}
</div>
);
}

View file

@ -1,11 +0,0 @@
---
import OfficerManagementComponent from "./OfficerManagement/OfficerManagement";
---
<section>
<h2 class="text-2xl font-bold mb-6">Officer Management</h2>
<div class="bg-gray-800 shadow-md rounded-lg p-6">
<OfficerManagementComponent client:load />
</div>
</section>

View file

@ -1,89 +0,0 @@
---
import { Icon } from "astro-icon/components";
import EmailRequestSettings from "./SettingsSection/EmailRequestSettings";
// Import environment variables for debugging if needed
const logtoApiEndpoint = import.meta.env.LOGTO_API_ENDPOINT || "";
---
<div id="officer-email-section" class="">
<div class="mb-6">
<h2 class="text-2xl font-bold">IEEE Email Management</h2>
<p class="opacity-70">Manage your official IEEE UCSD email address</p>
</div>
<!-- IEEE Email Management Card -->
<div
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
>
<Icon name="heroicons:envelope" class="h-5 w-5" />
</div>
IEEE Email Address
</h3>
<p class="text-sm opacity-70 mb-4">
Request and manage your official IEEE UCSD email address. This email can be used for official IEEE communications and professional purposes.
</p>
<div class="h-px w-full bg-border my-4"></div>
<EmailRequestSettings client:load />
</div>
</div>
<!-- Email Guidelines Card -->
<div
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div
class="inline-flex items-center justify-center p-3 rounded-full bg-info text-info-foreground"
>
<Icon name="heroicons:information-circle" class="h-5 w-5" />
</div>
Email Usage Guidelines
</h3>
<div class="space-y-4 text-sm">
<div class="alert alert-info">
<Icon name="heroicons:information-circle" class="h-4 w-4" />
<div>
<h4 class="font-bold">Officer Email Access</h4>
<p>IEEE email addresses are only available to active IEEE UCSD officers. Your officer status is automatically verified when you request an email.</p>
</div>
</div>
<div class="space-y-2">
<h4 class="font-semibold">Acceptable Use:</h4>
<ul class="list-disc list-inside space-y-1 opacity-80">
<li>Official IEEE UCSD communications</li>
<li>Professional networking related to IEEE activities</li>
<li>Event coordination and planning</li>
<li>Communications with sponsors and external partners</li>
</ul>
</div>
<div class="space-y-2">
<h4 class="font-semibold">Email Features:</h4>
<ul class="list-disc list-inside space-y-1 opacity-80">
<li>Webmail access at <a href="https://mail.ieeeucsd.org" target="_blank" rel="noopener noreferrer" class="link link-primary">https://mail.ieeeucsd.org</a></li>
<li>IMAP/SMTP support for email clients</li>
<li>5GB storage space</li>
<li>Professional @ieeeucsd.org domain</li>
</ul>
</div>
<div class="space-y-2">
<h4 class="font-semibold">Important Notes:</h4>
<ul class="list-disc list-inside space-y-1 opacity-80">
<li>Your email username is based on your personal email address</li>
<li>Passwords can be reset through this interface</li>
<li>Email access may be revoked when officer status changes</li>
<li>Contact the webmaster for any technical issues</li>
</ul>
</div>
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -1,701 +0,0 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { SendLog } from '../../../scripts/pocketbase/SendLog';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import { Icon } from "@iconify/react";
import type { Event, User as SchemaUser, EventAttendee } from "../../../schemas/pocketbase";
import toast from "react-hot-toast";
// Extended User interface with additional properties needed for this component
interface User extends SchemaUser {
member_type: string;
}
// Define AttendeeEntry interface locally
interface AttendeeEntry {
user_id: string;
time_checked_in: string;
food: string;
points_earned?: number;
}
// Cache for storing user data
const userCache = new Map<string, {
data: User;
timestamp: number;
}>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const ITEMS_PER_PAGE = 50;
// Add HighlightText component
const HighlightText = ({ text, searchTerms }: { text: string | number | null | undefined, searchTerms: string[] }) => {
// Convert input to string and handle null/undefined
const textStr = String(text ?? '');
if (!searchTerms.length || !textStr) return <>{textStr}</>;
try {
const escapedTerms = searchTerms.map(term =>
term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
);
const parts = textStr.split(new RegExp(`(${escapedTerms.join('|')})`, 'gi'));
return (
<>
{parts.map((part, i) => {
const isMatch = searchTerms.some(term =>
part.toLowerCase().includes(term.toLowerCase())
);
return isMatch ? (
<mark key={i} className="bg-primary/20 rounded px-1">{part}</mark>
) : (
<span key={i}>{part}</span>
);
})}
</>
);
} catch (error) {
console.error('Error in HighlightText:', error);
return <>{textStr}</>;
}
};
// Add new interface for selected fields
interface EventFields {
id: true;
event_name: true;
}
interface UserFields {
id: true;
name: true;
email: true;
pid: true;
member_id: true;
member_type: true;
graduation_year: true;
major: true;
}
// Constants for field selection
const EVENT_FIELDS: (keyof EventFields)[] = ['id', 'event_name'];
const USER_FIELDS: (keyof UserFields)[] = ['id', 'name', 'email', 'pid', 'member_id', 'member_type', 'graduation_year', 'major'];
export default function Attendees() {
const [eventId, setEventId] = useState<string>('');
const [eventName, setEventName] = useState<string>('');
const [users, setUsers] = useState<Map<string, User>>(new Map());
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [attendeesList, setAttendeesList] = useState<AttendeeEntry[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [processedSearchTerms, setProcessedSearchTerms] = useState<string[]>([]);
const [refreshKey, setRefreshKey] = useState(0); // Add a refresh key to force re-fetching
const get = Get.getInstance();
const auth = Authentication.getInstance();
// Memoize search terms processing
const updateProcessedSearchTerms = useCallback((searchTerm: string) => {
const terms = searchTerm.toLowerCase().split(/\s+/).filter(Boolean);
setProcessedSearchTerms(terms);
setCurrentPage(1); // Reset to first page on new search
}, []);
// Memoize filtered attendees
const filteredAttendees = useMemo(() => {
if (!searchTerm.trim()) return attendeesList;
return attendeesList.filter(attendee => {
const user = users.get(attendee.user_id);
if (!user) return false;
const searchableValues = [
user.name,
user.email,
user.pid,
user.member_id,
user.member_type,
user.graduation_year,
user.major,
attendee.food,
new Date(attendee.time_checked_in).toLocaleString(),
].map(value => (value || '').toString().toLowerCase());
return processedSearchTerms.every(term =>
searchableValues.some(value => value.includes(term))
);
});
}, [attendeesList, users, processedSearchTerms]);
// Memoize paginated attendees
const paginatedAttendees = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
return filteredAttendees.slice(startIndex, startIndex + ITEMS_PER_PAGE);
}, [filteredAttendees, currentPage]);
// Memoize pagination info
const paginationInfo = useMemo(() => {
const totalPages = Math.ceil(filteredAttendees.length / ITEMS_PER_PAGE);
return {
totalPages,
hasNextPage: currentPage < totalPages,
hasPrevPage: currentPage > 1
};
}, [filteredAttendees.length, currentPage]);
// Optimized user data fetching with cache
const fetchUserData = useCallback(async (userIds: string[]) => {
if (!userIds.length) return new Map<string, User>();
const now = Date.now();
const uncachedIds: string[] = [];
const cachedUsers = new Map<string, User>();
// Check cache first
userIds.forEach(id => {
const cached = userCache.get(id);
if (cached && now - cached.timestamp < CACHE_DURATION) {
cachedUsers.set(id, cached.data);
} else {
uncachedIds.push(id);
}
});
// If we have all users in cache, return early
if (uncachedIds.length === 0) {
return cachedUsers;
}
try {
// Create a filter to get all uncached users in one request
const userFilter = uncachedIds.map(id => `id="${id}"`).join(" || ");
// Fetch all uncached users in one request
const usersResponse = await get.getAll<User>(
Collections.USERS,
userFilter
);
// Process the fetched users
usersResponse.forEach(user => {
// Add member_type if it doesn't exist
const userWithMemberType = {
...user,
member_type: user.member_type || "N/A"
};
cachedUsers.set(user.id, userWithMemberType);
// Update cache
userCache.set(user.id, {
data: userWithMemberType,
timestamp: now
});
});
// Create placeholders for any users that weren't found
const fetchedIds = new Set(usersResponse.map(user => user.id));
uncachedIds.forEach(id => {
if (!fetchedIds.has(id) && !cachedUsers.has(id)) {
// Create a placeholder user
const placeholderUser: User = {
id,
name: `User ${id}`,
email: "N/A",
emailVisibility: false,
verified: false,
created: "",
updated: "",
member_type: "N/A"
};
cachedUsers.set(id, placeholderUser);
}
});
} catch (error) {
console.error('Failed to fetch users:', error);
// Create placeholders for all uncached users that failed to fetch
uncachedIds.forEach(id => {
if (!cachedUsers.has(id)) {
const placeholderUser: User = {
id,
name: `User ${id}`,
email: "N/A",
emailVisibility: false,
verified: false,
created: "",
updated: "",
member_type: "N/A"
};
cachedUsers.set(id, placeholderUser);
}
});
}
return cachedUsers;
}, []);
// Function to refresh attendees data
const refreshAttendees = useCallback(() => {
setRefreshKey(prev => prev + 1);
}, []);
// Listen for the custom event
useEffect(() => {
const handleUpdateAttendees = async (e: CustomEvent<{ eventId: string; eventName: string }>) => {
setEventId(e.detail.eventId);
setEventName(e.detail.eventName);
setCurrentPage(1); // Reset pagination on new event
setSearchTerm(''); // Clear search on new event
// Log the attendees view action
try {
const sendLog = SendLog.getInstance();
await sendLog.send(
"view",
"event_attendees",
`Viewed attendees for event: ${e.detail.eventName}`
);
} catch (error) {
console.error('Failed to log attendees view:', error);
}
};
window.addEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener);
// Expose refresh function to window
(window as any).refreshAttendees = refreshAttendees;
return () => {
window.removeEventListener('updateAttendees', handleUpdateAttendees as unknown as EventListener);
delete (window as any).refreshAttendees;
};
}, [refreshAttendees]);
// Update search terms when search input changes
useEffect(() => {
updateProcessedSearchTerms(searchTerm);
}, [searchTerm, updateProcessedSearchTerms]);
// Fetch event data when eventId changes or refreshKey changes
useEffect(() => {
let isMounted = true;
const fetchEventData = async () => {
if (!eventId || !auth.isAuthenticated()) {
if (!auth.isAuthenticated()) {
// console.log('User not authenticated');
setError('Authentication required');
}
return;
}
try {
setLoading(true);
setError(null);
if (!eventId) {
setAttendeesList([]);
setUsers(new Map());
return;
}
// Clear cache to ensure fresh data
const dataSync = DataSyncService.getInstance();
await dataSync.clearCache();
await dataSync.syncCollection(Collections.EVENTS, `id="${eventId}"`);
const event = await get.getOne<Event>(Collections.EVENTS, eventId);
if (!event) {
setError("Event not found");
setAttendeesList([]);
setUsers(new Map());
return;
}
// Fetch attendees from event_attendees collection with a higher limit
const attendeesList = await get.getList<EventAttendee>(
Collections.EVENT_ATTENDEES,
1,
2000, // Increased limit to handle more attendees
`event="${eventId}"`
);
if (!attendeesList.items.length) {
if (isMounted) {
setAttendeesList([]);
setUsers(new Map());
}
return;
}
// Transform EventAttendee records to match the expected format
const transformedAttendees = attendeesList.items.map(attendee => ({
user_id: attendee.user, // This is the user ID (relation)
time_checked_in: attendee.time_checked_in,
food: attendee.food_ate,
points_earned: attendee.points_earned
}));
if (isMounted) {
setAttendeesList(transformedAttendees);
}
// Fetch all users at once to improve performance
const userIds = transformedAttendees.map(a => a.user_id);
// Create a filter to get all users in one request
const userFilter = userIds.map(id => `id="${id}"`).join(" || ");
try {
// Fetch all users directly from PocketBase in one request
const usersResponse = await get.getAll<User>(
Collections.USERS,
userFilter
);
// Create a map of users
const userMap = new Map<string, User>();
usersResponse.forEach(user => {
// Add member_type if it doesn't exist
const userWithMemberType = {
...user,
member_type: user.member_type || "N/A"
};
userMap.set(user.id, userWithMemberType);
// Update cache
userCache.set(user.id, {
data: userWithMemberType,
timestamp: Date.now()
});
});
// For any missing users, create placeholders
userIds.forEach(id => {
if (!userMap.has(id)) {
const placeholderUser: User = {
id,
name: `User ${id}`,
email: "N/A",
emailVisibility: false,
verified: false,
created: "",
updated: "",
member_type: "N/A"
};
userMap.set(id, placeholderUser);
}
});
if (isMounted) {
setUsers(userMap);
}
} catch (error) {
console.error("Failed to fetch users:", error);
// Fallback to individual user fetching
const userMap = await fetchUserData(userIds);
if (isMounted) {
setUsers(userMap);
}
}
toast.success(`Loaded ${attendeesList.items.length} attendees for ${event.event_name}`);
} catch (error) {
console.error("Error fetching event data:", error);
setError("Failed to load event data. Please try refreshing.");
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchEventData();
return () => { isMounted = false; };
}, [eventId, auth, fetchUserData, refreshKey]);
// Reset state when modal is closed
useEffect(() => {
const handleModalClose = () => {
setEventId('');
setEventName('');
setAttendeesList([]);
setUsers(new Map());
setError(null);
setSearchTerm('');
setCurrentPage(1);
};
const modal = document.getElementById('attendeesModal');
if (modal) {
modal.addEventListener('close', handleModalClose);
return () => modal.removeEventListener('close', handleModalClose);
}
}, []);
// Function to download attendees as CSV
const downloadAttendeesCSV = () => {
// Function to sanitize and format CSV cell content
const escapeCSV = (cell: any): string => {
// Convert to string and replace any newlines with spaces
const value = (cell?.toString() || '').replace(/[\r\n]+/g, ' ').trim();
// If the value contains quotes or commas, wrap in quotes and escape internal quotes
if (value.includes('"') || value.includes(',')) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
};
// Create CSV headers
const headers = [
'Name',
'Email',
'PID',
'Member ID',
'Member Type',
'Graduation Year',
'Major',
'Check-in Time',
'Food Choice',
'Points Earned'
].map(escapeCSV);
// Create CSV rows
const rows = attendeesList.map(attendee => {
const user = users.get(attendee.user_id);
let checkInTime = '';
try {
checkInTime = new Date(attendee.time_checked_in).toLocaleString();
} catch (e) {
checkInTime = attendee.time_checked_in || 'N/A';
}
return [
user?.name || `User ${attendee.user_id}`,
user?.email || 'N/A',
user?.pid || 'N/A',
user?.member_id || 'N/A',
user?.member_type || 'N/A',
user?.graduation_year || 'N/A',
user?.major || 'N/A',
checkInTime,
attendee.food || 'N/A',
attendee.points_earned || 'N/A'
].map(escapeCSV);
});
// Combine headers and rows with Windows-style line endings
const csvContent = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\r\n');
// Create blob with UTF-8 BOM for better Excel compatibility
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// Create filename with date and time
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
const filename = `${eventName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_attendees_${timestamp}.csv`;
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url); // Clean up the URL object
toast.success(`Downloaded ${rows.length} attendee records`);
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
if (error) {
return (
<div className="alert alert-error">
<Icon icon="heroicons:exclamation-circle" className="h-6 w-6" />
<span>{error}</span>
<button
className="btn btn-sm btn-outline"
onClick={refreshAttendees}
>
<Icon icon="heroicons:arrow-path" className="h-4 w-4 mr-1" />
Retry
</button>
</div>
);
}
if (!eventId) {
return null;
}
if (!attendeesList || attendeesList.length === 0) {
return (
<div className="text-center py-8 text-base-content/70">
<Icon icon="heroicons:user-group" className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No attendees yet</p>
<button
className="btn btn-sm btn-outline mt-4"
onClick={refreshAttendees}
>
<Icon icon="heroicons:arrow-path" className="h-4 w-4 mr-1" />
Refresh
</button>
</div>
);
}
return (
<div className="flex flex-col h-[calc(100vh-16rem)]">
<div className="flex flex-col gap-4 mb-4">
{/* Search and Actions Row */}
<div className="flex justify-between items-center gap-4">
<div className="flex-1 flex gap-2">
<div className="join flex-1">
<div className="join-item bg-base-200 flex items-center px-3">
<Icon icon="heroicons:magnifying-glass" className="h-5 w-5 opacity-70" />
</div>
<input
type="text"
placeholder="Search attendees..."
className="input input-bordered join-item w-full"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="flex gap-2">
<button
className="btn btn-outline btn-sm gap-2"
onClick={refreshAttendees}
>
<Icon icon="heroicons:arrow-path" className="h-4 w-4" />
Refresh
</button>
<button
className="btn btn-primary btn-sm gap-2"
onClick={downloadAttendeesCSV}
>
<Icon icon="heroicons:arrow-down-tray" className="h-4 w-4" />
Download CSV
</button>
</div>
</div>
{/* Stats Row */}
<div className="flex justify-between items-center">
<div className="text-sm opacity-70">
Total Attendees: {attendeesList.length}
</div>
{searchTerm && (
<div className="text-sm opacity-70">
Showing: {filteredAttendees.length} matches
</div>
)}
</div>
</div>
{/* Table with pagination */}
<div className="overflow-x-auto flex-1">
<table className="table table-zebra w-full">
<thead className="sticky top-0 bg-base-100">
<tr>
<th className="bg-base-100">Name</th>
<th className="bg-base-100">Email</th>
<th className="bg-base-100">PID</th>
<th className="bg-base-100">Member ID</th>
<th className="bg-base-100">Member Type</th>
<th className="bg-base-100">Graduation Year</th>
<th className="bg-base-100">Major</th>
<th className="bg-base-100">Check-in Time</th>
<th className="bg-base-100">Food Choice</th>
<th className="bg-base-100">Points</th>
</tr>
</thead>
<tbody>
{paginatedAttendees.map((attendee, index) => {
const user = users.get(attendee.user_id);
let checkInTime = '';
try {
checkInTime = new Date(attendee.time_checked_in).toLocaleString();
} catch (e) {
checkInTime = attendee.time_checked_in || 'N/A';
}
return (
<tr key={`${attendee.user_id}-${index}`}>
<td><HighlightText text={user?.name || `User ${attendee.user_id}`} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.email || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.pid || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.member_id || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.member_type || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.graduation_year || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={user?.major || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={checkInTime} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={attendee.food || 'N/A'} searchTerms={processedSearchTerms} /></td>
<td><HighlightText text={attendee.points_earned || 'N/A'} searchTerms={processedSearchTerms} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination Controls */}
{paginationInfo.totalPages > 1 && (
<div className="flex justify-center mt-4">
<div className="join">
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasPrevPage}
onClick={() => setCurrentPage(1)}
>
«
</button>
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasPrevPage}
onClick={() => setCurrentPage(p => p - 1)}
>
</button>
<button className="join-item btn btn-sm">
Page {currentPage} of {paginationInfo.totalPages}
</button>
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasNextPage}
onClick={() => setCurrentPage(p => p + 1)}
>
</button>
<button
className="join-item btn btn-sm"
disabled={!paginationInfo.hasNextPage}
onClick={() => setCurrentPage(paginationInfo.totalPages)}
>
»
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -1,268 +0,0 @@
---
import { Authentication } from "../../scripts/pocketbase/Authentication";
import { Get } from "../../scripts/pocketbase/Get";
import EventRequestForm from "./Officer_EventRequestForm/EventRequestForm";
import UserEventRequests from "./Officer_EventRequestForm/UserEventRequests";
import { Collections } from "../../schemas/pocketbase/schema";
import { DataSyncService } from "../../scripts/database/DataSyncService";
import { EventRequestFormPreviewModalWrapper } from "./Officer_EventRequestForm/EventRequestFormPreview";
// Import the EventRequest type from UserEventRequests to ensure consistency
import type { EventRequest as UserEventRequest } from "./Officer_EventRequestForm/UserEventRequests";
// Use the imported type
type EventRequest = UserEventRequest;
// Get instances
const get = Get.getInstance();
const auth = Authentication.getInstance();
// Initialize variables for user's submissions
let userEventRequests: EventRequest[] = [];
let error: string | null = null;
// Fetch user's event request submissions if authenticated
// This provides initial data for server-side rendering
// Client-side will use IndexedDB for data management
if (auth.isAuthenticated()) {
try {
const userId = auth.getUserId();
if (userId) {
userEventRequests = await get.getAll<EventRequest>(
Collections.EVENT_REQUESTS,
`requested_user="${userId}"`,
"-created",
);
}
} catch (err) {
console.error("Failed to fetch user event requests:", err);
error = "Failed to load your event requests. Please try again later.";
}
}
---
<div class="w-full max-w-6xl mx-auto py-8 px-4">
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Event Request Form</h1>
<p class="text-gray-300 mb-4">
Submit your event request at least 6 weeks before your event. After
submitting, please notify PR and/or Coordinators in the #-events Slack
channel.
</p>
<div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300">
<p class="font-medium mb-2">This form includes sections for:</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>PR Materials (if needed)</li>
<li>Event Details</li>
<li>TAP Form Information</li>
<li>AS Funding (if needed)</li>
</ul>
<p class="mt-3">
Your progress is automatically saved as you fill out the form.
</p>
</div>
</div>
<!-- Tabs -->
<div class="tabs tabs-boxed mb-6">
<a class="tab tab-lg tab-active" id="form-tab">Submit Event Request</a>
<a class="tab tab-lg" id="submissions-tab">View Your Submissions</a>
</div>
<!-- Form Tab Content -->
<div
id="form-content"
class="bg-base-200 rounded-lg shadow-xl overflow-hidden"
>
<div class="p-6">
<EventRequestForm client:load />
</div>
</div>
<!-- Submissions Tab Content -->
<div id="submissions-content" class="hidden">
<div class="bg-base-200 rounded-lg shadow-xl overflow-hidden p-6">
<h2 class="text-2xl font-bold text-white mb-4">
Your Event Request Submissions
</h2>
{
error && (
<div class="alert alert-error mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 stroke-current shrink-0"
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>
<span>{error}</span>
</div>
)
}
{
!error && (
<UserEventRequests client:load eventRequests={userEventRequests} />
)
}
</div>
</div>
</div>
<!-- The modal will be rendered through the global function and event system -->
<EventRequestFormPreviewModalWrapper client:load />
<style is:global>
/* Ensure the modal container is always visible */
#event-request-preview-modal-overlay {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
max-width: 100vw !important;
max-height: 100vh !important;
z-index: 999999 !important;
overflow: auto !important;
margin: 0 !important;
padding: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
background-color: rgba(0, 0, 0, 0.6) !important;
backdrop-filter: blur(4px) !important;
}
/* Style for the modal content */
#event-request-preview-modal-overlay > div {
z-index: 1000000 !important;
position: relative !important;
max-width: min(90vw, 1024px) !important;
width: 100% !important;
max-height: 90vh !important;
overflow: auto !important;
margin: 0 !important;
background-color: var(--color-base-100) !important;
border-radius: 1rem !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
}
</style>
<script is:inline>
// Define the global function immediately to ensure it's available
window.showEventRequestFormPreview = function (formData) {
console.log("showEventRequestFormPreview called with formData:", formData);
// Create a custom event to trigger the preview
const event = new CustomEvent("showEventRequestPreviewModal", {
detail: { formData },
});
console.log("Dispatching event with detail:", event.detail);
// Dispatch event to show modal
document.dispatchEvent(event);
// Prevent body scrolling when modal is open
document.body.style.overflow = "hidden";
// Add event listener to restore scrolling when modal is closed
const handleModalClose = () => {
document.body.style.overflow = "";
document.removeEventListener("modalClosed", handleModalClose);
};
document.addEventListener("modalClosed", handleModalClose);
};
</script>
<script>
// Import the DataSyncService for client-side use
import { DataSyncService } from "../../scripts/database/DataSyncService";
import { Collections } from "../../schemas/pocketbase/schema";
import { Authentication } from "../../scripts/pocketbase/Authentication";
// Tab switching logic
document.addEventListener("DOMContentLoaded", async () => {
// Initialize DataSyncService for client-side
const dataSync = DataSyncService.getInstance();
const auth = Authentication.getInstance();
// Prefetch data into IndexedDB if authenticated
if (auth.isAuthenticated()) {
try {
const userId = auth.getUserId();
if (userId) {
// Force sync to ensure we have the latest data
await dataSync.syncCollection(
Collections.EVENT_REQUESTS,
`requested_user="${userId}"`,
"-created",
);
// console.log("Initial data sync complete for user event requests");
}
} catch (err) {
// console.error("Error during initial data sync:", err);
}
}
const formTab = document.getElementById("form-tab");
const submissionsTab = document.getElementById("submissions-tab");
const formContent = document.getElementById("form-content");
const submissionsContent = document.getElementById("submissions-content");
// Function to switch tabs
const switchTab = (
activeTab: HTMLElement,
activeContent: HTMLElement,
inactiveTab: HTMLElement,
inactiveContent: HTMLElement,
) => {
// Update tab classes
activeTab.classList.add("tab-active");
inactiveTab.classList.remove("tab-active");
// Show/hide content
activeContent.classList.remove("hidden");
inactiveContent.classList.add("hidden");
// Dispatch event to refresh submissions when switching to submissions tab
if (activeTab.id === "submissions-tab") {
// Dispatch a custom event that the UserEventRequests component listens for
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
}
};
// Add click event listeners to tabs
formTab?.addEventListener("click", (e) => {
e.preventDefault();
if (formContent && submissionsContent && submissionsTab) {
switchTab(formTab, formContent, submissionsTab, submissionsContent);
}
});
submissionsTab?.addEventListener("click", (e) => {
e.preventDefault();
if (formContent && submissionsContent && formTab) {
switchTab(submissionsTab, submissionsContent, formTab, formContent);
}
});
// Listen for visibility changes
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
// Dispatch custom event that components can listen for
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
}
});
});
</script>

View file

@ -1,479 +0,0 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import toast from 'react-hot-toast';
import type { EventRequestFormData } from './EventRequestForm';
import InvoiceBuilder from './InvoiceBuilder';
import type { InvoiceData, InvoiceItem } from './InvoiceBuilder';
import CustomAlert from '../universal/CustomAlert';
import { Icon } from '@iconify/react';
// Enhanced animation variants with faster transitions
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.035,
when: "beforeChildren",
duration: 0.3,
ease: "easeOut"
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 500,
damping: 25,
mass: 0.8,
duration: 0.25
}
}
};
// Input field hover animation
const inputHoverVariants = {
hover: {
scale: 1.01,
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
transition: { duration: 0.15 }
}
};
// Button animation
const buttonVariants = {
hover: {
scale: 1.03,
transition: { duration: 0.15, ease: "easeOut" }
},
tap: {
scale: 0.97,
transition: { duration: 0.1 }
}
};
// Toggle animation
const toggleVariants = {
checked: { backgroundColor: "rgba(var(--p), 0.2)" },
unchecked: { backgroundColor: "rgba(0, 0, 0, 0.05)" },
hover: { scale: 1.01, transition: { duration: 0.15 } }
};
interface ASFundingSectionProps {
formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
}
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => {
// Check initial budget status
React.useEffect(() => {
if (formData.invoiceData?.total) {
checkBudgetLimit(formData.invoiceData.total);
}
}, [formData.expected_attendance]);
const [invoiceFiles, setInvoiceFiles] = useState<File[]>(formData.invoice_files || []);
const [jsonInput, setJsonInput] = useState<string>('');
const [jsonError, setJsonError] = useState<string>('');
const [showJsonInput, setShowJsonInput] = useState<boolean>(false);
const [isDragging, setIsDragging] = useState(false);
// Handle invoice file upload
const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files) as File[];
// Combine existing files with new files instead of replacing
const combinedFiles = [...invoiceFiles, ...newFiles];
setInvoiceFiles(combinedFiles);
onDataChange({ invoice_files: combinedFiles });
}
};
// Handle removing individual files
const handleRemoveFile = (indexToRemove: number) => {
const updatedFiles = invoiceFiles.filter((_, index) => index !== indexToRemove);
setInvoiceFiles(updatedFiles);
onDataChange({ invoice_files: updatedFiles });
};
// Handle clearing all files
const handleClearAllFiles = () => {
setInvoiceFiles([]);
onDataChange({ invoice_files: [] });
};
// Handle JSON input change
const handleJsonInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setJsonInput(e.target.value);
setJsonError('');
};
// Show JSON example
const showJsonExample = () => {
const example = {
vendor: "Example Restaurant",
items: [
{
id: "item-1",
description: "Burger",
quantity: 2,
unitPrice: 12.99,
amount: 25.98
},
{
id: "item-2",
description: "Fries",
quantity: 2,
unitPrice: 4.99,
amount: 9.98
}
],
subtotal: 35.96,
taxRate: 9.0,
taxAmount: 3.24,
tipPercentage: 13.9,
tipAmount: 5.00,
total: 44.20
};
setJsonInput(JSON.stringify(example, null, 2));
};
// Validate and apply JSON
// Check budget limits and show warning if exceeded
const checkBudgetLimit = (total: number) => {
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
if (total > maxBudget) {
toast.error(`Total amount ($${total.toFixed(2)}) exceeds maximum funding of $${maxBudget.toFixed(2)} for ${formData.expected_attendance} attendees.`, {
duration: 4000,
position: 'top-center'
});
return true;
}
return false;
};
const validateAndApplyJson = () => {
try {
if (!jsonInput.trim()) {
setJsonError('JSON input is empty');
return;
}
const data = JSON.parse(jsonInput);
// Validate structure
if (!data.vendor) {
setJsonError('Vendor field is required');
return;
}
if (!Array.isArray(data.items) || data.items.length === 0) {
setJsonError('Items array is required and must contain at least one item');
return;
}
// Validate items
for (const item of data.items) {
if (!item.description || typeof item.unitPrice !== 'number' || typeof item.quantity !== 'number') {
setJsonError('Each item must have description, unitPrice, and quantity fields');
return;
}
}
// Validate tax, tip, and total
if (typeof data.taxAmount !== 'number') {
setJsonError('Tax amount must be a number');
return;
}
if (typeof data.tipAmount !== 'number') {
setJsonError('Tip amount must be a number');
return;
}
if (typeof data.total !== 'number') {
setJsonError('Total is required and must be a number');
return;
}
// Create itemized invoice string for Pocketbase
const itemizedInvoice = JSON.stringify({
vendor: data.vendor,
items: data.items.map((item: InvoiceItem) => ({
item: item.description,
quantity: item.quantity,
unit_price: item.unitPrice,
amount: item.amount
})),
subtotal: data.subtotal,
tax: data.taxAmount,
tip: data.tipAmount,
total: data.total
}, null, 2);
// Check budget limits and show toast if needed
checkBudgetLimit(data.total);
// Apply the JSON data to the form
onDataChange({
invoiceData: data,
itemized_invoice: itemizedInvoice,
as_funding_required: true
});
toast.success('Invoice data applied successfully');
setShowJsonInput(false);
} catch (error) {
setJsonError('Invalid JSON format: ' + (error as Error).message);
}
};
// Handle drag events for file upload
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const newFiles = Array.from(e.dataTransfer.files) as File[];
// Combine existing files with new files instead of replacing
const combinedFiles = [...invoiceFiles, ...newFiles];
setInvoiceFiles(combinedFiles);
onDataChange({ invoice_files: combinedFiles });
}
};
// Handle invoice data change from the invoice builder
const handleInvoiceDataChange = (data: InvoiceData) => {
// Check budget limits and show toast if needed
checkBudgetLimit(data.total);
onDataChange({
invoiceData: data,
itemized_invoice: JSON.stringify(data)
});
};
return (
<motion.div
className="space-y-8"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={itemVariants}>
<h2 className="text-3xl font-bold mb-4 text-primary bg-gradient-to-r from-primary to-primary-focus bg-clip-text text-transparent">
AS Funding Details
</h2>
</motion.div>
<motion.div variants={itemVariants}>
<CustomAlert
type="info"
title="AS Funding Information"
message="AS funding can cover food and other expenses for your event. Please itemize all expenses in the invoice builder below."
className="mb-6"
icon="heroicons:information-circle"
/>
</motion.div>
{/* Invoice Upload Section */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<h3 className="text-xl font-semibold mb-2 text-primary">Invoice Information</h3>
<p className="text-sm text-gray-500 mb-4">Upload your invoice files or create an itemized invoice below.</p>
<motion.div
className={`mt-2 border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${isDragging ? 'border-primary bg-primary/5' : 'border-base-300 hover:border-primary/50'
}`}
whileHover={{ scale: 1.02, boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)" }}
whileTap={{ scale: 0.98 }}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => document.getElementById('invoice-files')?.click()}
>
<input
id="invoice-files"
type="file"
className="hidden"
onChange={handleInvoiceFileChange}
accept=".pdf,.jpg,.jpeg,.png"
multiple
/>
<div className="flex flex-col items-center justify-center gap-3">
<motion.div
className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary"
whileHover={{ rotate: 15, scale: 1.1 }}
>
<Icon icon="heroicons:document-text" className="h-8 w-8" />
</motion.div>
{invoiceFiles.length > 0 ? (
<>
<div className="flex items-center justify-between w-full">
<p className="font-medium text-primary">{invoiceFiles.length} file(s) selected:</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearAllFiles();
}}
className="btn btn-xs btn-outline btn-error"
title="Clear all files"
>
Clear All
</button>
</div>
<div className="max-h-32 overflow-y-auto text-left w-full space-y-1">
{invoiceFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
<span className="text-sm truncate flex-1">{file.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveFile(index);
}}
className="btn btn-xs btn-error ml-2"
title="Remove file"
>
<Icon icon="heroicons:x-mark" className="w-3 h-3" />
</button>
</div>
))}
</div>
<p className="text-xs text-gray-500">Click or drag to add more files</p>
</>
) : (
<>
<p className="font-medium">Drop your invoice files here or click to browse</p>
<p className="text-xs text-gray-500">Supports PDF, JPG, JPEG, PNG (multiple files allowed)</p>
</>
)}
</div>
</motion.div>
</motion.div>
{/* JSON/Builder Toggle */}
<motion.div
variants={itemVariants}
className="bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold text-primary">Invoice Details</h3>
<div className="flex mb-4 border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowJsonInput(false)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${!showJsonInput ? 'bg-primary text-white' : 'hover:bg-base-200'
}`}
>
Visual Editor
</button>
<button
type="button"
onClick={() => setShowJsonInput(true)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${showJsonInput ? 'bg-primary text-white' : 'hover:bg-base-200'
}`}
>
JSON Editor
</button>
</div>
</div>
{showJsonInput ? (
<motion.div
className="space-y-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex justify-between items-center">
<label className="label-text font-medium">JSON Invoice Data</label>
<motion.button
type="button"
className="btn btn-sm btn-ghost text-primary"
onClick={showJsonExample}
whileHover="hover"
whileTap="tap"
variants={buttonVariants}
>
<Icon icon="heroicons:code-bracket" className="w-4 h-4 mr-1" />
Show Example
</motion.button>
</div>
<motion.textarea
className={`textarea textarea-bordered w-full h-64 font-mono text-sm ${jsonError ? 'textarea-error' : 'focus:textarea-primary'}`}
value={jsonInput}
onChange={handleJsonInputChange}
placeholder="Paste your JSON invoice data here..."
whileHover="hover"
variants={inputHoverVariants}
/>
{jsonError && (
<div className="text-error text-sm flex items-center gap-1">
<Icon icon="heroicons:exclamation-circle" className="w-4 h-4" />
{jsonError}
</div>
)}
<div className="flex justify-end">
<motion.button
type="button"
className="btn btn-primary"
onClick={validateAndApplyJson}
whileHover="hover"
whileTap="tap"
variants={buttonVariants}
>
<Icon icon="heroicons:check-circle" className="w-5 h-5 mr-1" />
Apply JSON
</motion.button>
</div>
</motion.div>
) : (
<motion.div
variants={itemVariants}
className="form-control space-y-6"
>
<InvoiceBuilder
invoiceData={formData.invoiceData || {
vendor: '',
items: [],
subtotal: 0,
taxAmount: 0,
tipAmount: 0,
total: 0
}}
onChange={handleInvoiceDataChange}
/>
</motion.div>
)}
</motion.div>
</motion.div>
);
};
export default ASFundingSection;

View file

@ -1,320 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import type { EventRequestFormData } from './EventRequestForm';
import type { EventRequest } from '../../../schemas/pocketbase';
import CustomAlert from '../universal/CustomAlert';
// Enhanced animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
when: "beforeChildren"
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
// Input field hover animation
const inputHoverVariants = {
hover: {
scale: 1.01,
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
transition: { duration: 0.2 }
}
};
interface EventDetailsSectionProps {
formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
}
const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onDataChange }) => {
return (
<motion.div
className="space-y-8"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={itemVariants}>
<h2 className="text-3xl font-bold mb-4 text-primary bg-gradient-to-r from-primary to-primary-focus bg-clip-text text-transparent">
Event Details
</h2>
</motion.div>
<motion.div variants={itemVariants}>
<CustomAlert
type="info"
title="Coordinator Notification"
message="Please remember to ping @Coordinators in #-events on Slack once you've submitted this form so that they can fill out a TAP form for you."
className="mb-6"
icon="heroicons:information-circle"
/>
</motion.div>
{/* Event Name */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Event Name</span>
<span className="label-text-alt text-error">*</span>
</label>
<motion.input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
value={formData.name}
onChange={(e) => onDataChange({ name: e.target.value })}
placeholder="Enter event name"
required
whileHover="hover"
variants={inputHoverVariants}
/>
</motion.div>
{/* Event Description */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Event Description</span>
<span className="label-text-alt text-error">*</span>
</label>
<motion.textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px] mt-2"
value={formData.event_description}
onChange={(e) => onDataChange({ event_description: e.target.value })}
placeholder="Provide a detailed description of your event"
rows={4}
required
whileHover="hover"
variants={inputHoverVariants}
/>
</motion.div>
{/* Date and Time Section */}
<motion.div
variants={itemVariants}
className="grid grid-cols-1 gap-6"
>
{/* Event Start Date */}
<motion.div
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Event Start Date & Time</span>
<span className="label-text-alt text-error">*</span>
</label>
<motion.input
type="datetime-local"
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
value={formData.start_date_time}
onChange={(e) => {
const newStartDateTime = e.target.value;
onDataChange({ start_date_time: newStartDateTime });
// If there's already an end time set, update it to use the new start date
if (formData.end_date_time && newStartDateTime) {
try {
const existingEndDate = new Date(formData.end_date_time);
const newStartDate = new Date(newStartDateTime);
if (!isNaN(existingEndDate.getTime()) && !isNaN(newStartDate.getTime())) {
// Keep the same time but update to the new date
const updatedEndDate = new Date(newStartDate);
updatedEndDate.setHours(existingEndDate.getHours(), existingEndDate.getMinutes(), 0, 0);
onDataChange({ end_date_time: updatedEndDate.toISOString() });
}
} catch (error) {
console.error('Error updating end date when start date changed:', error);
}
}
}}
required
whileHover="hover"
variants={inputHoverVariants}
/>
<p className="text-sm text-base-content/70 mt-2">
Note: For multi-day events, please submit a separate request for each day.
</p>
<p className="text-sm text-base-content/70 mt-1">
The event time should not include setup time.
</p>
</motion.div>
{/* Event End Time */}
<motion.div
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Event End Time</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="flex flex-col gap-2">
<motion.input
type="time"
className="input input-bordered focus:input-primary transition-all duration-300"
value={formData.end_date_time ? (() => {
try {
const endDate = new Date(formData.end_date_time);
if (isNaN(endDate.getTime())) return '';
return endDate.toTimeString().substring(0, 5);
} catch (e) {
return '';
}
})() : ''}
onChange={(e) => {
const timeValue = e.target.value;
if (timeValue && formData.start_date_time) {
try {
// Create a new date object from start_date_time
const startDate = new Date(formData.start_date_time);
if (isNaN(startDate.getTime())) {
console.error('Invalid start date time');
return;
}
// Parse the time value
const [hours, minutes] = timeValue.split(':').map(Number);
// Validate hours and minutes
if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
console.error('Invalid time values');
return;
}
// Create a new date with the same date as start but different time
const endDate = new Date(startDate);
endDate.setHours(hours, minutes, 0, 0);
// Update end_date_time with the new time but same date as start
onDataChange({ end_date_time: endDate.toISOString() });
} catch (error) {
console.error('Error setting end time:', error);
}
} else if (!timeValue) {
// Clear end_date_time if time is cleared
onDataChange({ end_date_time: '' });
}
}}
required
disabled={!formData.start_date_time}
whileHover="hover"
variants={inputHoverVariants}
/>
<p className="text-xs text-base-content/60">
{!formData.start_date_time
? "Please set the start date and time first."
: "The end time will use the same date as the start date."
}
</p>
</div>
</motion.div>
</motion.div>
{/* Event Location */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Event Location</span>
<span className="label-text-alt text-error">*</span>
</label>
<motion.input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
value={formData.location}
onChange={(e) => onDataChange({ location: e.target.value })}
placeholder="Enter event location"
required
whileHover="hover"
variants={inputHoverVariants}
/>
</motion.div>
{/* Room Booking */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Room Booking Status</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="flex gap-6 mt-2">
<motion.label
className="flex items-center gap-3 cursor-pointer bg-base-100 p-3 rounded-lg hover:bg-primary/10 transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<input
type="radio"
className="radio radio-primary"
checked={formData.will_or_have_room_booking === true}
onChange={() => onDataChange({ will_or_have_room_booking: true })}
required
/>
<span className="font-medium">Yes, I have a room booking</span>
</motion.label>
<motion.label
className="flex items-center gap-3 cursor-pointer bg-base-100 p-3 rounded-lg hover:bg-primary/10 transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<input
type="radio"
className="radio radio-primary"
checked={formData.will_or_have_room_booking === false}
onChange={() => {
onDataChange({ will_or_have_room_booking: false });
}}
required
/>
<span className="font-medium">No, I don't need a booking</span>
</motion.label>
</div>
{formData.will_or_have_room_booking === false && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-4"
>
<CustomAlert
type="warning"
title="IMPORTANT: Event Will Be Cancelled"
message="If you need a booking and submit without one, your event WILL BE CANCELLED. This is non-negotiable. Contact the event coordinator immediately if you have any booking concerns."
/>
</motion.div>
)}
</motion.div>
</motion.div>
);
};
export default EventDetailsSection;

View file

@ -1,895 +0,0 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import type { EventRequestFormData } from './EventRequestForm';
import type { InvoiceItem } from './InvoiceBuilder';
import type { EventRequest } from '../../../schemas/pocketbase';
import { FlyerTypes, LogoOptions, EventRequestStatus } from '../../../schemas/pocketbase';
import CustomAlert from '../universal/CustomAlert';
import { Icon } from '@iconify/react';
import axios from 'axios';
// Define modal props interface
interface EventRequestFormPreviewModalProps {
formData: EventRequestFormData;
closeModal: () => void;
}
// Animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: 0.1,
duration: 0.3
}
},
exit: {
opacity: 0,
transition: { duration: 0.2 }
}
};
const sectionVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 400,
damping: 30,
mass: 0.8
}
}
};
const modalVariants = {
hidden: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: {
duration: 0.3,
ease: [0.19, 1.0, 0.22, 1.0] // Ease out expo
}
},
exit: {
opacity: 0,
scale: 0.95,
transition: {
duration: 0.2,
ease: "easeIn"
}
}
};
// Extended version of InvoiceItem to handle multiple property names
interface ExtendedInvoiceItem {
id?: string;
description?: string;
item?: string;
name?: string;
quantity: number;
unitPrice?: number;
unit_price?: number;
price?: number;
amount?: number;
[key: string]: any; // Allow any additional properties
}
// Helper function to normalize EventRequest to match EventRequestFormData structure
const normalizeFormData = (data: EventRequestFormData | (EventRequest & {
invoiceData?: any;
needs_as_funding?: boolean;
})): EventRequestFormData => {
// If it's already EventRequestFormData, return it
if ('needs_as_funding' in data && data.needs_as_funding !== undefined && 'invoiceData' in data) {
return data as EventRequestFormData;
}
// Convert EventRequest to EventRequestFormData format
const eventRequest = data as EventRequest & {
invoiceData?: any;
needs_as_funding?: boolean;
};
try {
// Parse invoice data
let invoiceData: {
vendor?: string;
items: any[];
subtotal: number;
taxAmount: number;
tipAmount: number;
total: number;
} = {
items: [],
subtotal: 0,
taxAmount: 0,
tipAmount: 0,
total: 0
};
// Parse existing invoice data if available
if (eventRequest.itemized_invoice) {
if (typeof eventRequest.itemized_invoice === 'string') {
try {
const parsed = JSON.parse(eventRequest.itemized_invoice || '{}');
if (parsed && typeof parsed === 'object') {
invoiceData = {
...invoiceData,
...(parsed as any),
items: Array.isArray((parsed as any).items) ? (parsed as any).items : [],
// Normalize tax/tip fields
taxAmount: Number(parsed.taxAmount ?? parsed.tax ?? 0),
tipAmount: Number(parsed.tipAmount ?? parsed.tip ?? 0)
};
// Normalize item property names
invoiceData.items = invoiceData.items.map(item => {
// Create a normalized item with all possible property names
return {
id: item.id || `item-${Math.random().toString(36).substr(2, 9)}`,
description: item.description || item.item || item.name || 'Item',
quantity: parseFloat(item.quantity) || 1,
unitPrice: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
unit_price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
amount: parseFloat(item.amount) ||
(parseFloat(item.quantity) || 1) *
parseFloat(item.unitPrice || item.unit_price || item.price || 0)
};
});
}
} catch (e) {
console.error('Error parsing itemized_invoice:', e);
}
} else if (typeof eventRequest.itemized_invoice === 'object' && eventRequest.itemized_invoice !== null) {
const parsed = eventRequest.itemized_invoice as any;
invoiceData = {
...invoiceData,
...parsed,
items: Array.isArray(parsed.items) ? parsed.items : [],
// Normalize tax/tip fields
taxAmount: Number(parsed.taxAmount ?? parsed.tax ?? 0),
tipAmount: Number(parsed.tipAmount ?? parsed.tip ?? 0)
};
// Normalize item property names
invoiceData.items = invoiceData.items.map(item => {
return {
id: item.id || `item-${Math.random().toString(36).substr(2, 9)}`,
description: item.description || item.item || item.name || 'Item',
quantity: parseFloat(item.quantity) || 1,
unitPrice: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
unit_price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
amount: parseFloat(item.amount) ||
(parseFloat(item.quantity) || 1) *
parseFloat(item.unitPrice || item.unit_price || item.price || 0)
};
});
}
} else if (eventRequest.invoiceData) {
const parsed = eventRequest.invoiceData as any;
if (parsed && typeof parsed === 'object') {
invoiceData = {
...invoiceData,
...parsed,
items: Array.isArray(parsed.items) ? parsed.items : [],
// Normalize tax/tip fields
taxAmount: Number(parsed.taxAmount ?? parsed.tax ?? 0),
tipAmount: Number(parsed.tipAmount ?? parsed.tip ?? 0)
};
// Normalize item property names
invoiceData.items = invoiceData.items.map(item => {
return {
id: item.id || `item-${Math.random().toString(36).substr(2, 9)}`,
description: item.description || item.item || item.name || 'Item',
quantity: parseFloat(item.quantity) || 1,
unitPrice: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
unit_price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
price: parseFloat(item.unitPrice || item.unit_price || item.price || 0),
amount: parseFloat(item.amount) ||
(parseFloat(item.quantity) || 1) *
parseFloat(item.unitPrice || item.unit_price || item.price || 0)
};
});
}
}
// Calculate subtotal if not set
if (typeof invoiceData.subtotal !== 'number' || isNaN(invoiceData.subtotal)) {
invoiceData.subtotal = invoiceData.items.reduce((sum: number, item: any) => {
const amount = typeof item.amount === 'number' ? item.amount : 0;
return sum + amount;
}, 0);
}
// Ensure tax and tip amounts are numbers
if (typeof invoiceData.taxAmount !== 'number' || isNaN(invoiceData.taxAmount)) {
invoiceData.taxAmount = 0;
}
if (typeof invoiceData.tipAmount !== 'number' || isNaN(invoiceData.tipAmount)) {
invoiceData.tipAmount = 0;
}
// Calculate total if not set
if (typeof invoiceData.total !== 'number' || isNaN(invoiceData.total)) {
invoiceData.total = invoiceData.subtotal + invoiceData.taxAmount + invoiceData.tipAmount;
}
// Create a normalized object that implements the EventRequestFormData interface
const normalized = {
name: eventRequest.name,
location: eventRequest.location,
start_date_time: eventRequest.start_date_time,
end_date_time: eventRequest.end_date_time,
event_description: eventRequest.event_description || '',
flyers_needed: eventRequest.flyers_needed || false,
photography_needed: eventRequest.photography_needed || false,
flyer_type: eventRequest.flyer_type || [],
other_flyer_type: eventRequest.other_flyer_type || '',
flyer_advertising_start_date: eventRequest.flyer_advertising_start_date || '',
advertising_format: eventRequest.advertising_format || '',
required_logos: eventRequest.required_logos || [],
other_logos: [] as File[], // EventRequest has this as strings but we need File[]
flyer_additional_requests: eventRequest.flyer_additional_requests || '',
will_or_have_room_booking: eventRequest.will_or_have_room_booking || false,
room_booking: null,
room_booking_confirmation: [] as File[],
expected_attendance: eventRequest.expected_attendance || 0,
food_drinks_being_served: eventRequest.food_drinks_being_served || false,
needs_as_funding: eventRequest.needs_as_funding ?? eventRequest.as_funding_required ?? false,
as_funding_required: eventRequest.as_funding_required || false,
invoice: null,
invoice_files: [] as File[],
invoiceData: invoiceData,
needs_graphics: eventRequest.needs_graphics ?? eventRequest.flyers_needed ?? false,
status: eventRequest.status || '',
created_by: eventRequest.requested_user || '',
id: eventRequest.id || '',
created: eventRequest.created || '',
updated: eventRequest.updated || '',
itemized_invoice: eventRequest.itemized_invoice || ''
};
return normalized as unknown as EventRequestFormData;
} catch (error) {
console.error("Error normalizing form data:", error);
// Return a minimal valid object to prevent rendering errors
return {
name: eventRequest.name || 'Unknown Event',
location: eventRequest.location || '',
start_date_time: eventRequest.start_date_time || new Date().toISOString(),
end_date_time: eventRequest.end_date_time || new Date().toISOString(),
event_description: eventRequest.event_description || '',
flyers_needed: false,
photography_needed: false,
as_funding_required: false,
food_drinks_being_served: false,
flyer_type: [],
other_flyer_type: '',
flyer_advertising_start_date: '',
flyer_additional_requests: '',
required_logos: [],
other_logos: [] as File[],
advertising_format: '',
will_or_have_room_booking: false,
expected_attendance: 0,
room_booking: null,
invoice: null,
invoice_files: [] as File[],
invoiceData: {
items: [],
subtotal: 0,
taxAmount: 0,
tipAmount: 0,
total: 0
},
needs_graphics: false
} as unknown as EventRequestFormData;
}
};
// Create a standalone component that can be used to show the preview as a modal
export const EventRequestFormPreviewModal = ({ formData, closeModal }: EventRequestFormPreviewModalProps) => {
console.log("EventRequestFormPreviewModal rendered with formData:", formData);
// Normalize the form data to ensure it's in the correct format
const normalizedFormData = normalizeFormData(formData);
console.log("Normalized formData:", normalizedFormData);
useEffect(() => {
console.log("Modal opened with styles applied");
const body = document.body;
body.style.overflow = 'hidden'; // Prevent background scrolling
return () => {
body.style.overflow = ''; // Restore scrolling when modal closes
};
}, []);
return (
<div
id="event-request-preview-modal-overlay"
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[999999] overflow-y-auto p-4"
onClick={(e) => {
if (e.target === e.currentTarget) {
closeModal();
}
}}
>
<div
className="bg-base-100 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-base-100 z-10 p-4 flex justify-between items-center border-b">
<h2 className="text-xl font-bold text-base-content">Event Request Preview</h2>
<button
onClick={closeModal}
className="btn btn-sm btn-circle btn-ghost"
>
<Icon icon="heroicons:x-mark" width={20} height={20} />
</button>
</div>
<div className="p-6">
<EventRequestFormPreview
formData={normalizedFormData}
isModal={true}
onClose={closeModal}
/>
</div>
</div>
</div>
);
};
// Define the interface for the EventRequestFormPreview component
interface EventRequestFormPreviewProps {
formData?: EventRequestFormData | (EventRequest & {
invoiceData?: any;
needs_as_funding?: boolean;
}); // Accept both form data and event request types
isOpen?: boolean; // Control whether the modal is open
onClose?: () => void; // Callback when modal is closed
isModal: boolean; // Whether to render as a modal or inline component
}
// Define the main EventRequestFormPreview component
const EventRequestFormPreview: React.FC<EventRequestFormPreviewProps> = ({
formData: propFormData,
isOpen = true,
onClose = () => { },
isModal = false
}) => {
console.log("EventRequestFormPreview received props:", { propFormData, isOpen, isModal });
const [formData, setFormData] = useState<EventRequestFormData | null>(
propFormData ? normalizeFormData(propFormData) : null
);
const [loading, setLoading] = useState(propFormData ? false : true);
// Log whenever formData changes for debugging
useEffect(() => {
console.log("EventRequestFormPreview formData state:", formData);
}, [formData]);
// Load form data from local storage if not provided via props
useEffect(() => {
if (propFormData) {
setFormData(normalizeFormData(propFormData));
setLoading(false);
} else {
loadFormData();
}
// Listen for form data updates
document.addEventListener('eventRequestFormDataUpdated', handleFormDataUpdate as EventListener);
return () => {
document.removeEventListener('eventRequestFormDataUpdated', handleFormDataUpdate as EventListener);
};
}, [propFormData]);
// Load form data from local storage
const loadFormData = () => {
setLoading(true);
const savedData = localStorage.getItem('eventRequestFormData');
if (savedData) {
try {
const parsedData = JSON.parse(savedData);
setFormData(parsedData);
} catch (e) {
console.error('Error parsing saved form data:', e);
}
}
setLoading(false);
};
// Handle form data updates
const handleFormDataUpdate = (event: CustomEvent) => {
if (event.detail && event.detail.formData) {
setFormData(event.detail.formData);
}
};
// Format date and time
const formatDateTime = (dateTimeString: string) => {
if (!dateTimeString) return 'Not specified';
try {
const date = new Date(dateTimeString);
const options: Intl.DateTimeFormatOptions = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
};
return date.toLocaleDateString('en-US', options);
} catch (e) {
return dateTimeString;
}
};
// Handle backdrop click for modal
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (isModal && e.target === e.currentTarget) {
onClose();
}
};
// Map badge colors for status
const getStatusBadge = (status?: string) => {
if (!status) return null;
const statusMap: { [key: string]: string } = {
'submitted': 'badge-info',
'pending': 'badge-warning',
'completed': 'badge-success',
'declined': 'badge-error'
};
return (
<span className={`badge ${statusMap[status] || 'badge-neutral'} ml-2`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
// Render the content of the preview
const renderContent = () => {
console.log("renderContent called, loading:", loading, "formData:", formData);
if (loading) {
console.log("Rendering loading state");
return (
<motion.div
className="flex justify-center items-center h-64"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="loading loading-spinner loading-lg text-primary"></div>
</motion.div>
);
}
if (!formData) {
console.log("Rendering no form data state");
return (
<motion.div
className="text-center py-16 border-2 border-dashed border-base-300 rounded-lg"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<Icon icon="heroicons:document-text" className="w-16 h-16 mx-auto text-base-300 mb-4" />
<h3 className="text-xl font-bold mb-2">No Form Data Available</h3>
<p className="text-base-content/60">Please fill out the form to see a preview.</p>
</motion.div>
);
}
// Content layout to display the form data
return (
<motion.div
initial="hidden"
animate="visible"
exit="exit"
variants={containerVariants}
className="space-y-8"
>
{/* Only show the review header when in modal view */}
{isModal && (
<motion.div
variants={sectionVariants}
className="bg-base-200/60 p-6 rounded-xl"
>
<div className="flex items-center gap-2 mb-2">
<Icon icon="heroicons:information-circle" className="text-primary w-5 h-5" />
<h2 className="text-lg font-semibold">
Review Your Event Request
{getStatusBadge(formData.status)}
</h2>
</div>
<p className="text-base-content/70">
Please review all information to make sure it is correct. Please contact the event coordinator any issues occur
</p>
</motion.div>
)}
{/* Event Details Section */}
<motion.div
variants={sectionVariants}
className="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden"
>
<div className="bg-gradient-to-r from-primary/10 to-primary/5 p-4 flex items-center">
<Icon icon="heroicons:calendar" className="text-primary w-5 h-5 mr-2" />
<h3 className="text-lg font-semibold text-base-content">
Event Details
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
<div className="col-span-full mb-4">
<h4 className="text-xl font-bold mb-1 text-base-content">
{formData.name || 'Untitled Event'}
</h4>
<p className="text-base-content/70 whitespace-pre-line">
{formData.event_description || 'No description provided.'}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center text-base-content/60">
<Icon icon="heroicons:map-pin" className="w-4 h-4 mr-1" />
<span className="text-sm">Location</span>
</div>
<p className="font-medium text-base-content">{formData.location || 'Not specified'}</p>
</div>
<div className="space-y-1">
<div className="flex items-center text-base-content/60">
<Icon icon="heroicons:users" className="w-4 h-4 mr-1" />
<span className="text-sm">Expected Attendance</span>
</div>
<p className="font-medium text-base-content">{formData.expected_attendance || 'Not specified'}</p>
{formData.expected_attendance > 0 && (
<p className="text-xs text-primary">
Budget limit: ${Math.min(formData.expected_attendance * 10, 5000)} (max $5,000)
</p>
)}
</div>
<div className="space-y-1">
<div className="flex items-center text-base-content/60">
<Icon icon="heroicons:calendar" className="w-4 h-4 mr-1" />
<span className="text-sm">Date & Time</span>
</div>
<p className="font-medium text-base-content">{formatDateTime(formData.start_date_time)}</p>
<p className="text-xs text-base-content/60">
Note: Multi-day events require separate submissions for each day.
</p>
</div>
<div className="space-y-1">
<div className="flex items-center text-base-content/60">
<Icon icon="heroicons:building-office-2" className="w-4 h-4 mr-1" />
<span className="text-sm">Room Booking</span>
</div>
<p className="font-medium text-base-content">
{formData.will_or_have_room_booking ? 'Yes' : 'No'}
</p>
</div>
</div>
</div>
</motion.div>
{/* PR Materials Section - Only show if flyers are needed */}
{formData.flyers_needed && (
<motion.div
variants={sectionVariants}
className="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden"
>
<div className="bg-gradient-to-r from-primary/10 to-primary/5 p-4 flex items-center">
<Icon icon="heroicons:document-duplicate" className="text-primary w-5 h-5 mr-2" />
<h3 className="text-lg font-semibold text-base-content">
PR Materials
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
<div className="space-y-1">
<div className="flex items-center text-base-content/60">
<Icon icon="heroicons:document" className="w-4 h-4 mr-1" />
<span className="text-sm">Flyer Types</span>
</div>
<p className="font-medium text-base-content">
{formData.flyer_type?.length
? formData.flyer_type.map(type => {
switch (type) {
case 'digital_with_social': return 'Digital (with social media)';
case 'digital_no_social': return 'Digital (no social media)';
case 'physical_with_advertising': return 'Physical (with advertising)';
case 'physical_no_advertising': return 'Physical (no advertising)';
case 'newsletter': return 'Newsletter';
case 'other': return formData.other_flyer_type || 'Other';
default: return type;
}
}).join(', ')
: 'None specified'
}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center text-base-content/60">
<Icon icon="heroicons:calendar" className="w-4 h-4 mr-1" />
<span className="text-sm">Advertising Start Date</span>
</div>
<p className="font-medium text-base-content">
{formData.flyer_advertising_start_date
? formatDateTime(formData.flyer_advertising_start_date)
: 'Not specified'
}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center text-base-content/60">
<Icon icon="heroicons:photo" className="w-4 h-4 mr-1" />
<span className="text-sm">Required Logos</span>
</div>
<p className="font-medium text-base-content">
{formData.required_logos?.length
? formData.required_logos.join(', ')
: 'None required'
}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center text-base-content/60">
<Icon icon="heroicons:presentation-chart-bar" className="w-4 h-4 mr-1" />
<span className="text-sm">Advertising Format</span>
</div>
<p className="font-medium text-base-content">
{formData.advertising_format || 'Not specified'}
</p>
</div>
{formData.flyer_additional_requests && (
<div className="col-span-full space-y-1">
<div className="flex items-center text-base-content/60">
<Icon icon="heroicons:chat-bubble-left-right" className="w-4 h-4 mr-1" />
<span className="text-sm">Additional Requests</span>
</div>
<p className="font-medium text-base-content whitespace-pre-line">
{formData.flyer_additional_requests}
</p>
</div>
)}
</div>
</div>
</motion.div>
)}
{/* TAP Form Section */}
<motion.div
variants={sectionVariants}
className="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden"
>
<div className="bg-gradient-to-r from-accent/10 to-accent/5 p-4 flex items-center">
<Icon icon="heroicons:building-office-2" className="text-accent w-5 h-5 mr-2" />
<h3 className="text-lg font-semibold text-base-content">
TAP Information
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-medium mb-1 flex items-center">
<Icon icon="heroicons:building-office" className="w-4 h-4 mr-1" />
Room Booking Status
</h4>
<div className="flex items-center">
<span className={`badge ${formData.will_or_have_room_booking ? 'badge-success' : 'badge-neutral'}`}>
{formData.will_or_have_room_booking ? 'Room Booking Confirmed' : 'No Booking Needed'}
</span>
{formData.will_or_have_room_booking && formData.room_booking && (
<span className="badge badge-info ml-2">File Uploaded</span>
)}
</div>
</div>
<div>
<h4 className="font-medium mb-1 flex items-center">
<Icon icon="heroicons:cake" className="w-4 h-4 mr-1" />
Food and Drinks
</h4>
<span className={`badge ${formData.food_drinks_being_served ? 'badge-success' : 'badge-neutral'}`}>
{formData.food_drinks_being_served ? 'Being Served' : 'Not Being Served'}
</span>
</div>
</div>
</div>
</motion.div>
{/* AS Funding Section - Only show if needed */}
{formData.needs_as_funding && (
<motion.div
variants={sectionVariants}
className="bg-base-100 rounded-xl border border-base-300 shadow-sm overflow-hidden"
>
<div className="bg-gradient-to-r from-primary/10 to-primary/5 p-4 flex items-center">
<Icon icon="heroicons:currency-dollar" className="text-primary w-5 h-5 mr-2" />
<h3 className="text-lg font-semibold text-base-content">
AS Funding
</h3>
</div>
<div className="p-6">
{formData.invoiceData && formData.invoiceData.items && formData.invoiceData.items.length > 0 ? (
<div className="overflow-x-auto">
<h4 className="font-medium mb-2 flex items-center text-base-content">
<Icon icon="heroicons:receipt-percent" className="w-4 h-4 mr-1" />
Invoice from {formData.invoiceData.vendor || 'Unknown Vendor'}
</h4>
<table className="table table-zebra w-full text-sm border-collapse overflow-hidden rounded-lg">
<thead className="bg-base-200/70">
<tr>
<th className="py-3 text-base-content">Item</th>
<th className="py-3 text-right text-base-content">Qty</th>
<th className="py-3 text-right text-base-content">Unit Price</th>
<th className="py-3 text-right text-base-content">Amount</th>
</tr>
</thead>
<tbody>
{formData.invoiceData.items.map((item: ExtendedInvoiceItem, index: number) => (
<tr key={item.id || index} className="border-t border-base-300">
<td className="py-2 font-medium text-base-content">{item.description || item.item || item.name || 'Item'}</td>
<td className="py-2 text-right text-base-content">{item.quantity || 1}</td>
<td className="py-2 text-right text-base-content">${(item.unitPrice || item.unit_price || item.price || 0).toFixed(2)}</td>
<td className="py-2 text-right font-medium text-base-content">${(item.amount || (item.quantity * (item.unitPrice || item.unit_price || item.price || 0)) || 0).toFixed(2)}</td>
</tr>
))}
</tbody>
<tfoot className="bg-base-200/40">
<tr className="border-t border-base-300">
<td colSpan={3} className="py-2 text-right font-medium text-base-content">Subtotal:</td>
<td className="py-2 text-right font-medium text-base-content">${(formData.invoiceData.subtotal || 0).toFixed(2)}</td>
</tr>
<tr className="border-t border-base-300">
<td colSpan={3} className="py-2 text-right font-medium text-base-content">Tax:</td>
<td className="py-2 text-right font-medium text-base-content">${(formData.invoiceData.taxAmount || 0).toFixed(2)}</td>
</tr>
<tr className="border-t border-base-300">
<td colSpan={3} className="py-2 text-right font-medium text-base-content">Tip:</td>
<td className="py-2 text-right font-medium text-base-content">${(formData.invoiceData.tipAmount || 0).toFixed(2)}</td>
</tr>
<tr className="bg-primary/5">
<td colSpan={3} className="py-2 text-right font-bold text-primary">Total:</td>
<td className="py-2 text-right font-bold text-primary">${(formData.invoiceData.total || 0).toFixed(2)}</td>
</tr>
</tfoot>
</table>
</div>
) : (
<p className="text-base-content">No itemized invoice available.</p>
)}
</div>
</motion.div>
)}
</motion.div>
);
};
// Render the whole component
return (
<div
className={`event-request-preview ${isModal ? 'modal-preview' : 'inline-preview'}`}
onClick={isModal ? handleBackdropClick : undefined}
>
{renderContent()}
</div>
);
};
// Ensure the modal always appears properly by adding global styles
const modalStyles = `
/* Global styles for event request preview modal */
#event-request-preview-modal-overlay,
.fixed[id="event-request-preview-modal-overlay"] {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
max-width: 100vw !important;
max-height: 100vh !important;
z-index: 999999 !important;
margin: 0 !important;
padding: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
overflow: auto !important;
background-color: rgba(0, 0, 0, 0.6) !important;
backdrop-filter: blur(4px) !important;
}
`;
// Add the styles to the document head
if (typeof document !== 'undefined') {
const styleElement = document.createElement('style');
styleElement.textContent = modalStyles;
document.head.appendChild(styleElement);
}
// Create a wrapper component that listens for the custom event
export const EventRequestFormPreviewModalWrapper: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [formData, setFormData] = useState<EventRequestFormData | null>(null);
useEffect(() => {
// Define the event handler
const handleShowModal = (event: CustomEvent) => {
console.log("showEventRequestPreviewModal event received:", event.detail);
if (event.detail && event.detail.formData) {
console.log("Setting formData from event:", event.detail.formData);
setFormData(event.detail.formData);
setIsOpen(true);
}
};
// Add event listener
document.addEventListener('showEventRequestPreviewModal', handleShowModal as EventListener);
// Clean up
return () => {
document.removeEventListener('showEventRequestPreviewModal', handleShowModal as EventListener);
};
}, []);
const closeModal = () => {
console.log("Closing modal");
setIsOpen(false);
// Dispatch custom event to notify modal has closed
document.dispatchEvent(new CustomEvent('modalClosed'));
};
console.log("EventRequestFormPreviewModalWrapper state:", { isOpen, hasFormData: !!formData });
if (!isOpen || !formData) return null;
return (
<EventRequestFormPreviewModal
formData={formData}
closeModal={closeModal}
/>
);
};
// Export the EventRequestFormPreview component as a named export
export { EventRequestFormPreview };
// Export the wrapper component as default
export default EventRequestFormPreviewModalWrapper;

View file

@ -1,786 +0,0 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import toast from 'react-hot-toast';
import CustomAlert from '../universal/CustomAlert';
import { Icon } from '@iconify/react';
// Enhanced animation variants with faster transitions
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.035,
when: "beforeChildren",
duration: 0.3,
ease: "easeOut"
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 500,
damping: 25,
mass: 0.8,
duration: 0.25
}
}
};
// Input field hover animation
const inputHoverVariants = {
hover: {
scale: 1.01,
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
transition: { duration: 0.15 }
}
};
// Button animation
const buttonVariants = {
hover: {
scale: 1.03,
transition: { duration: 0.15, ease: "easeOut" }
},
tap: {
scale: 0.97,
transition: { duration: 0.1 }
}
};
// Row animation
const rowVariants = {
hidden: { opacity: 0, x: -10 },
visible: {
opacity: 1,
x: 0,
transition: {
type: "spring",
stiffness: 500,
damping: 25,
mass: 0.8,
duration: 0.25
}
},
exit: {
opacity: 0,
x: 10,
transition: { duration: 0.15 }
}
};
// Invoice item interface
export interface InvoiceItem {
id: string;
description: string;
quantity: number;
unitPrice: number;
amount: number;
}
// Invoice data interface
export interface InvoiceData {
items: InvoiceItem[];
subtotal: number;
taxAmount: number;
tipAmount: number;
total: number;
vendor: string;
}
interface InvoiceBuilderProps {
invoiceData: InvoiceData;
onChange: (data: InvoiceData) => void;
}
const InvoiceBuilder: React.FC<InvoiceBuilderProps> = ({ invoiceData, onChange }) => {
const [validationMessages, setValidationMessages] = useState({
vendor: '',
items: '',
tax: '',
tip: ''
});
// State for new item form
const [newItem, setNewItem] = useState<{
description: string;
quantity: number;
unitPrice: string;
}>({
description: '',
quantity: 1,
unitPrice: ''
});
// State for form errors
const [errors, setErrors] = useState<{
description?: string;
quantity?: string;
unitPrice?: string;
}>({});
// State for raw input values (to preserve exact user input)
const [rawInputs, setRawInputs] = useState<{
taxAmount: string;
tipAmount: string;
}>({
taxAmount: '',
tipAmount: ''
});
// Validate inputs
useEffect(() => {
const newValidationMessages = {
vendor: !invoiceData.vendor ? 'Vendor name is required' : '',
items: invoiceData.items.length === 0 ? 'At least one item is required' : '',
tax: invoiceData.taxAmount && isNaN(parseFloat(invoiceData.taxAmount.toString())) ? 'Tax must be a valid number' : '',
tip: invoiceData.tipAmount && isNaN(parseFloat(invoiceData.tipAmount.toString())) ? 'Tip must be a valid number' : ''
};
setValidationMessages(newValidationMessages);
}, [invoiceData]);
// Handle vendor name change
const handleVendorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange({ ...invoiceData, vendor: e.target.value });
};
// Generate a unique ID for new items
const generateId = () => {
return `item-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
};
// Helper function to round to 2 decimal places
const roundToTwoDecimals = (num: number): number => {
return Math.round((num + Number.EPSILON) * 100) / 100;
};
// Validate new item before adding
const validateNewItem = () => {
const newErrors: {
description?: string;
quantity?: string;
unitPrice?: string;
} = {};
if (!newItem.description.trim()) {
newErrors.description = 'Item description is required';
}
if (typeof newItem.quantity !== 'number' || newItem.quantity <= 0) {
newErrors.quantity = 'Quantity must be a positive number';
}
const unitPrice = typeof newItem.unitPrice === 'string'
? parseFloat(newItem.unitPrice)
: newItem.unitPrice;
if (isNaN(unitPrice) || unitPrice <= 0) {
newErrors.unitPrice = 'Price must be a positive number';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Add new item
const handleAddItem = () => {
if (!validateNewItem()) {
return;
}
// Calculate amount
const quantity = typeof newItem.quantity === 'number' ? newItem.quantity : 0;
const unitPrice = typeof newItem.unitPrice === 'string' && newItem.unitPrice !== ''
? parseFloat(newItem.unitPrice)
: 0;
const amount = roundToTwoDecimals(quantity * unitPrice);
// Create new item
const item: InvoiceItem = {
id: generateId(),
description: newItem.description,
quantity: quantity,
unitPrice: unitPrice,
amount: amount
};
// Add item to invoice
const updatedItems = [...invoiceData.items, item];
// Calculate new subtotal
const subtotal = updatedItems.reduce((sum, item) => sum + item.amount, 0);
// Recalculate tax and tip amounts based on rates
const taxAmount = roundToTwoDecimals(invoiceData.taxAmount);
const tipAmount = roundToTwoDecimals(invoiceData.tipAmount);
// Calculate new total
const total = roundToTwoDecimals(subtotal + taxAmount + tipAmount);
onChange({
...invoiceData,
items: updatedItems,
subtotal: subtotal,
taxAmount: taxAmount,
tipAmount: tipAmount,
total: total
});
// Reset form
setNewItem({
description: '',
quantity: 1,
unitPrice: ''
});
// Clear errors
setErrors({});
toast.success(`Added ${item.description} to invoice`);
};
// Handle item changes
const handleItemChange = (index: number, field: keyof InvoiceItem, value: string | number) => {
const updatedItems = [...invoiceData.items];
if (field === 'description') {
updatedItems[index] = { ...updatedItems[index], description: value as string };
} else if (field === 'unitPrice') {
const unitPrice = parseFloat(value.toString());
if (!isNaN(unitPrice)) {
const amount = roundToTwoDecimals(unitPrice * updatedItems[index].quantity);
updatedItems[index] = {
...updatedItems[index],
unitPrice: unitPrice,
amount: amount
};
}
} else if (field === 'quantity') {
const quantity = parseInt(value.toString());
if (!isNaN(quantity)) {
const amount = roundToTwoDecimals(quantity * updatedItems[index].unitPrice);
updatedItems[index] = {
...updatedItems[index],
quantity: quantity,
amount: amount
};
}
}
// Calculate new subtotal
const subtotal = updatedItems.reduce((sum, item) => sum + item.amount, 0);
// Recalculate tax and tip amounts based on rates
const taxAmount = roundToTwoDecimals(invoiceData.taxAmount);
const tipAmount = roundToTwoDecimals(invoiceData.tipAmount);
// Calculate new total
const total = roundToTwoDecimals(subtotal + taxAmount + tipAmount);
onChange({
...invoiceData,
items: updatedItems,
subtotal: subtotal,
taxAmount: taxAmount,
tipAmount: tipAmount,
total: total
});
};
// Remove item
const handleRemoveItem = (id: string) => {
const updatedItems = invoiceData.items.filter(item => item.id !== id);
// Calculate new subtotal
const subtotal = updatedItems.reduce((sum, item) => sum + item.amount, 0);
// Recalculate tax and tip amounts based on rates
const taxAmount = roundToTwoDecimals(invoiceData.taxAmount);
const tipAmount = roundToTwoDecimals(invoiceData.tipAmount);
// Calculate new total
const total = roundToTwoDecimals(subtotal + taxAmount + tipAmount);
onChange({
...invoiceData,
items: updatedItems,
subtotal: subtotal,
taxAmount: taxAmount,
tipAmount: tipAmount,
total: total
});
toast.success('Item removed from invoice');
};
// Handle tax change
const handleTaxAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const taxAmount = parseFloat(e.target.value);
if (!isNaN(taxAmount)) {
const total = roundToTwoDecimals(invoiceData.subtotal + taxAmount + invoiceData.tipAmount);
onChange({
...invoiceData,
taxAmount: taxAmount,
total: total
});
} else {
onChange({ ...invoiceData, taxAmount: 0 });
}
};
// Handle tip change
const handleTipAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const tipAmount = parseFloat(e.target.value);
if (!isNaN(tipAmount)) {
const total = roundToTwoDecimals(invoiceData.subtotal + invoiceData.taxAmount + tipAmount);
onChange({
...invoiceData,
tipAmount: tipAmount,
total: total
});
} else {
onChange({ ...invoiceData, tipAmount: 0 });
}
};
// Calculate subtotal
const subtotal = invoiceData.subtotal;
// Calculate tax amount
const taxAmount = invoiceData.taxAmount;
// Calculate tip amount
const tipAmount = invoiceData.tipAmount;
// Calculate total
const total = invoiceData.total;
return (
<motion.div
className="space-y-6"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={itemVariants}>
<h3 className="text-xl font-bold mb-4 text-primary">Invoice Builder</h3>
</motion.div>
{/* AS Funding Limit Notice */}
<CustomAlert
type="warning"
title="AS Funding Limits"
message="Maximum of $10.00 per expected student attendee and $5,000 per event."
className="mb-4"
icon="heroicons:exclamation-triangle"
/>
{/* Vendor Input */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Vendor Name</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="relative">
<motion.input
type="text"
className={`input input-bordered w-full ${validationMessages.vendor ? 'input-error' : 'focus:input-primary'}`}
placeholder="Enter vendor name"
value={invoiceData.vendor}
onChange={handleVendorChange}
whileHover="hover"
variants={inputHoverVariants}
/>
{validationMessages.vendor && (
<div className="text-error text-sm mt-1 flex items-center gap-1">
<Icon icon="heroicons:exclamation-circle" className="w-4 h-4" />
{validationMessages.vendor}
</div>
)}
</div>
</motion.div>
{/* Add New Item Form */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<h4 className="font-medium text-lg mb-4">Add New Item</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="form-control">
<label className="label">
<span className="label-text">Description</span>
</label>
<motion.input
type="text"
className={`input input-bordered w-full ${errors.description ? 'input-error' : 'focus:input-primary'}`}
placeholder="Item description"
value={newItem.description}
onChange={(e) => setNewItem({ ...newItem, description: e.target.value })}
whileHover="hover"
variants={inputHoverVariants}
/>
{errors.description && (
<div className="text-error text-sm mt-1 flex items-center gap-1">
<Icon icon="heroicons:exclamation-circle" className="w-4 h-4" />
{errors.description}
</div>
)}
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Price ($)</span>
</label>
<motion.input
type="number"
className={`input input-bordered w-full ${errors.unitPrice ? 'input-error' : 'focus:input-primary'}`}
placeholder="0.00"
min="0"
step="0.01"
value={newItem.unitPrice}
onChange={(e) => setNewItem({ ...newItem, unitPrice: e.target.value })}
whileHover="hover"
variants={inputHoverVariants}
/>
{errors.unitPrice && (
<div className="text-error text-sm mt-1 flex items-center gap-1">
<Icon icon="heroicons:exclamation-circle" className="w-4 h-4" />
{errors.unitPrice}
</div>
)}
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Quantity</span>
</label>
<motion.input
type="number"
className={`input input-bordered w-full ${errors.quantity ? 'input-error' : 'focus:input-primary'}`}
placeholder="1"
min="1"
value={newItem.quantity}
onChange={(e) => {
const value = parseInt(e.target.value);
setNewItem({
...newItem,
quantity: isNaN(value) ? 1 : value
});
}}
whileHover="hover"
variants={inputHoverVariants}
/>
{errors.quantity && (
<div className="text-error text-sm mt-1 flex items-center gap-1">
<Icon icon="heroicons:exclamation-circle" className="w-4 h-4" />
{errors.quantity}
</div>
)}
</div>
</div>
<div className="flex justify-end">
<motion.button
type="button"
className="btn btn-primary gap-2"
onClick={handleAddItem}
whileHover="hover"
whileTap="tap"
variants={buttonVariants}
>
<Icon icon="heroicons:plus-circle" className="w-5 h-5" />
Add Item
</motion.button>
</div>
</motion.div>
{/* Items Section */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<div className="flex justify-between items-center mb-4">
<label className="label-text font-medium text-lg">
Items
<span className="text-error ml-1">*</span>
</label>
</div>
{validationMessages.items && (
<div className="text-error text-sm mb-3 flex items-center gap-1">
<Icon icon="heroicons:exclamation-circle" className="w-4 h-4" />
{validationMessages.items}
</div>
)}
<div className="overflow-x-auto">
<table className="table table-zebra w-full">
<thead>
<tr className="bg-base-300/50">
<th className="w-[40%]">Item</th>
<th className="w-[20%]">Price ($)</th>
<th className="w-[15%]">Qty</th>
<th className="w-[20%]">Total ($)</th>
<th className="w-[5%]"></th>
</tr>
</thead>
<tbody>
{invoiceData.items.map((item, index) => (
<motion.tr
key={item.id}
variants={rowVariants}
initial="hidden"
animate="visible"
exit="exit"
className="hover:bg-base-300/30 transition-colors"
>
<td>
<motion.input
type="text"
className="input input-bordered input-sm w-full focus:input-primary"
placeholder="Item name"
value={item.description}
onChange={(e) => handleItemChange(index, 'description', e.target.value)}
whileHover="hover"
variants={inputHoverVariants}
/>
</td>
<td>
<motion.input
type="number"
className="input input-bordered input-sm w-full focus:input-primary"
placeholder="0.00"
min="0"
step="0.01"
value={item.unitPrice}
onChange={(e) => handleItemChange(index, 'unitPrice', e.target.value)}
whileHover="hover"
variants={inputHoverVariants}
/>
</td>
<td>
<motion.input
type="number"
className="input input-bordered input-sm w-full focus:input-primary"
placeholder="1"
min="1"
value={item.quantity}
onChange={(e) => handleItemChange(index, 'quantity', e.target.value)}
whileHover="hover"
variants={inputHoverVariants}
/>
</td>
<td>
<div className="input input-sm input-bordered bg-base-200/50 w-full text-right">
{item.amount.toFixed(2)}
</div>
</td>
<td>
<motion.button
type="button"
className="btn btn-sm btn-ghost btn-circle text-error"
onClick={() => handleRemoveItem(item.id)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Icon icon="heroicons:trash" className="w-5 h-5" />
</motion.button>
</td>
</motion.tr>
))}
{invoiceData.items.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-4 text-gray-500">
No items added yet. Use the form above to add items.
</td>
</tr>
)}
</tbody>
</table>
</div>
</motion.div>
{/* Tax and Tip Section */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label-text font-medium text-lg mb-4">Tax and Tip</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="form-control">
<label className="label">
<span className="label-text">Tax Amount ($)</span>
</label>
<div className="relative">
<motion.input
type="number"
className={`input input-bordered w-full ${validationMessages.tax ? 'input-error' : 'focus:input-primary'}`}
placeholder="0.00"
min="0"
step="0.01"
value={invoiceData.taxAmount || ''}
onChange={handleTaxAmountChange}
whileHover="hover"
variants={inputHoverVariants}
/>
{validationMessages.tax && (
<div className="text-error text-sm mt-1 flex items-center gap-1">
<Icon icon="heroicons:exclamation-circle" className="w-4 h-4" />
{validationMessages.tax}
</div>
)}
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Tip Amount ($)</span>
</label>
<div className="relative">
<motion.input
type="number"
className={`input input-bordered w-full ${validationMessages.tip ? 'input-error' : 'focus:input-primary'}`}
placeholder="0.00"
min="0"
step="0.01"
value={invoiceData.tipAmount || ''}
onChange={handleTipAmountChange}
whileHover="hover"
variants={inputHoverVariants}
/>
{validationMessages.tip && (
<div className="text-error text-sm mt-1 flex items-center gap-1">
<Icon icon="heroicons:exclamation-circle" className="w-4 h-4" />
{validationMessages.tip}
</div>
)}
</div>
</div>
</div>
</motion.div>
{/* Totals Section */}
<motion.div
variants={itemVariants}
className="bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<h3 className="text-lg font-medium mb-4">Invoice Summary</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-base-content/70">Subtotal:</span>
<span className="font-medium">${subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-base-content/70">Tax:</span>
<span className="font-medium">${taxAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-base-content/70">Tip:</span>
<span className="font-medium">${tipAmount.toFixed(2)}</span>
</div>
<div className="divider my-2"></div>
<div className="flex justify-between items-center">
<span className="text-lg font-bold">Total:</span>
<span className="text-lg font-bold text-primary">${total.toFixed(2)}</span>
</div>
</div>
</motion.div>
{/* Budget Warning */}
{total > 0 && (
<CustomAlert
type="warning"
title="BUDGET RESTRICTION"
message="Your total cannot exceed $10 per expected attendee, with an absolute maximum of $5,000. Your form WILL BE REJECTED if it exceeds this limit."
className="mt-4"
icon="heroicons:exclamation-triangle"
/>
)}
{/* Validation notice */}
{invoiceData.items.length === 0 && (
<CustomAlert
type="info"
title="Invoice Required"
message="Please add at least one item to the invoice."
className="mt-4"
icon="heroicons:information-circle"
/>
)}
{/* Important Note */}
<CustomAlert
type="warning"
title="Important Note"
message="The invoice builder helps you itemize your expenses for AS funding. You must still upload the actual invoice file."
className="mt-4"
icon="heroicons:exclamation-triangle"
/>
{/* Summary Section */}
<motion.div
variants={itemVariants}
className="mt-6 bg-base-200/40 rounded-lg p-4"
>
<div className=" md:grid-cols-2 gap-6">
{/* Right Column: Summary */}
<div>
<h3 className="font-medium text-lg mb-3">Invoice Summary</h3>
<div className="bg-base-100/50 p-4 rounded-lg">
<div className="flex justify-between items-center mb-1">
<span className="text-sm">Subtotal:</span>
<span className="font-medium">${invoiceData.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center mb-1">
<span className="text-sm">Tax:</span>
<span className="font-medium">${invoiceData.taxAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center mb-1">
<span className="text-sm">Tip:</span>
<span className="font-medium">${invoiceData.tipAmount.toFixed(2)}</span>
</div>
<div className="divider my-1"></div>
<div className="flex justify-between items-center font-bold text-primary">
<span>Total:</span>
<span>${invoiceData.total.toFixed(2)}</span>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
);
};
export default InvoiceBuilder;

View file

@ -1,518 +0,0 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import type { EventRequestFormData } from './EventRequestForm';
import type { EventRequest } from '../../../schemas/pocketbase';
import { FlyerTypes, LogoOptions } from '../../../schemas/pocketbase';
import CustomAlert from '../universal/CustomAlert';
import { Icon } from '@iconify/react';
// Enhanced animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.07,
when: "beforeChildren"
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
// Input field hover animation
const inputHoverVariants = {
hover: {
scale: 1.01,
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
transition: { duration: 0.2 }
}
};
// Checkbox animation
const checkboxVariants = {
checked: { scale: 1.05 },
unchecked: { scale: 1 },
hover: {
backgroundColor: "rgba(var(--p), 0.1)",
transition: { duration: 0.2 }
},
tap: { scale: 0.95 }
};
// File upload animation
const fileUploadVariants = {
initial: { scale: 1 },
hover: {
scale: 1.02,
boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)",
transition: { duration: 0.2 }
},
tap: { scale: 0.98 }
};
// Flyer type options
const FLYER_TYPES = [
{ value: FlyerTypes.DIGITAL_WITH_SOCIAL, label: 'Digital flyer (with social media advertising: Facebook, Instagram, Discord)' },
{ value: FlyerTypes.DIGITAL_NO_SOCIAL, label: 'Digital flyer (with NO social media advertising)' },
{ value: FlyerTypes.PHYSICAL_WITH_ADVERTISING, label: 'Physical flyer (with advertising)' },
{ value: FlyerTypes.PHYSICAL_NO_ADVERTISING, label: 'Physical flyer (with NO advertising)' },
{ value: FlyerTypes.NEWSLETTER, label: 'Newsletter (IEEE, ECE, IDEA)' },
{ value: FlyerTypes.OTHER, label: 'Other' }
];
// Logo options
const LOGO_OPTIONS = [
{ value: LogoOptions.IEEE, label: 'IEEE' },
{ value: LogoOptions.AS, label: 'AS (required if funded by AS)' },
{ value: LogoOptions.HKN, label: 'HKN' },
{ value: LogoOptions.TESC, label: 'TESC' },
{ value: LogoOptions.PIB, label: 'PIB' },
{ value: LogoOptions.TNT, label: 'TNT' },
{ value: LogoOptions.SWE, label: 'SWE' },
{ value: LogoOptions.OTHER, label: 'OTHER (please upload transparent logo files)' }
];
// Format options
const FORMAT_OPTIONS = [
{ value: 'pdf', label: 'PDF' },
{ value: 'jpeg', label: 'JPEG' },
{ value: 'png', label: 'PNG' },
{ value: 'does_not_matter', label: 'DOES NOT MATTER' }
];
interface PRSectionProps {
formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
}
const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
const [otherLogoFiles, setOtherLogoFiles] = useState<File[]>(formData.other_logos || []);
const [isDragging, setIsDragging] = useState(false);
// Handle checkbox change for flyer types
const handleFlyerTypeChange = (type: string) => {
const updatedTypes = formData.flyer_type.includes(type)
? formData.flyer_type.filter(t => t !== type)
: [...formData.flyer_type, type];
onDataChange({ flyer_type: updatedTypes });
};
// Handle checkbox change for required logos
const handleLogoChange = (logo: string) => {
const updatedLogos = formData.required_logos.includes(logo)
? formData.required_logos.filter(l => l !== logo)
: [...formData.required_logos, logo];
onDataChange({ required_logos: updatedLogos });
};
// Handle file upload for other logos
const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files) as File[];
// Combine existing files with new files instead of replacing
const combinedFiles = [...otherLogoFiles, ...newFiles];
setOtherLogoFiles(combinedFiles);
onDataChange({ other_logos: combinedFiles });
}
};
// Handle removing individual files
const handleRemoveLogoFile = (indexToRemove: number) => {
const updatedFiles = otherLogoFiles.filter((_, index) => index !== indexToRemove);
setOtherLogoFiles(updatedFiles);
onDataChange({ other_logos: updatedFiles });
};
// Handle clearing all files
const handleClearAllLogoFiles = () => {
setOtherLogoFiles([]);
onDataChange({ other_logos: [] });
};
// Handle drag events for file upload
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const newFiles = Array.from(e.dataTransfer.files) as File[];
// Combine existing files with new files instead of replacing
const combinedFiles = [...otherLogoFiles, ...newFiles];
setOtherLogoFiles(combinedFiles);
onDataChange({ other_logos: combinedFiles });
}
};
return (
<motion.div
className="space-y-8"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={itemVariants}>
<h2 className="text-3xl font-bold mb-4 text-primary bg-gradient-to-r from-primary to-primary-focus bg-clip-text text-transparent">
PR Materials
</h2>
</motion.div>
<motion.div variants={itemVariants}>
<CustomAlert
type="info"
title="Important Timeline"
message="If you need PR Materials, please don't forget that this form MUST be submitted at least 6 weeks in advance even if you aren't requesting AS funding or physical flyers. Also, please remember to ping PR in #-events on Slack once you've submitted this form."
className="mb-6"
icon="heroicons:clock"
/>
</motion.div>
{/* Type of material needed */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Type of Material Needed</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="space-y-3 mt-3">
{FLYER_TYPES.map((type) => (
<motion.label
key={type.value}
className={`flex items-start gap-3 cursor-pointer p-3 rounded-lg transition-colors ${formData.flyer_type.includes(type.value)
? 'bg-primary/20 border border-primary/50'
: 'hover:bg-base-300/30'
}`}
initial="unchecked"
animate={formData.flyer_type.includes(type.value) ? "checked" : "unchecked"}
whileHover="hover"
whileTap="tap"
variants={checkboxVariants}
style={{ margin: '4px' }}
>
<input
type="checkbox"
className="checkbox checkbox-primary mt-1"
checked={formData.flyer_type.includes(type.value)}
onChange={() => handleFlyerTypeChange(type.value)}
/>
<span className="font-medium">{type.label}</span>
</motion.label>
))}
</div>
{/* Other flyer type input */}
{formData.flyer_type.includes(FlyerTypes.OTHER) && (
<motion.div
className="mt-4 pl-8"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<motion.input
type="text"
className="input input-bordered input-primary w-full"
placeholder="Please specify other material needed"
value={formData.other_flyer_type}
onChange={(e) => onDataChange({ other_flyer_type: e.target.value })}
required
whileHover="hover"
variants={inputHoverVariants}
/>
</motion.div>
)}
</motion.div>
{/* Advertising start date */}
{formData.flyer_type.some(type =>
type === FlyerTypes.DIGITAL_WITH_SOCIAL ||
type === FlyerTypes.PHYSICAL_WITH_ADVERTISING ||
type === FlyerTypes.NEWSLETTER
) && (
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Advertising Start Date</span>
<span className="label-text-alt text-error">*</span>
</label>
<p className="text-sm text-gray-500 mb-3">When do you need us to start advertising?</p>
<motion.input
type="date"
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
value={formData.flyer_advertising_start_date}
onChange={(e) => onDataChange({ flyer_advertising_start_date: e.target.value })}
required
whileHover="hover"
variants={inputHoverVariants}
/>
</motion.div>
)}
{/* Logos Required */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Logos Required</span>
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 mt-3">
{LOGO_OPTIONS.map((logo) => (
<motion.label
key={logo.value}
className={`flex items-start gap-3 cursor-pointer p-3 rounded-lg transition-colors ${formData.required_logos.includes(logo.value)
? 'bg-primary/20 border border-primary/50'
: 'hover:bg-base-300/30'
}`}
initial="unchecked"
animate={formData.required_logos.includes(logo.value) ? "checked" : "unchecked"}
whileHover="hover"
whileTap="tap"
variants={checkboxVariants}
style={{ margin: '4px' }}
>
<input
type="checkbox"
className="checkbox checkbox-primary mt-1"
checked={formData.required_logos.includes(logo.value)}
onChange={() => handleLogoChange(logo.value)}
/>
<span className="font-medium">{logo.label}</span>
</motion.label>
))}
</div>
</motion.div>
{/* Logo file upload */}
{formData.required_logos.includes(LogoOptions.OTHER) && (
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Logo Files</span>
<span className="label-text-alt text-error">*</span>
</label>
<motion.div
className={`mt-2 border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${isDragging ? 'border-primary bg-primary/5' : 'border-base-300 hover:border-primary/50'
}`}
variants={fileUploadVariants}
initial="initial"
whileHover="hover"
whileTap="tap"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => document.getElementById('logo-files')?.click()}
>
<input
id="logo-files"
type="file"
className="hidden"
onChange={handleLogoFileChange}
accept="image/*"
multiple
required
/>
<div className="flex flex-col items-center justify-center gap-3">
<motion.div
className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary"
whileHover={{ rotate: 15, scale: 1.1 }}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</motion.div>
{otherLogoFiles.length > 0 ? (
<>
<div className="flex items-center justify-between w-full">
<p className="font-medium text-primary">{otherLogoFiles.length} file(s) selected:</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearAllLogoFiles();
}}
className="btn btn-xs btn-outline btn-error"
title="Clear all files"
>
Clear All
</button>
</div>
<div className="max-h-32 overflow-y-auto text-left w-full space-y-1">
{otherLogoFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
<span className="text-sm truncate flex-1">{file.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveLogoFile(index);
}}
className="btn btn-xs btn-error ml-2"
title="Remove file"
>
<Icon icon="heroicons:x-mark" className="w-3 h-3" />
</button>
</div>
))}
</div>
<p className="text-xs text-gray-500">Click or drag to add more files</p>
</>
) : (
<>
<p className="font-medium">Drop your logo files here or click to browse</p>
<p className="text-xs text-gray-500">Please upload transparent logo files (PNG preferred, multiple files allowed)</p>
</>
)}
</div>
</motion.div>
</motion.div>
)}
{/* Format */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Required Format</span>
<span className="label-text-alt text-error">*</span>
</label>
<p className="text-sm text-gray-500 mb-3">What format do you need the materials to be in?</p>
<motion.select
className="select select-bordered focus:select-primary transition-all duration-300"
value={formData.advertising_format}
onChange={(e) => onDataChange({ advertising_format: e.target.value })}
required
whileHover="hover"
variants={inputHoverVariants}
>
<option value="">Select format</option>
{FORMAT_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</motion.select>
</motion.div>
{/* Additional specifications */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Additional Specifications</span>
</label>
<p className="text-sm text-gray-500 mb-3">Any other specifications and requests?</p>
<motion.textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px]"
value={formData.flyer_additional_requests}
onChange={(e) => onDataChange({ flyer_additional_requests: e.target.value })}
placeholder="Color scheme, overall design, examples to consider, etc."
rows={4}
whileHover="hover"
variants={inputHoverVariants}
/>
</motion.div>
{/* Photography Needed */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Photography</span>
<span className="label-text-alt text-error">*</span>
</label>
<p className="text-sm text-gray-500 mb-3">Do you need photography for your event?</p>
<div className="flex gap-6 mt-2">
<motion.label
className={`flex items-center gap-3 cursor-pointer p-4 rounded-lg transition-colors ${formData.photography_needed === true
? 'bg-primary/20 border border-primary/50'
: 'bg-base-100 hover:bg-primary/10'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<input
type="radio"
className="radio radio-primary"
checked={formData.photography_needed === true}
onChange={() => onDataChange({ photography_needed: true })}
required
/>
<span className="font-medium">Yes, we need photography</span>
</motion.label>
<motion.label
className={`flex items-center gap-3 cursor-pointer p-4 rounded-lg transition-colors ${formData.photography_needed === false
? 'bg-primary/20 border border-primary/50'
: 'bg-base-100 hover:bg-primary/10'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<input
type="radio"
className="radio radio-primary"
checked={formData.photography_needed === false}
onChange={() => onDataChange({ photography_needed: false })}
required
/>
<span className="font-medium">No, we don't need photography</span>
</motion.label>
</div>
</motion.div>
</motion.div>
);
};
export default PRSection;

View file

@ -1,588 +0,0 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import type { EventRequestFormData } from './EventRequestForm';
import type { EventRequest } from '../../../schemas/pocketbase';
import CustomAlert from '../universal/CustomAlert';
import FilePreview from '../universal/FilePreview';
import { Icon } from '@iconify/react';
// Enhanced animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
when: "beforeChildren"
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
// Input field hover animation
const inputHoverVariants = {
hover: {
scale: 1.01,
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
transition: { duration: 0.2 }
}
};
// Add a new CSS class to hide the number input arrows
const hiddenNumberInputArrows = `
/* Hide number input spinners */
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}
`;
// File upload animation
const fileUploadVariants = {
initial: { scale: 1 },
hover: {
scale: 1.02,
boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)",
transition: { duration: 0.2 }
},
tap: { scale: 0.98 }
};
interface TAPFormSectionProps {
formData: EventRequestFormData;
onDataChange: (data: Partial<EventRequestFormData>) => void;
}
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
const [roomBookingFiles, setRoomBookingFiles] = useState<File[]>(formData.room_booking_files || []);
const [isDragging, setIsDragging] = useState(false);
const [fileError, setFileError] = useState<string | null>(null);
const [showFilePreview, setShowFilePreview] = useState(false);
const [filePreviewUrl, setFilePreviewUrl] = useState<string | null>(null);
const [selectedPreviewFile, setSelectedPreviewFile] = useState<File | null>(null);
// Add style tag for hidden arrows
useEffect(() => {
const style = document.createElement('style');
style.textContent = hiddenNumberInputArrows;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
// Handle room booking file upload with size limit
const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files) as File[];
// Check file sizes - 1MB limit for each file
const oversizedFiles = newFiles.filter(file => file.size > 1024 * 1024);
if (oversizedFiles.length > 0) {
setFileError(`${oversizedFiles.length} file(s) exceed 1MB limit`);
return;
}
setFileError(null);
// Combine existing files with new files instead of replacing
const combinedFiles = [...roomBookingFiles, ...newFiles];
setRoomBookingFiles(combinedFiles);
onDataChange({ room_booking_files: combinedFiles });
// Create preview URL for the first new file
if (filePreviewUrl) {
URL.revokeObjectURL(filePreviewUrl);
}
const url = URL.createObjectURL(newFiles[0]);
setFilePreviewUrl(url);
setSelectedPreviewFile(newFiles[0]);
}
};
// Handle removing individual files
const handleRemoveFile = (indexToRemove: number) => {
const updatedFiles = roomBookingFiles.filter((_, index) => index !== indexToRemove);
setRoomBookingFiles(updatedFiles);
onDataChange({ room_booking_files: updatedFiles });
// Clear preview if we removed the previewed file
if (selectedPreviewFile && updatedFiles.length === 0) {
if (filePreviewUrl) {
URL.revokeObjectURL(filePreviewUrl);
setFilePreviewUrl(null);
}
setSelectedPreviewFile(null);
}
};
// Handle clearing all files
const handleClearAllFiles = () => {
setRoomBookingFiles([]);
onDataChange({ room_booking_files: [] });
if (filePreviewUrl) {
URL.revokeObjectURL(filePreviewUrl);
setFilePreviewUrl(null);
}
setSelectedPreviewFile(null);
};
// Handle drag events for file upload
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const newFiles = Array.from(e.dataTransfer.files) as File[];
// Check file sizes - 1MB limit for each file
const oversizedFiles = newFiles.filter(file => file.size > 1024 * 1024);
if (oversizedFiles.length > 0) {
setFileError(`${oversizedFiles.length} file(s) exceed 1MB limit`);
return;
}
setFileError(null);
// Combine existing files with new files instead of replacing
const combinedFiles = [...roomBookingFiles, ...newFiles];
setRoomBookingFiles(combinedFiles);
onDataChange({ room_booking_files: combinedFiles });
// Create preview URL for the first new file
if (filePreviewUrl) {
URL.revokeObjectURL(filePreviewUrl);
}
const url = URL.createObjectURL(newFiles[0]);
setFilePreviewUrl(url);
setSelectedPreviewFile(newFiles[0]);
}
};
// Function to toggle file preview
const toggleFilePreview = () => {
setShowFilePreview(!showFilePreview);
};
// Clean up object URL when component unmounts
useEffect(() => {
return () => {
if (filePreviewUrl) {
URL.revokeObjectURL(filePreviewUrl);
}
};
}, [filePreviewUrl]);
return (
<motion.div
className="space-y-8"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={itemVariants}>
<h2 className="text-3xl font-bold mb-4 text-primary bg-gradient-to-r from-primary to-primary-focus bg-clip-text text-transparent">
TAP Form Information
</h2>
</motion.div>
<motion.div variants={itemVariants}>
<CustomAlert
type="info"
title="CRITICAL INFORMATION"
message="Failure to complete ALL sections with accurate information WILL result in event cancellation. This is non-negotiable. If information is not available, contact the event coordinator BEFORE submitting."
className="mb-6"
icon="heroicons:exclamation-triangle"
/>
</motion.div>
{/* Expected attendance */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Expected Attendance</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="relative mt-2">
<motion.input
type="number"
className="input input-bordered focus:input-primary transition-all duration-300 w-full pr-20"
value={formData.expected_attendance || ''}
onChange={(e) => {
// Allow any attendance number, no longer limiting to 500
const attendance = parseInt(e.target.value) || 0;
onDataChange({ expected_attendance: attendance });
}}
min="0"
placeholder="Enter expected attendance"
required
whileHover="hover"
variants={inputHoverVariants}
/>
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-400 bg-base-100 px-2 py-1 rounded">
people
</div>
</div>
{formData.expected_attendance > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-4 bg-success/20 p-3 rounded-lg"
>
<p className="text-sm font-medium">
Budget Calculator: $10 per person × {formData.expected_attendance} people
</p>
<p className="text-base font-bold mt-1">
{formData.expected_attendance * 10 <= 5000 ? (
`You cannot exceed spending past $${formData.expected_attendance * 10} dollars.`
) : (
`You cannot exceed spending past $5,000 dollars.`
)}
</p>
{formData.expected_attendance * 10 > 5000 && (
<p className="text-xs mt-1 text-warning">
Budget cap reached. Maximum budget is $5,000 regardless of attendance.
</p>
)}
</motion.div>
)}
<motion.div
className="mt-4 p-4 bg-base-300/30 rounded-lg text-xs text-gray-400"
initial={{ opacity: 0.8 }}
whileHover={{ opacity: 1, scale: 1.01 }}
>
<ul className="space-y-2 list-disc list-inside">
<li>PROGRAMMING FUNDS EVENTS FUNDED BY PROGRAMMING FUNDS MAY ONLY ADMIT UC SAN DIEGO STUDENTS, STAFF OR FACULTY AS GUESTS.</li>
<li>ONLY UC SAN DIEGO UNDERGRADUATE STUDENTS MAY RECEIVE ITEMS FUNDED BY THE ASSOCIATED STUDENTS.</li>
<li>EVENT FUNDING IS GRANTED UP TO A MAXIMUM OF $10.00 PER EXPECTED STUDENT ATTENDEE AND $5,000 PER EVENT</li>
</ul>
</motion.div>
</motion.div>
{/* Room booking confirmation - Show file error if present */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Room Booking Confirmation</span>
{formData.will_or_have_room_booking && <span className="label-text-alt text-error">*</span>}
</label>
{formData.will_or_have_room_booking && (
<p className="text-sm text-gray-500 mb-3">
<strong>Required:</strong> Upload your room booking confirmation document.
</p>
)}
{fileError && (
<div className="mt-2 mb-2">
<CustomAlert
type="error"
title="File Error"
message={fileError}
/>
</div>
)}
{formData.will_or_have_room_booking ? (
<motion.div
className={`mt-2 border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${isDragging ? 'border-primary bg-primary/5' : 'border-base-300 hover:border-primary/50'
}`}
variants={fileUploadVariants}
initial="initial"
whileHover="hover"
whileTap="tap"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => document.getElementById('room-booking-file')?.click()}
>
<input
id="room-booking-file"
type="file"
className="hidden"
onChange={handleRoomBookingFileChange}
accept=".pdf,.png,.jpg,.jpeg"
multiple
/>
<div className="flex flex-col items-center justify-center gap-3">
<motion.div
className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary"
whileHover={{ rotate: 15, scale: 1.1 }}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</motion.div>
{roomBookingFiles.length > 0 ? (
<>
<div className="flex items-center justify-between w-full">
<p className="font-medium text-primary">{roomBookingFiles.length} file(s) selected:</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearAllFiles();
}}
className="btn btn-xs btn-outline btn-error"
title="Clear all files"
>
Clear All
</button>
</div>
<div className="max-h-32 overflow-y-auto text-left w-full space-y-1">
{roomBookingFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
<span className="text-sm truncate flex-1">{file.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveFile(index);
}}
className="btn btn-xs btn-error ml-2"
title="Remove file"
>
<Icon icon="heroicons:x-mark" className="w-3 h-3" />
</button>
</div>
))}
</div>
<p className="text-xs text-gray-500">Click or drag to add more files (Max size: 1MB each)</p>
</>
) : (
<>
<p className="font-medium">Drop your files here or click to browse</p>
<p className="text-xs text-gray-500">Accepted formats: PDF, PNG, JPG (Max size: 1MB each, multiple files allowed)</p>
</>
)}
</div>
</motion.div>
) : (
<motion.div
className="mt-2 bg-base-300/30 rounded-lg p-4 text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<p className="text-sm text-base-content/70">Room booking upload not required when no booking is needed.</p>
</motion.div>
)}
{/* Preview File Button - Outside the upload area */}
{formData.will_or_have_room_booking && roomBookingFiles.length > 0 && (
<div className="mt-3 flex justify-end">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={toggleFilePreview}
>
{showFilePreview ? 'Hide Preview' : `Preview Files (${roomBookingFiles.length})`}
</button>
</div>
)}
{/* File Preview Component */}
{showFilePreview && roomBookingFiles.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="mt-4 p-4 bg-base-200 rounded-lg"
>
<div className="flex justify-between items-center mb-4">
<h3 className="font-medium">File Preview ({roomBookingFiles.length} files)</h3>
<button
type="button"
className="btn btn-sm btn-circle"
onClick={toggleFilePreview}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{roomBookingFiles.map((file, index) => {
const url = URL.createObjectURL(file);
return (
<div key={index} className="border rounded-lg p-2">
<p className="text-sm font-medium mb-2 truncate">{file.name}</p>
<FilePreview url={url} filename={file.name} />
</div>
);
})}
</div>
</motion.div>
)}
</motion.div>
{/* Food/Drinks */}
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
>
<label className="label">
<span className="label-text font-medium text-lg">Food & Drinks</span>
<span className="label-text-alt text-error">*</span>
</label>
<p className="text-sm text-gray-500 mb-3">Will you be serving food or drinks at your event?</p>
<div className="flex gap-6 mt-2">
<motion.label
className={`flex items-center gap-3 cursor-pointer p-4 rounded-lg transition-colors ${formData.food_drinks_being_served === true
? 'bg-primary/20 border border-primary/50'
: 'bg-base-100 hover:bg-primary/10'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<input
type="radio"
className="radio radio-primary"
checked={formData.food_drinks_being_served === true}
onChange={() => onDataChange({ food_drinks_being_served: true })}
required
/>
<span className="font-medium">Yes, we'll have food/drinks</span>
</motion.label>
<motion.label
className={`flex items-center gap-3 cursor-pointer p-4 rounded-lg transition-colors ${formData.food_drinks_being_served === false
? 'bg-primary/20 border border-primary/50'
: 'bg-base-100 hover:bg-primary/10'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<input
type="radio"
className="radio radio-primary"
checked={formData.food_drinks_being_served === false}
onChange={() => onDataChange({ food_drinks_being_served: false })}
required
/>
<span className="font-medium">No, no food/drinks</span>
</motion.label>
</div>
</motion.div>
{/* AS Funding Question - only show if food/drinks are being served */}
{formData.food_drinks_being_served && (
<motion.div
variants={itemVariants}
className="form-control bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
whileHover={{ y: -2 }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<label className="label">
<span className="label-text font-medium text-lg">AS Funding Request</span>
<span className="label-text-alt text-error">*</span>
</label>
<p className="text-sm text-gray-500 mb-3">Do you need funding from AS?</p>
<div className="flex gap-6 mt-2">
<motion.label
className={`flex items-center gap-3 cursor-pointer p-4 rounded-lg transition-colors ${formData.needs_as_funding === true
? 'bg-primary/20 border border-primary/50'
: 'bg-base-100 hover:bg-primary/10'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<input
type="radio"
className="radio radio-primary"
checked={formData.needs_as_funding === true}
onChange={() => onDataChange({
needs_as_funding: true,
as_funding_required: true
})}
required
/>
<span className="font-medium">Yes, we need AS funding</span>
</motion.label>
<motion.label
className={`flex items-center gap-3 cursor-pointer p-4 rounded-lg transition-colors ${formData.needs_as_funding === false
? 'bg-primary/20 border border-primary/50'
: 'bg-base-100 hover:bg-primary/10'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<input
type="radio"
className="radio radio-primary"
checked={formData.needs_as_funding === false}
onChange={() => onDataChange({
needs_as_funding: false,
as_funding_required: false
})}
required
/>
<span className="font-medium">No, we don't need funding</span>
</motion.label>
</div>
</motion.div>
)}
{/* Single information alert container that changes content based on selection */}
{formData.food_drinks_being_served && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="mb-4"
>
<CustomAlert
type="info"
title={formData.needs_as_funding ? "AS Funding Information" : "Food and Drinks Information"}
message={formData.needs_as_funding
? "In the next step, you'll be asked to provide vendor information and invoice details for your AS funding request."
: "Please make sure to follow all campus policies regarding food and drinks at your event."}
className="mb-4"
icon={formData.needs_as_funding ? "heroicons:currency-dollar" : "heroicons:cake"}
/>
</motion.div>
)}
</motion.div>
);
};
export default TAPFormSection;

View file

@ -1,684 +0,0 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase';
import { EventRequestFormPreview } from './EventRequestFormPreview';
import type { EventRequestFormData } from './EventRequestForm';
// Declare the global window interface to include our custom function
declare global {
interface Window {
showEventRequestFormPreview?: (formData: any) => void;
}
}
// Extended EventRequest interface with additional properties needed for this component
export interface EventRequest extends SchemaEventRequest {
invoice_data?: any;
}
// Helper function to convert EventRequest to EventRequestFormData
const convertToFormData = (request: EventRequest): EventRequestFormData => {
try {
// Parse itemized_invoice if it's a string
let invoiceData = {};
try {
if (request.itemized_invoice) {
if (typeof request.itemized_invoice === 'string') {
const parsedInvoice = JSON.parse(request.itemized_invoice) as Record<string, any>;
// Get or calculate subtotal from items
const subtotal = parsedInvoice.subtotal ??
(Array.isArray(parsedInvoice.items) ?
parsedInvoice.items.reduce((sum: number, item: any) => {
const amount = typeof item.amount === 'number' ? item.amount : 0;
return sum + amount;
}, 0) : 0);
// Normalize tax and tip amounts
const taxAmount = Number(parsedInvoice.taxAmount ?? parsedInvoice.tax ?? 0);
const tipAmount = Number(parsedInvoice.tipAmount ?? parsedInvoice.tip ?? 0);
// Calculate or get total
const total = parsedInvoice.total ?? (subtotal + taxAmount + tipAmount);
invoiceData = {
...parsedInvoice,
subtotal,
taxAmount,
tipAmount,
total
};
} else {
const parsedInvoice = request.itemized_invoice as Record<string, any>;
// Get or calculate subtotal from items
const subtotal = parsedInvoice.subtotal ??
(Array.isArray(parsedInvoice.items) ?
parsedInvoice.items.reduce((sum: number, item: any) => {
const amount = typeof item.amount === 'number' ? item.amount : 0;
return sum + amount;
}, 0) : 0);
// Normalize tax and tip amounts
const taxAmount = Number(parsedInvoice.taxAmount ?? parsedInvoice.tax ?? 0);
const tipAmount = Number(parsedInvoice.tipAmount ?? parsedInvoice.tip ?? 0);
// Calculate or get total
const total = parsedInvoice.total ?? (subtotal + taxAmount + tipAmount);
invoiceData = {
...parsedInvoice,
subtotal,
taxAmount,
tipAmount,
total
};
}
} else if (request.invoice_data) {
const parsedInvoice = request.invoice_data as Record<string, any>;
// Get or calculate subtotal from items
const subtotal = parsedInvoice.subtotal ??
(Array.isArray(parsedInvoice.items) ?
parsedInvoice.items.reduce((sum: number, item: any) => {
const amount = typeof item.amount === 'number' ? item.amount : 0;
return sum + amount;
}, 0) : 0);
// Normalize tax and tip amounts
const taxAmount = Number(parsedInvoice.taxAmount ?? parsedInvoice.tax ?? 0);
const tipAmount = Number(parsedInvoice.tipAmount ?? parsedInvoice.tip ?? 0);
// Calculate or get total
const total = parsedInvoice.total ?? (subtotal + taxAmount + tipAmount);
invoiceData = {
...parsedInvoice,
subtotal,
taxAmount,
tipAmount,
total
};
}
} catch (e) {
console.error('Error parsing invoice data:', e);
}
// Cast to unknown first, then to EventRequestFormData to avoid type checking
return {
name: request.name,
location: request.location,
start_date_time: request.start_date_time,
end_date_time: request.end_date_time,
event_description: request.event_description || '',
flyers_needed: request.flyers_needed || false,
photography_needed: request.photography_needed || false,
flyer_type: request.flyer_type || [],
other_flyer_type: request.other_flyer_type || '',
flyer_advertising_start_date: request.flyer_advertising_start_date || '',
advertising_format: request.advertising_format || '',
required_logos: request.required_logos || [],
other_logos: [] as File[], // EventRequest doesn't have this as files
flyer_additional_requests: request.flyer_additional_requests || '',
will_or_have_room_booking: request.will_or_have_room_booking || false,
room_booking: null,
room_booking_confirmation: [] as File[], // EventRequest doesn't have this as files
expected_attendance: request.expected_attendance || 0,
food_drinks_being_served: request.food_drinks_being_served || false,
needs_as_funding: request.as_funding_required || false,
as_funding_required: request.as_funding_required || false,
invoice: null,
invoice_files: [],
invoiceData: invoiceData,
needs_graphics: request.flyers_needed || false,
status: request.status || '',
created_by: request.requested_user || '',
id: request.id || '',
created: request.created || '',
updated: request.updated || '',
itemized_invoice: request.itemized_invoice || '',
} as unknown as EventRequestFormData;
} catch (error) {
console.error("Error converting EventRequest to EventRequestFormData:", error);
// Return a minimal valid object to prevent rendering errors
return {
name: request?.name || "Unknown Event",
location: request?.location || "",
start_date_time: request?.start_date_time || new Date().toISOString(),
end_date_time: request?.end_date_time || new Date().toISOString(),
event_description: request?.event_description || "",
flyers_needed: false,
photography_needed: false,
flyer_type: [],
other_flyer_type: "",
flyer_advertising_start_date: "",
advertising_format: "",
required_logos: [],
other_logos: [] as File[],
flyer_additional_requests: "",
will_or_have_room_booking: false,
room_booking: null,
room_booking_confirmation: [] as File[],
expected_attendance: 0,
food_drinks_being_served: false,
needs_as_funding: false,
as_funding_required: false,
invoice: null,
invoice_files: [],
invoiceData: {},
needs_graphics: false,
status: request?.status || "",
created_by: "",
id: request?.id || "",
created: request?.created || "",
updated: request?.updated || "",
itemized_invoice: ""
} as unknown as EventRequestFormData;
}
};
interface UserEventRequestsProps {
eventRequests: EventRequest[];
}
// Create a portal component for the modal to ensure it's rendered at the root level
const EventRequestModal: React.FC<{ isOpen: boolean, onClose: () => void, children: React.ReactNode }> = ({
isOpen,
onClose,
children
}) => {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[99999]"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100vw',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
margin: 0,
overflow: 'auto'
}}
onClick={onClose}
>
<div
className="bg-base-100 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"
style={{
position: 'relative',
margin: 'auto',
zIndex: 100000
}}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
};
// Add a utility function to truncate text with an ellipsis
const truncateText = (text: string, maxLength: number) => {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
};
const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: initialEventRequests }) => {
const [eventRequests, setEventRequests] = useState<EventRequest[]>(initialEventRequests);
const [selectedRequest, setSelectedRequest] = useState<EventRequest | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
const dataSync = DataSyncService.getInstance();
// Refresh event requests
const refreshEventRequests = async () => {
setIsRefreshing(true);
try {
const auth = Authentication.getInstance();
if (!auth.isAuthenticated()) {
return;
}
const userId = auth.getUserId();
if (!userId) {
return;
}
// Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
const updatedRequests = await dataSync.getData<EventRequest>(
Collections.EVENT_REQUESTS,
true, // Force sync
`requested_user="${userId}"`,
'-created',
{}, // expand
true // Enable deletion detection for user-specific requests
);
setEventRequests(updatedRequests);
} catch (err) {
console.error('Failed to refresh event requests:', err);
} finally {
setIsRefreshing(false);
}
};
// Auto refresh on component mount
useEffect(() => {
refreshEventRequests();
}, []);
// Listen for tab visibility changes and refresh data when tab becomes visible
useEffect(() => {
const handleTabVisible = () => {
// console.log("Tab became visible, refreshing event requests...");
refreshEventRequests();
};
// Add event listener for custom dashboardTabVisible event
document.addEventListener("dashboardTabVisible", handleTabVisible);
// Clean up event listener on component unmount
return () => {
document.removeEventListener("dashboardTabVisible", handleTabVisible);
};
}, []);
// Format date for display
const formatDate = (dateString: string) => {
if (!dateString) return 'Not specified';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
};
// Get status badge class based on status
const getStatusBadge = (status?: string) => {
if (!status) return 'badge-warning';
switch (status.toLowerCase()) {
case 'approved':
case 'completed':
return 'badge-success text-white';
case 'rejected':
case 'declined':
return 'badge-error text-white';
case 'pending':
return 'badge-warning text-black';
case 'submitted':
return 'badge-info text-white';
default:
return 'badge-warning text-black';
}
};
// Get card border class based on status
const getCardBorderClass = (status?: string) => {
if (!status) return 'border-l-warning';
switch (status.toLowerCase()) {
case 'approved':
case 'completed':
return 'border-l-success';
case 'rejected':
case 'declined':
return 'border-l-error';
case 'pending':
return 'border-l-warning';
case 'submitted':
return 'border-l-info';
default:
return 'border-l-warning';
}
};
// Open modal with event request details
const openDetailModal = (request: EventRequest) => {
setSelectedRequest(request);
setIsModalOpen(true);
};
// Close modal
const closeModal = () => {
setIsModalOpen(false);
setSelectedRequest(null);
};
if (eventRequests.length === 0) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-base-200 rounded-xl p-8 text-center shadow-sm"
>
<div className="flex flex-col items-center justify-center py-6">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 className="text-xl font-semibold mb-3">No Event Requests Found</h3>
<p className="text-base-content/60 mb-6 max-w-md">You haven't submitted any event requests yet. Use the form above to submit a new event request.</p>
<button
className="btn btn-outline btn-sm gap-2"
onClick={refreshEventRequests}
disabled={isRefreshing}
>
{isRefreshing ? (
<>
<span className="loading loading-spinner loading-xs"></span>
Refreshing...
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</>
)}
</button>
</div>
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
<h3 className="text-lg font-semibold">Your Submissions</h3>
<div className="flex items-center gap-3">
<div className="join">
<button
className={`join-item btn btn-sm ${viewMode === 'table' ? 'btn-active' : ''}`}
onClick={() => setViewMode('table')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
<button
className={`join-item btn btn-sm ${viewMode === 'cards' ? 'btn-active' : ''}`}
onClick={() => setViewMode('cards')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
</div>
<button
className="btn btn-outline btn-sm gap-2"
onClick={refreshEventRequests}
disabled={isRefreshing}
>
{isRefreshing ? (
<>
<span className="loading loading-spinner loading-xs"></span>
Refreshing...
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</>
)}
</button>
</div>
</div>
{viewMode === 'table' ? (
<div className="overflow-x-auto overflow-y-auto rounded-xl shadow-sm max-h-[70vh]">
<table className="table table-zebra w-full text-xs">
<thead className="bg-base-300/50 sticky top-0 z-10">
<tr>
<th className="w-[17%]">Event Name</th>
<th className="w-[16%]">Date</th>
<th className="w-[14%]">Location</th>
<th className="w-[7%] text-center">PR</th>
<th className="w-[7%] text-center">AS</th>
<th className="w-[15%]">Submitted</th>
<th className="w-[14%] text-center">Status</th>
<th className="w-[10%] px-0 text-center">View</th>
</tr>
</thead>
<tbody>
{eventRequests.map((request) => (
<tr key={request.id} className={`hover border-l-4 ${getCardBorderClass(request.status)}`}>
<td className="font-medium">
<div className="tooltip" data-tip={request.name}>
<span className="block truncate max-w-[125px]">
{truncateText(request.name, 18)}
</span>
</div>
</td>
<td>
<div className="tooltip" data-tip={formatDate(request.start_date_time)}>
<span className="block truncate max-w-[110px]">
{formatDate(request.start_date_time)}
</span>
</div>
</td>
<td>
<div className="tooltip" data-tip={request.location}>
<span className="block truncate max-w-[90px]">
{truncateText(request.location, 14)}
</span>
</div>
</td>
<td className="text-center">
{request.flyers_needed ? (
<span className="badge badge-success badge-sm">Yes</span>
) : (
<span className="badge badge-ghost badge-sm">No</span>
)}
</td>
<td className="text-center">
{request.as_funding_required ? (
<span className="badge badge-success badge-sm">Yes</span>
) : (
<span className="badge badge-ghost badge-sm">No</span>
)}
</td>
<td>
<div className="tooltip" data-tip={formatDate(request.created)}>
<span className="block truncate max-w-[100px]">
{formatDate(request.created)}
</span>
</div>
</td>
<td className="text-center">
<span className={`badge ${getStatusBadge(request.status)} badge-sm`}>
{request.status || 'Submitted'}
</span>
</td>
<td className="text-center px-0">
<div className="flex justify-center">
<button
className="btn btn-xs btn-ghost btn-circle tooltip flex items-center justify-center p-0"
data-tip="View Details"
onClick={(e) => {
e.stopPropagation();
openDetailModal(request);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{eventRequests.map((request) => (
<motion.div
key={request.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className={`card bg-base-200 shadow-sm hover:shadow-md transition-shadow border-l-4 ${getCardBorderClass(request.status)}`}
>
<div className="card-body p-5">
<div className="flex justify-between items-start">
<h3 className="card-title text-base tooltip" data-tip={request.name}>
<span className="block truncate max-w-[180px]">
{truncateText(request.name, 25)}
</span>
</h3>
<span className={`badge ${getStatusBadge(request.status)}`}>
{request.status || 'Pending'}
</span>
</div>
<div className="space-y-2 mt-2 text-sm">
<div className="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="truncate">{formatDate(request.start_date_time)}</span>
</div>
<div className="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="truncate tooltip" data-tip={request.location}>
{truncateText(request.location, 25)}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-3">
{request.flyers_needed && (
<span className="badge badge-outline badge-sm">PR Materials</span>
)}
{request.as_funding_required && (
<span className="badge badge-outline badge-sm">AS Funding</span>
)}
{request.photography_needed && (
<span className="badge badge-outline badge-sm">Photography</span>
)}
</div>
<div className="card-actions justify-end mt-4">
<button
className="btn btn-sm btn-circle btn-ghost text-primary tooltip flex items-center justify-center"
data-tip="View Details"
onClick={(e) => {
e.stopPropagation();
openDetailModal(request);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</div>
</div>
</motion.div>
))}
</div>
)}
<div className="bg-base-300/30 p-5 rounded-xl text-sm shadow-sm">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
About Your Submissions
</h3>
<ul className="space-y-2 ml-2">
<li className="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Event requests are typically reviewed within 1-2 business days.
</li>
<li className="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
You'll receive email notifications when your request status changes.
</li>
<li className="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-base-content/60 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
For urgent inquiries, please contact the PR team or coordinators in the #-events Slack channel.
</li>
</ul>
</div>
{/* Use the new portal component for the modal */}
{isModalOpen && selectedRequest && (
<EventRequestModal
isOpen={isModalOpen}
onClose={closeModal}
>
<div className="sticky top-0 z-10 bg-base-100 px-6 py-4 border-b border-base-300 flex justify-between items-center">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-base-content">{selectedRequest.name}</h2>
<span className={`badge ${getStatusBadge(selectedRequest.status)}`}>
{selectedRequest.status || 'Pending'}
</span>
</div>
<div className="flex items-center gap-2">
<button
className="btn btn-sm btn-circle"
onClick={closeModal}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="p-6">
{selectedRequest ? (
<EventRequestFormPreview
formData={convertToFormData(selectedRequest)}
isModal={true}
/>
) : (
<div className="flex items-center justify-center h-64">
<div className="loading loading-spinner loading-lg text-primary"></div>
</div>
)}
</div>
</EventRequestModal>
)}
</motion.div>
);
};
export default UserEventRequests;

View file

@ -1,329 +0,0 @@
---
import { Authentication } from "../../scripts/pocketbase/Authentication";
import { Get } from "../../scripts/pocketbase/Get";
import { toast } from "react-hot-toast";
import EventRequestManagementTable from "./Officer_EventRequestManagement/EventRequestManagementTable";
import EventRequestModal from "./Officer_EventRequestManagement/EventRequestModal";
import type { EventRequest } from "../../schemas/pocketbase/schema";
import { Collections } from "../../schemas/pocketbase/schema";
import { Icon } from "astro-icon/components";
import CustomAlert from "./universal/CustomAlert";
// Get instances
const get = Get.getInstance();
const auth = Authentication.getInstance();
// Extended EventRequest interface with additional properties needed for this component
interface ExtendedEventRequest extends EventRequest {
requested_user_expand?: {
name: string;
email: string;
};
expand?: {
requested_user?: {
id: string;
name: string;
email: string;
emailVisibility?: boolean; // Add this field to the interface
[key: string]: any;
};
[key: string]: any;
};
[key: string]: any; // For other optional properties
}
// Animation delay constants to ensure consistency with React components
const ANIMATION_DELAYS = {
heading: "0.1s",
info: "0.2s",
content: "0.3s",
};
// Initialize variables for all event requests
let allEventRequests: ExtendedEventRequest[] = [];
let error = null;
try {
// Don't check authentication here - let the client component handle it
// The server-side check is causing issues when the token is valid client-side but not server-side
allEventRequests = await get
.getAll<ExtendedEventRequest>(
Collections.EVENT_REQUESTS,
"",
"-created",
{
expand: "requested_user",
}
)
.catch((err) => {
console.error("Error in get.getAll:", err);
// Return empty array instead of throwing
return [];
});
// Process the event requests to add the requested_user_expand property
allEventRequests = allEventRequests.map((request) => {
const requestWithExpand = { ...request };
// Add the requested_user_expand property if the expand data is available
if (
request.expand?.requested_user &&
request.expand.requested_user.name
) {
// Always include email regardless of emailVisibility setting
requestWithExpand.requested_user_expand = {
name: request.expand.requested_user.name,
email:
request.expand.requested_user.email ||
"(No email available)",
};
// Force emailVisibility to true in the expand data
if (requestWithExpand.expand?.requested_user) {
requestWithExpand.expand.requested_user.emailVisibility = true;
}
}
return requestWithExpand;
});
} catch (err) {
console.error("Error fetching event requests:", err);
error = err;
}
---
<div class="w-full max-w-7xl mx-auto py-8 px-4 sm:px-6">
<style>
.event-table-container {
min-height: 600px;
height: auto !important;
max-height: none !important;
}
.event-table-container table {
height: auto !important;
}
.event-table-container .overflow-x-auto {
max-height: none !important;
}
/* Modern table styles */
.modern-table thead th {
position: sticky;
top: 0;
z-index: 10;
font-weight: 600;
letter-spacing: 0.02em;
}
.modern-table tbody tr {
transition: all 0.2s ease;
}
/* Badge styles */
.animated-badge {
opacity: 0;
animation: fadeIn 0.3s ease forwards;
animation-delay: calc(var(--badge-index) * 0.05s);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Card styles */
.dashboard-card {
border-radius: 1rem;
transition:
transform 0.2s,
box-shadow 0.2s;
}
.dashboard-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
}
/* Animation for card entrance */
.card-enter {
animation: cardEnter 0.5s ease forwards;
opacity: 0;
}
@keyframes cardEnter {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
<div
class="mb-8 space-y-4 card-enter"
style={`animation-delay: ${ANIMATION_DELAYS.heading};`}
>
<div class="flex items-center gap-3">
<div class="rounded-full bg-primary/10 p-2">
<Icon name="mdi:calendar-clock" class="w-6 h-6 text-primary" />
</div>
<h1 class="text-3xl font-bold text-white">
Event Request Management
</h1>
</div>
<p class="text-gray-300 text-lg max-w-3xl">
Review and manage event requests submitted by officers. Update
status and coordinate with the team.
</p>
<div
class="bg-gradient-to-br from-base-300/50 to-base-300/30 p-5 rounded-xl border border-base-300/50 shadow-inner text-sm text-gray-300 card-enter"
style={`animation-delay: ${ANIMATION_DELAYS.info};`}
>
<div class="flex items-start gap-3">
<Icon
name="mdi:lightbulb-on"
class="w-5 h-5 text-primary mt-1 flex-shrink-0"
/>
<div>
<p class="font-medium mb-2 text-white">
As an executive officer, you can:
</p>
<ul class="grid grid-cols-1 sm:grid-cols-2 gap-2 ml-1">
<li class="flex items-center gap-2">
<Icon
name="mdi:check-circle"
class="h-4 w-4 text-success"
/>
<span>View all submitted event requests</span>
</li>
<li class="flex items-center gap-2">
<Icon
name="mdi:check-circle"
class="h-4 w-4 text-success"
/>
<span>Update request statuses</span>
</li>
<li class="flex items-center gap-2">
<Icon
name="mdi:check-circle"
class="h-4 w-4 text-success"
/>
<span>Filter requests by criteria</span>
</li>
<li class="flex items-center gap-2">
<Icon
name="mdi:check-circle"
class="h-4 w-4 text-success"
/>
<span>Sort requests by various fields</span>
</li>
</ul>
</div>
</div>
</div>
</div>
{
error && (
<div
class="mb-6 card-enter"
style={`animation-delay: ${ANIMATION_DELAYS.content};`}
>
<CustomAlert
client:load
type="error"
title="Error fetching event requests"
message={error.toString()}
icon="heroicons:exclamation-triangle"
/>
</div>
)
}
<!-- Main page content including table and modal -->
<EventRequestModal client:load eventRequests={allEventRequests} />
</div>
<script type="module" define:vars={{ ANIMATION_DELAYS }}>
// Import the DataSyncService for client-side use
import { DataSyncService } from "../../scripts/database/DataSyncService";
import { Collections } from "../../schemas/pocketbase/schema";
// Use a more efficient approach to refresh data only when needed
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
// Dispatch a custom event that components can listen for
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
}
});
// Also force refresh when this tab is clicked in dashboard
document.addEventListener("DOMContentLoaded", () => {
// Find dashboard tab for event request management
const eventRequestManagementTab = document.querySelector(
'[data-section="eventRequestManagement"]'
);
if (eventRequestManagementTab) {
eventRequestManagementTab.addEventListener("click", () => {
// Dispatch custom event to refresh data when this tab is clicked
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
});
}
});
// Handle authentication errors and initial data loading
document.addEventListener("DOMContentLoaded", async () => {
// Initialize DataSyncService for client-side
const dataSync = DataSyncService.getInstance();
// Add subtle entrance animations to cards
const cards = document.querySelectorAll(".card-enter");
cards.forEach((card, index) => {
// Use the same animation delay calculation logic consistently
const delay =
index === 0
? ANIMATION_DELAYS.heading
: index === 1
? ANIMATION_DELAYS.info
: ANIMATION_DELAYS.content;
card.setAttribute("style", `animation-delay: ${delay}`);
});
// Prefetch data into IndexedDB
try {
await dataSync.syncCollection(
Collections.EVENT_REQUESTS,
"",
"-created",
"requested_user"
);
} catch (err) {
console.error("Error during initial data sync:", err);
}
// Check for error message in the UI
const errorElement = document.querySelector(".alert-error span");
if (
errorElement &&
errorElement.textContent?.includes("Authentication error")
) {
// Redirect to login page after a short delay
setTimeout(() => {
window.location.href = "/login";
}, 3000);
}
});
</script>

View file

@ -1,892 +0,0 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import toast from 'react-hot-toast';
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
import { Collections } from '../../../schemas/pocketbase/schema';
// Extended EventRequest interface with additional properties needed for this component
interface ExtendedEventRequest extends SchemaEventRequest {
requested_user_expand?: {
name: string;
email: string;
};
expand?: {
requested_user?: {
id: string;
name: string;
email: string;
[key: string]: any;
};
[key: string]: any;
};
invoice_data?: any;
invoice_files?: string[]; // Array of invoice file IDs
status: "submitted" | "pending" | "completed" | "declined";
declined_reason?: string; // Reason for declining the event request
flyers_completed?: boolean; // Track if flyers have been completed by PR team
}
interface EventRequestManagementTableProps {
eventRequests: ExtendedEventRequest[];
onRequestSelect: (request: ExtendedEventRequest) => void;
onStatusChange: (id: string, status: "submitted" | "pending" | "completed" | "declined") => Promise<void>;
isLoadingUserData?: boolean;
}
const EventRequestManagementTable = ({
eventRequests: initialEventRequests,
onRequestSelect,
onStatusChange,
isLoadingUserData = false
}: EventRequestManagementTableProps) => {
const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [statusFilter, setStatusFilter] = useState<string>('active');
const [searchTerm, setSearchTerm] = useState<string>('');
const [sortField, setSortField] = useState<string>('start_date_time');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const dataSync = DataSyncService.getInstance();
// Add state for update modal
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState<boolean>(false);
const [requestToUpdate, setRequestToUpdate] = useState<ExtendedEventRequest | null>(null);
// Add state for decline reason modal
const [isDeclineModalOpen, setIsDeclineModalOpen] = useState<boolean>(false);
const [declineReason, setDeclineReason] = useState<string>('');
const [requestToDecline, setRequestToDecline] = useState<ExtendedEventRequest | null>(null);
// Refresh event requests
const refreshEventRequests = async () => {
setIsRefreshing(true);
try {
const auth = Authentication.getInstance();
// Don't check authentication here - try to fetch anyway
// The token might be valid for the API even if isAuthenticated() returns false
// console.log("Fetching event requests...");
// Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
Collections.EVENT_REQUESTS,
true, // Force sync
'', // No filter - get all requests
'-created',
'requested_user', // Expand user data
true // Enable deletion detection for all event requests
);
// If we still have "Unknown" users, try to fetch them directly
const requestsWithUsers = await Promise.all(
updatedRequests.map(async (request) => {
// If user data is missing, try to fetch it directly
if (!request.expand?.requested_user && request.requested_user) {
try {
const userData = await dataSync.getItem(
Collections.USERS,
request.requested_user,
true // Force sync the user data
);
if (userData) {
// TypeScript cast to access the properties
const typedUserData = userData as unknown as {
id: string;
name?: string;
email?: string;
};
// Update the expand object with the user data
return {
...request,
expand: {
...(request.expand || {}),
requested_user: {
id: typedUserData.id,
name: typedUserData.name || 'Unknown',
email: typedUserData.email || 'Unknown'
}
}
} as ExtendedEventRequest;
}
} catch (error) {
console.error(`Error fetching user data for request ${request.id}:`, error);
}
}
return request;
})
) as ExtendedEventRequest[];
// console.log(`Fetched ${updatedRequests.length} event requests`);
setEventRequests(requestsWithUsers);
applyFilters(requestsWithUsers);
} catch (error) {
// console.error('Error refreshing event requests:', error);
toast.error('Failed to refresh event requests');
} finally {
setIsRefreshing(false);
}
};
// Apply filters and sorting
const applyFilters = (requests = eventRequests) => {
let filtered = [...requests];
// Apply status filter
if (statusFilter !== 'all') {
if (statusFilter === 'active') {
// Filter to show only submitted and pending events (hide completed and declined)
filtered = filtered.filter(request => {
const status = request.status?.toLowerCase();
return status === 'submitted' || status === 'pending' || !status; // Include requests without status (assume pending)
});
} else {
// For specific status filters, treat empty status as 'pending'
filtered = filtered.filter(request => {
const status = request.status?.toLowerCase() || 'pending'; // Default empty status to 'pending'
return status === statusFilter.toLowerCase();
});
}
}
// Apply search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(request =>
request.name.toLowerCase().includes(term) ||
request.location.toLowerCase().includes(term) ||
request.event_description.toLowerCase().includes(term) ||
request.expand?.requested_user?.name?.toLowerCase().includes(term) ||
request.expand?.requested_user?.email?.toLowerCase().includes(term)
);
}
// Apply sorting
filtered.sort((a, b) => {
let aValue: any = a[sortField as keyof ExtendedEventRequest];
let bValue: any = b[sortField as keyof ExtendedEventRequest];
// Handle special cases
if (sortField === 'requested_user') {
aValue = a.expand?.requested_user?.name || '';
bValue = b.expand?.requested_user?.name || '';
}
// Handle date fields
if (sortField === 'created' || sortField === 'updated' ||
sortField === 'start_date_time' || sortField === 'end_date_time') {
aValue = new Date(aValue || '').getTime();
bValue = new Date(bValue || '').getTime();
}
// Compare values based on sort direction
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
setFilteredRequests(filtered);
};
// Update event request status
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined", declineReason?: string): Promise<void> => {
try {
// Find the event request to get its current status and name
const eventRequest = eventRequests.find(req => req.id === id);
const eventName = eventRequest?.name || 'Event';
const previousStatus = eventRequest?.status;
// If declining, update with decline reason
if (status === 'declined' && declineReason) {
const { Update } = await import('../../../scripts/pocketbase/Update');
const update = Update.getInstance();
await update.updateFields("event_request", id, {
status: status,
declined_reason: declineReason
});
} else {
await onStatusChange(id, status);
}
// Update local state
setEventRequests(prev =>
prev.map(request =>
request.id === id ? {
...request,
status,
...(status === 'declined' && declineReason ? { declined_reason: declineReason } : {})
} : request
)
);
setFilteredRequests(prev =>
prev.map(request =>
request.id === id ? {
...request,
status,
...(status === 'declined' && declineReason ? { declined_reason: declineReason } : {})
} : request
)
);
toast.success(`"${eventName}" status updated to ${status}`);
// Send email notification for status change
try {
const { EmailClient } = await import('../../../scripts/email/EmailClient');
const auth = Authentication.getInstance();
const changedByUserId = auth.getUserId();
if (previousStatus && previousStatus !== status) {
await EmailClient.notifyEventRequestStatusChange(
id,
previousStatus,
status,
changedByUserId || undefined,
status === 'declined' ? declineReason : undefined
);
console.log('Event request status change notification email sent successfully');
}
// Send design team notifications for PR-related actions
if (eventRequest?.flyers_needed) {
if (status === 'declined') {
await EmailClient.notifyDesignTeam(id, 'declined');
console.log('Design team notified of declined PR request');
}
}
} catch (emailError) {
console.error('Failed to send event request status change notification email:', emailError);
// Don't show error to user - email failure shouldn't disrupt the main operation
}
} catch (error) {
console.error('Error updating status:', error);
toast.error('Failed to update status');
}
};
// Update PR status (flyers_completed)
const updatePRStatus = async (id: string, completed: boolean): Promise<void> => {
try {
const { Update } = await import('../../../scripts/pocketbase/Update');
const update = Update.getInstance();
await update.updateField("event_request", id, "flyers_completed", completed);
// Find the event request to get its details
const eventRequest = eventRequests.find(req => req.id === id);
const eventName = eventRequest?.name || 'Event';
// Update local state
setEventRequests(prev =>
prev.map(request =>
request.id === id ? { ...request, flyers_completed: completed } : request
)
);
setFilteredRequests(prev =>
prev.map(request =>
request.id === id ? { ...request, flyers_completed: completed } : request
)
);
toast.success(`"${eventName}" PR status updated to ${completed ? 'completed' : 'pending'}`);
// Send email notification if PR is completed
if (completed) {
try {
const { EmailClient } = await import('../../../scripts/email/EmailClient');
await EmailClient.notifyPRCompleted(id);
console.log('PR completion notification email sent successfully');
} catch (emailError) {
console.error('Failed to send PR completion notification email:', emailError);
// Don't show error to user - email failure shouldn't disrupt the main operation
}
}
} catch (error) {
console.error('Error updating PR status:', error);
toast.error('Failed to update PR status');
}
};
// Format date for display
const formatDate = (dateString: string) => {
if (!dateString) return 'Not specified';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
};
// Format date and time range for display
const formatDateTimeRange = (startDateString: string, endDateString: string) => {
if (!startDateString) return 'Not specified';
try {
const startDate = new Date(startDateString);
const endDate = endDateString ? new Date(endDateString) : null;
const startFormatted = startDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
if (endDate && endDate.getTime() !== startDate.getTime()) {
// Check if it's the same day
const isSameDay = startDate.toDateString() === endDate.toDateString();
if (isSameDay) {
// Same day, just show end time
const endTime = endDate.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
return `${startFormatted} - ${endTime}`;
} else {
// Different day, show full end date
const endFormatted = endDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return `${startFormatted} - ${endFormatted}`;
}
}
return startFormatted;
} catch (e) {
return startDateString;
}
};
// Get status badge class based on status
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
if (!status) return 'badge-warning';
switch (status) {
case 'completed':
return 'badge-success';
case 'declined':
return 'badge-error';
case 'pending':
return 'badge-warning';
case 'submitted':
return 'badge-info';
default:
return 'badge-warning';
}
};
// Helper function to truncate text
const truncateText = (text: string, maxLength: number) => {
if (!text) return '';
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
};
// Helper to get user display info - always show email address
const getUserDisplayInfo = (request: ExtendedEventRequest) => {
// If we're still loading user data, show loading indicator
if (isLoadingUserData) {
return {
name: request.expand?.requested_user?.name || 'Loading...',
email: request.expand?.requested_user?.email || 'Loading...'
};
}
// First try to get from the expand object
if (request.expand?.requested_user) {
const user = request.expand.requested_user;
const name = user.name || 'Unknown';
// Always show email regardless of emailVisibility
const email = user.email || 'Unknown';
return { name, email };
}
// Then try the requested_user_expand
if (request.requested_user_expand) {
const name = request.requested_user_expand.name || 'Unknown';
const email = request.requested_user_expand.email || 'Unknown';
return { name, email };
}
// Last fallback
return { name: 'Unknown', email: 'Unknown' };
};
// Update openDetailModal to call the prop function
const openDetailModal = (request: ExtendedEventRequest) => {
onRequestSelect(request);
};
// Handle sort change
const handleSortChange = (field: string) => {
if (sortField === field) {
// Toggle direction if same field
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
// Set new field and default to descending
setSortField(field);
setSortDirection('desc');
}
};
// Handle decline action with reason prompt
const handleDeclineAction = (request: ExtendedEventRequest) => {
setRequestToDecline(request);
setDeclineReason('');
setIsDeclineModalOpen(true);
};
// Confirm decline with reason
const confirmDecline = async () => {
if (!requestToDecline || !declineReason.trim()) {
toast.error('Please provide a reason for declining');
return;
}
try {
await updateEventRequestStatus(requestToDecline.id, 'declined', declineReason);
setIsDeclineModalOpen(false);
setRequestToDecline(null);
setDeclineReason('');
} catch (error) {
console.error('Error declining request:', error);
toast.error('Failed to decline request');
}
};
// Cancel decline action
const cancelDecline = () => {
setIsDeclineModalOpen(false);
setRequestToDecline(null);
setDeclineReason('');
};
// Apply filters when filter state changes
useEffect(() => {
applyFilters();
}, [statusFilter, searchTerm, sortField, sortDirection, eventRequests]);
// Check authentication and refresh token if needed
useEffect(() => {
const checkAuth = async () => {
const auth = Authentication.getInstance();
// Check if we're authenticated
if (!auth.isAuthenticated()) {
// console.log("Authentication check failed - attempting to continue anyway");
// Don't show error or redirect immediately - try to refresh first
try {
// Try to refresh event requests anyway - the token might be valid
await refreshEventRequests();
} catch (err) {
// console.error("Failed to refresh after auth check:", err);
toast.error("Authentication error. Please log in again.");
// Only redirect if refresh fails
setTimeout(() => {
window.location.href = "/login";
}, 2000);
}
} else {
// console.log("Authentication check passed");
}
};
checkAuth();
}, []);
// Auto refresh on component mount
useEffect(() => {
refreshEventRequests();
}, []);
// Listen for tab visibility changes and refresh data when tab becomes visible
useEffect(() => {
const handleTabVisible = () => {
// console.log("Tab became visible, refreshing event requests...");
refreshEventRequests();
};
// Add event listener for custom dashboardTabVisible event
document.addEventListener("dashboardTabVisible", handleTabVisible);
// Clean up event listener on component unmount
return () => {
document.removeEventListener("dashboardTabVisible", handleTabVisible);
};
}, []);
if (filteredRequests.length === 0) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-gradient-to-b from-base-200 to-base-300 rounded-xl p-8 text-center shadow-sm border border-base-300/30"
>
<div className="flex flex-col items-center justify-center py-6">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 className="text-xl font-semibold mb-3 text-white">No Event Requests Found</h3>
<p className="text-base-content/60 mb-6 max-w-md">
{statusFilter !== 'all' || searchTerm
? 'No event requests match your current filters. Try adjusting your search criteria.'
: 'There are no event requests in the system yet.'}
</p>
<div className="flex gap-3">
<button
className="btn btn-primary btn-outline btn-sm gap-2"
onClick={refreshEventRequests}
disabled={isRefreshing}
>
{isRefreshing ? (
<>
<span className="loading loading-spinner loading-xs"></span>
Refreshing...
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</>
)}
</button>
{(statusFilter !== 'all' || searchTerm) && (
<button
className="btn btn-ghost btn-sm gap-2"
onClick={() => {
setStatusFilter('all');
setSearchTerm('');
}}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Clear Filters
</button>
)}
</div>
</div>
</motion.div>
);
}
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-6"
style={{ minHeight: "500px" }}
>
{/* Filters and controls */}
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 p-4 bg-base-300/50 rounded-lg border border-base-100/10 mb-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full lg:w-auto">
<div className="relative flex items-center w-full sm:w-auto">
<input
type="text"
placeholder="Search events..."
className="input bg-[#1e2029] border-[#2a2d3a] focus:border-primary w-full sm:w-64 pr-10 rounded-lg"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<button className="absolute right-0 top-0 btn btn-square bg-primary text-white hover:bg-primary/90 border-none rounded-l-none h-full">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div>
<div className="relative w-full sm:w-auto">
<select
className="select bg-[#1e2029] border-[#2a2d3a] focus:border-primary w-full appearance-none pr-10 rounded-lg"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="active">Active (Submitted & Pending)</option>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="completed">Completed</option>
<option value="declined">Declined</option>
</select>
</div>
</div>
<div className="flex items-center gap-3 w-full lg:w-auto justify-between sm:justify-end">
<span className="text-sm text-gray-400">
{filteredRequests.length} {filteredRequests.length === 1 ? 'request' : 'requests'} found
</span>
<button
className="btn btn-primary btn-outline btn-sm gap-2"
onClick={refreshEventRequests}
disabled={isRefreshing}
>
{isRefreshing ? (
<>
<span className="loading loading-spinner loading-xs"></span>
Refreshing...
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</>
)}
</button>
</div>
</div>
{/* Event requests table */}
<div
className="rounded-xl shadow-sm overflow-x-auto bg-base-100/10 border border-base-100/20"
style={{
maxHeight: "unset",
height: "auto"
}}
>
<table className="table table-zebra w-full min-w-[600px]">
<thead className="bg-base-300/50 sticky top-0 z-10">
<tr>
<th
className="cursor-pointer hover:bg-base-300 transition-colors"
onClick={() => handleSortChange('name')}
>
<div className="flex items-center gap-1">
Event Name
{sortField === 'name' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th
className="cursor-pointer hover:bg-base-300 transition-colors hidden md:table-cell"
onClick={() => handleSortChange('start_date_time')}
>
<div className="flex items-center gap-1">
Date & Time
{sortField === 'start_date_time' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th
className="cursor-pointer hover:bg-base-300 transition-colors"
onClick={() => handleSortChange('requested_user')}
>
<div className="flex items-center gap-1">
Requested By
{sortField === 'requested_user' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th className="hidden lg:table-cell">PR Materials</th>
<th
className="cursor-pointer hover:bg-base-300 transition-colors hidden lg:table-cell"
onClick={() => handleSortChange('flyers_completed')}
>
<div className="flex items-center gap-1">
PR Status
{sortField === 'flyers_completed' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th className="hidden lg:table-cell">AS Funding</th>
<th
className="cursor-pointer hover:bg-base-300 transition-colors hidden md:table-cell"
onClick={() => handleSortChange('created')}
>
<div className="flex items-center gap-1">
Submitted
{sortField === 'created' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th
className="cursor-pointer hover:bg-base-300 transition-colors"
onClick={() => handleSortChange('status')}
>
<div className="flex items-center gap-1">
Status
{sortField === 'status' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
</svg>
)}
</div>
</th>
<th className="w-20 min-w-[5rem]">Actions</th>
</tr>
</thead>
<tbody>
{filteredRequests.map((request) => (
<tr key={request.id} className="hover transition-colors">
<td className="font-medium">
<div className="truncate max-w-[180px] md:max-w-[250px]" title={request.name}>
{truncateText(request.name, 30)}
</div>
</td>
<td className="hidden md:table-cell">
<div className="text-sm">
{formatDateTimeRange(request.start_date_time, request.end_date_time)}
</div>
</td>
<td>
{(() => {
const { name, email } = getUserDisplayInfo(request);
return (
<div className="flex flex-col">
<span>{name}</span>
<span className="text-xs text-gray-400">{email}</span>
</div>
);
})()}
</td>
<td className="hidden lg:table-cell">
{request.flyers_needed ? (
<span className="badge badge-success badge-sm">Yes</span>
) : (
<span className="badge badge-ghost badge-sm">No</span>
)}
</td>
<td className="hidden lg:table-cell">
{request.flyers_needed ? (
<input
type="checkbox"
checked={request.flyers_completed || false}
onChange={(e) => {
e.stopPropagation();
updatePRStatus(request.id, e.target.checked);
}}
className="checkbox checkbox-primary"
title="Mark PR materials as completed"
/>
) : (
<input
type="checkbox"
checked={false}
disabled={true}
className="checkbox checkbox-disabled opacity-30"
title="PR materials not needed for this event"
/>
)}
</td>
<td className="hidden lg:table-cell">
{request.as_funding_required ? (
<span className="badge badge-success badge-sm">Yes</span>
) : (
<span className="badge badge-ghost badge-sm">No</span>
)}
</td>
<td className="hidden md:table-cell">{formatDate(request.created)}</td>
<td>
<span className={`badge ${getStatusBadge(request.status)}`}>
{request.status?.charAt(0).toUpperCase() + request.status?.slice(1) || 'Pending'}
</span>
</td>
<td>
<div className="flex items-center justify-center">
<button
className="btn btn-sm btn-primary btn-outline gap-1 min-h-[2rem] max-w-[5rem] flex-shrink-0"
onClick={() => openDetailModal(request)}
title="View Event Details"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span className="hidden sm:inline">View</span>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
{/* Decline Reason Modal */}
{isDeclineModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-base-200 rounded-lg p-6 w-full max-w-md shadow-xl"
>
<h3 className="text-lg font-semibold text-white mb-4">
Decline Event Request
</h3>
<p className="text-gray-300 mb-4">
Please provide a reason for declining "{requestToDecline?.name}". This will be sent to the submitter.
</p>
<textarea
className="textarea textarea-bordered w-full h-32 bg-base-300 text-white border-base-300 focus:border-primary"
placeholder="Enter decline reason (required)..."
value={declineReason}
onChange={(e) => setDeclineReason(e.target.value)}
maxLength={500}
/>
<div className="text-xs text-gray-400 mb-4">
{declineReason.length}/500 characters
</div>
<div className="flex justify-end gap-3">
<button
className="btn btn-ghost"
onClick={cancelDecline}
>
Cancel
</button>
<button
className="btn btn-error"
onClick={confirmDecline}
disabled={!declineReason.trim()}
>
Decline Request
</button>
</div>
</motion.div>
</div>
)}
</>
);
};
export default EventRequestManagementTable;

View file

@ -1,404 +0,0 @@
import React, { useState, useEffect, useLayoutEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import EventRequestDetails from './EventRequestDetails';
import EventRequestManagementTable from './EventRequestManagementTable';
import { Update } from '../../../scripts/pocketbase/Update';
import { Collections } from '../../../schemas/pocketbase/schema';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Get } from '../../../scripts/pocketbase/Get';
import { toast } from 'react-hot-toast';
import { EmailClient } from '../../../scripts/email/EmailClient';
import type { EventRequest } from '../../../schemas/pocketbase/schema';
// Extended EventRequest interface to include expanded fields that might come from the API
interface ExtendedEventRequest extends Omit<EventRequest, 'status'> {
status: "submitted" | "pending" | "completed" | "declined";
requested_user_expand?: {
name: string;
email: string;
};
expand?: {
requested_user?: {
id: string;
name: string;
email: string;
emailVisibility?: boolean;
[key: string]: any;
};
[key: string]: any;
};
}
interface EventRequestModalProps {
eventRequests: ExtendedEventRequest[];
}
// Helper to refresh user data in request objects
const refreshUserData = async (requests: ExtendedEventRequest[]): Promise<ExtendedEventRequest[]> => {
try {
const get = Get.getInstance();
const updatedRequests = [...requests];
const userCache: Record<string, any> = {}; // Cache to avoid fetching the same user multiple times
for (let i = 0; i < updatedRequests.length; i++) {
const request = updatedRequests[i];
if (request.requested_user) {
try {
// Check if we've already fetched this user
let typedUserData;
if (userCache[request.requested_user]) {
typedUserData = userCache[request.requested_user];
} else {
// Fetch full user details for each request with expanded options
const userData = await get.getOne('users', request.requested_user);
// Type assertion to ensure we have the correct user data properties
typedUserData = userData as {
id: string;
name: string;
email: string;
[key: string]: any;
};
// Store in cache
userCache[request.requested_user] = typedUserData;
}
// Update expand object with user data
if (!request.expand) request.expand = {};
request.expand.requested_user = {
...typedUserData,
emailVisibility: true // Force this to be true for UI purposes
};
// Update the requested_user_expand property
request.requested_user_expand = {
name: typedUserData.name || 'Unknown',
email: typedUserData.email || '(No email available)'
};
} catch (err) {
console.error(`Error fetching user data for request ${request.id}:`, err);
// Ensure we have fallback values even if the API call fails
if (!request.expand) request.expand = {};
if (!request.expand.requested_user) {
request.expand.requested_user = {
id: request.requested_user,
name: 'Unknown',
email: 'Unknown',
emailVisibility: true
};
}
if (!request.requested_user_expand) {
request.requested_user_expand = {
name: 'Unknown',
email: 'Unknown'
};
}
}
}
}
return updatedRequests;
} catch (err) {
console.error('Error refreshing user data:', err);
return requests;
}
};
// Wrapper component for EventRequestManagementTable that handles string to function conversion
const TableWrapper: React.FC<{
eventRequests: ExtendedEventRequest[];
handleSelectRequest: (request: ExtendedEventRequest) => void;
handleStatusChange: (id: string, status: "submitted" | "pending" | "completed" | "declined") => Promise<void>;
isLoadingUserData?: boolean;
}> = ({ eventRequests, handleSelectRequest, handleStatusChange, isLoadingUserData = false }) => {
return (
<EventRequestManagementTable
eventRequests={eventRequests}
onRequestSelect={handleSelectRequest}
onStatusChange={handleStatusChange}
isLoadingUserData={isLoadingUserData}
/>
);
};
const EventRequestModal: React.FC<EventRequestModalProps> = ({ eventRequests }) => {
// Define animation delay as a constant to keep it consistent
const ANIMATION_DELAY = "0.3s";
const [selectedRequest, setSelectedRequest] = useState<ExtendedEventRequest | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [localEventRequests, setLocalEventRequests] = useState<ExtendedEventRequest[]>(eventRequests);
const [isLoadingUserData, setIsLoadingUserData] = useState(true); // Start as true to show loading immediately
// Fix scrollbar flashing when modal opens/closes
useLayoutEffect(() => {
const originalStyle = window.getComputedStyle(document.body).overflow;
const originalPaddingRight = window.getComputedStyle(document.body).paddingRight;
if (isModalOpen) {
// Store scroll position
const scrollY = window.scrollY;
// Measure the scrollbar width
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
// Add padding to prevent layout shift
document.body.style.paddingRight = `${scrollbarWidth}px`;
// Prevent body scroll
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';
} else {
// Restore scrolling
const scrollY = document.body.style.top;
document.body.style.overflow = originalStyle;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
document.body.style.paddingRight = originalPaddingRight;
if (scrollY) {
window.scrollTo(0, parseInt(scrollY || '0') * -1);
}
}
return () => {
// Clean up
document.body.style.overflow = originalStyle;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
document.body.style.paddingRight = originalPaddingRight;
};
}, [isModalOpen]);
// Function to refresh user data
const refreshUserDataAndUpdate = async (requests: ExtendedEventRequest[] = localEventRequests) => {
setIsLoadingUserData(true);
try {
const updatedRequests = await refreshUserData(requests);
setLocalEventRequests(updatedRequests);
} catch (err) {
console.error('Error refreshing event request data:', err);
} finally {
setIsLoadingUserData(false);
}
};
// Immediately load user data on mount and when eventRequests change
useEffect(() => {
if (eventRequests && eventRequests.length > 0) {
// First update with existing data from props
setLocalEventRequests(eventRequests);
// Then refresh user data
refreshUserDataAndUpdate(eventRequests);
}
}, [eventRequests]);
// Ensure user data is loaded immediately when component mounts
useEffect(() => {
// Refresh user data immediately on mount
refreshUserDataAndUpdate();
// Set up auto-refresh every 30 seconds
const refreshInterval = setInterval(() => {
refreshUserDataAndUpdate();
}, 30000); // 30 seconds
// Clear interval on unmount
return () => {
clearInterval(refreshInterval);
};
}, []);
// Set up event listeners for communication with the table component
useEffect(() => {
const handleSelectRequest = (event: CustomEvent) => {
setSelectedRequest(event.detail.request);
setIsModalOpen(true);
};
const handleStatusUpdated = (event: CustomEvent) => {
const { id, status } = event.detail;
// Update local state
setLocalEventRequests(prevRequests =>
prevRequests.map(req =>
req.id === id ? { ...req, status } : req
)
);
};
// Add event listeners
document.addEventListener('event-request-select', handleSelectRequest as EventListener);
document.addEventListener('status-updated', handleStatusUpdated as EventListener);
// Clean up
return () => {
document.removeEventListener('event-request-select', handleSelectRequest as EventListener);
document.removeEventListener('status-updated', handleStatusUpdated as EventListener);
};
}, []);
// Listen for dashboardTabVisible event to refresh user data
useEffect(() => {
const handleTabVisible = async () => {
refreshUserDataAndUpdate();
};
document.addEventListener('dashboardTabVisible', handleTabVisible);
return () => {
document.removeEventListener('dashboardTabVisible', handleTabVisible);
};
}, []);
const closeModal = () => {
setIsModalOpen(false);
setSelectedRequest(null);
};
const handleStatusChange = async (id: string, status: "submitted" | "pending" | "completed" | "declined"): Promise<void> => {
try {
const update = Update.getInstance();
await update.updateField("event_request", id, "status", status);
// Force sync to update IndexedDB with deletion detection enabled
const dataSync = DataSyncService.getInstance();
await dataSync.syncCollection(Collections.EVENT_REQUESTS, "", "-created", {}, true);
// Find the request to get its name and previous status
const request = localEventRequests.find((req) => req.id === id);
const eventName = request?.name || "Event";
const previousStatus = request?.status;
// Update local state
setLocalEventRequests(prevRequests =>
prevRequests.map(req =>
req.id === id ? { ...req, status } : req
)
);
// Notify success
toast.success(`"${eventName}" status updated to ${status}`);
// Send email notification for status change (non-blocking)
try {
await EmailClient.notifyEventRequestStatusChange(id, previousStatus || 'unknown', status);
console.log('Event request status change notification email sent successfully');
} catch (emailError) {
console.error('Failed to send event request status change notification email:', emailError);
// Don't show error to user - email failure shouldn't disrupt the main operation
}
// Dispatch event for other components
document.dispatchEvent(
new CustomEvent("status-updated", {
detail: { id, status },
})
);
} catch (err) {
console.error("Error updating status:", err);
toast.error(`Failed to update status`);
throw err;
}
};
// Function to handle request selection
const handleSelectRequest = (request: ExtendedEventRequest) => {
document.dispatchEvent(
new CustomEvent("event-request-select", {
detail: { request },
})
);
};
// Expose the functions globally for table component to use
useEffect(() => {
// @ts-ignore - Adding to window object
window.handleSelectRequest = handleSelectRequest;
// @ts-ignore - Adding to window object
window.handleStatusChange = handleStatusChange;
// @ts-ignore - Adding to window object
window.refreshUserData = refreshUserDataAndUpdate;
return () => {
// @ts-ignore - Cleanup
delete window.handleSelectRequest;
// @ts-ignore - Cleanup
delete window.handleStatusChange;
// @ts-ignore - Cleanup
delete window.refreshUserData;
};
}, [localEventRequests]);
return (
<>
{/* Table component with modernized UI */}
<div
className="bg-gradient-to-b from-base-200 to-base-300 rounded-xl shadow-xl overflow-hidden dashboard-card card-enter event-table-container border border-base-300/30"
style={{ animationDelay: ANIMATION_DELAY }}
>
<div className="p-4 md:p-6 h-auto">
<div id="event-request-table-container" className="relative">
<TableWrapper
eventRequests={localEventRequests}
handleSelectRequest={handleSelectRequest}
handleStatusChange={handleStatusChange}
isLoadingUserData={isLoadingUserData}
/>
</div>
</div>
</div>
{/* Modal with improved styling */}
<AnimatePresence>
{isModalOpen && selectedRequest && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[200]"
>
<div className="flex items-center justify-center min-h-screen p-4 overflow-hidden">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="w-full max-w-5xl max-h-[90vh] overflow-y-auto bg-gradient-to-b from-base-200 to-base-300 rounded-xl shadow-2xl border border-base-100/20 relative"
>
<div className="sticky top-0 right-0 z-[201] flex justify-between items-center p-4 bg-base-300/80 backdrop-blur-md border-b border-base-100/10">
<h2 className="text-xl font-bold text-white">{selectedRequest.name}</h2>
<button
onClick={closeModal}
className="btn btn-circle btn-sm bg-base-100/20 hover:bg-base-100/40 border-none text-white"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<EventRequestDetails
request={selectedRequest}
onClose={closeModal}
onStatusChange={handleStatusChange}
/>
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
export default EventRequestModal;

View file

@ -1,7 +0,0 @@
---
import ReimbursementManagementPortal from "./reimbursement/ReimbursementManagementPortal";
---
<div class="w-full">
<ReimbursementManagementPortal client:load />
</div>

View file

@ -1,3 +0,0 @@
---
---

View file

@ -1,194 +0,0 @@
---
import { Icon } from "astro-icon/components";
import ShowProfileLogs from "./ProfileSection/ShowProfileLogs";
import { Stats } from "./ProfileSection/Stats";
---
<div id="" class="">
<div class="mb-6">
<h2 class="text-2xl font-bold">Dashboard Overview</h2>
<p class="opacity-70">Welcome to your IEEE UCSD dashboard</p>
</div>
<!-- Resume Alert Notification -->
<div id="resumeAlert" class="hidden">
<div
class="bg-error/10 border-l-4 border-error p-4 rounded-lg mb-6 shadow-md"
>
<div class="flex items-center justify-between">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0 mt-0.5">
<div class="p-1.5 bg-error/20 rounded-full">
<Icon
name="heroicons:document-text"
class="h-5 w-5 text-error"
/>
</div>
</div>
<div>
<h3 class="font-semibold text-white mb-1">
Resume Required
</h3>
<p class="text-sm text-base-content/80">
Your resume is missing. Upload your resume to
increase visibility to recruiters and access
exclusive career opportunities.
</p>
</div>
</div>
<div class="ml-4 flex-shrink-0 self-center">
<a
href="#settings-section"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-error hover:bg-error-focus focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-error transition-colors duration-200"
id="uploadResumeBtn"
>
<Icon
name="heroicons:arrow-up-tray"
class="h-4 w-4 mr-1.5"
/>
Upload Now
</a>
</div>
</div>
</div>
<script>
// Function to check if user has a resume and show/hide the alert
const checkResumeStatus = async () => {
try {
const resumeAlert = document.getElementById("resumeAlert");
if (!resumeAlert) return;
// Get the current user from PocketBase
const { Authentication } = await import(
"../../scripts/pocketbase/Authentication"
);
const { Get } = await import(
"../../scripts/pocketbase/Get"
);
const auth = Authentication.getInstance();
const get = Get.getInstance();
if (!auth.isAuthenticated()) {
resumeAlert.classList.add("hidden");
return;
}
const user = auth.getCurrentUser();
if (!user) {
resumeAlert.classList.add("hidden");
return;
}
// Fetch user data to check if resume exists
const userData = await get.getOne("users", user.id);
if (userData && userData.resume) {
// User has a resume, hide the alert
resumeAlert.classList.add("hidden");
} else {
// User doesn't have a resume, show the alert
resumeAlert.classList.remove("hidden");
}
} catch (error) {
console.error("Error checking resume status:", error);
}
};
// Check resume status when the page loads
document.addEventListener("DOMContentLoaded", checkResumeStatus);
// Listen for resume upload events
window.addEventListener("resumeUploaded", (event: Event) => {
const resumeAlert = document.getElementById("resumeAlert");
if (!resumeAlert) return;
// Cast to CustomEvent to access detail property
const customEvent = event as CustomEvent<{
hasResume: boolean;
}>;
const { hasResume } = customEvent.detail;
if (hasResume) {
resumeAlert.classList.add("hidden");
} else {
resumeAlert.classList.remove("hidden");
}
});
// Handle the "Upload Now" button click
document
.getElementById("uploadResumeBtn")
?.addEventListener("click", (e) => {
e.preventDefault();
// Find the settings button in the sidebar and click it
const settingsButton = document.querySelector(
'[data-section="settings"]'
) as HTMLElement;
if (settingsButton) {
settingsButton.click();
// Scroll to the resume section after a short delay
setTimeout(() => {
// Find the resume management section by ID or a more reliable selector
const resumeSection = document.getElementById(
"resume-management-section"
);
if (resumeSection) {
resumeSection.scrollIntoView({
behavior: "smooth",
});
} else {
// Fallback: try to find by heading text
const headings =
document.querySelectorAll("h3.card-title");
for (const heading of headings) {
if (
heading.textContent?.includes(
"Resume Management"
)
) {
heading.scrollIntoView({
behavior: "smooth",
});
break;
}
}
}
}, 300);
}
});
</script>
</div>
<Stats client:load />
<!-- Dashboard Content -->
<div
class="card bg-base-100 shadow-lg border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
>
<div class="card-body">
<h3 class="card-title text-xl font-bold flex items-center gap-3">
<div class="badge badge-primary p-3">
<Icon name="heroicons:eye" class="h-5 w-5" />
</div>
Recent Activity
<div class="badge badge-ghost text-xs font-normal">
Real-time updates
</div>
</h3>
<p class="text-sm opacity-70">
Track your recent interactions with the IEEE UCSD platform
</p>
<div class="divider"></div>
<div class="">
<div class="min-h-[300px]">
<ShowProfileLogs client:load />
</div>
</div>
</div>
</div>
</div>

View file

@ -1,579 +0,0 @@
import { useEffect, useState, useCallback, useMemo } from "react";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { DataSyncService } from "../../../scripts/database/DataSyncService";
import { Collections } from "../../../schemas/pocketbase/schema";
import debounce from 'lodash/debounce';
import type { Log } from "../../../schemas/pocketbase";
import { SendLog } from "../../../scripts/pocketbase/SendLog";
const LOGS_PER_PAGE = 5;
export default function ShowProfileLogs() {
const [logs, setLogs] = useState<Log[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalLogs, setTotalLogs] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [allLogs, setAllLogs] = useState<Log[]>([]);
const [isFetchingAll, setIsFetchingAll] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true);
// Auto-refresh logs every 30 seconds if enabled
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
if (!isFetchingAll) {
fetchLogs(true);
}
}, 30000); // 30 seconds
return () => clearInterval(interval);
}, [autoRefresh, isFetchingAll]);
const fetchLogs = async (skipCache = false) => {
setLoading(true);
setError(null);
const auth = Authentication.getInstance();
const currentUser = auth.getPocketBase().authStore.model;
const userId = currentUser?.id;
if (!userId) {
setError("Not authenticated");
setLoading(false);
return;
}
try {
setIsFetchingAll(true);
// console.log("Fetching logs for user:", userId);
// Use DataSyncService to fetch logs
const dataSync = DataSyncService.getInstance();
// First sync logs for this user
await dataSync.syncCollection(
Collections.LOGS,
`user = "${userId}"`,
"-created",
{ expand: "user" }
);
// Then get all logs from IndexedDB
const fetchedLogs = await dataSync.getData<Log>(
Collections.LOGS,
false, // Don't force sync again
`user = "${userId}"`,
"-created"
);
// console.log("Fetched logs:", fetchedLogs.length);
if (fetchedLogs.length === 0) {
// If no logs found, try to fetch directly from PocketBase
// console.log("No logs found in IndexedDB, trying direct fetch from PocketBase");
try {
const sendLog = SendLog.getInstance();
const directLogs = await sendLog.getUserLogs(userId);
// console.log("Direct fetch logs:", directLogs.length);
if (directLogs.length > 0) {
setAllLogs(directLogs);
setTotalPages(Math.ceil(directLogs.length / LOGS_PER_PAGE));
setTotalLogs(directLogs.length);
} else {
setAllLogs(fetchedLogs);
setTotalPages(Math.ceil(fetchedLogs.length / LOGS_PER_PAGE));
setTotalLogs(fetchedLogs.length);
}
} catch (directError) {
// console.error("Failed to fetch logs directly:", directError);
setAllLogs(fetchedLogs);
setTotalPages(Math.ceil(fetchedLogs.length / LOGS_PER_PAGE));
setTotalLogs(fetchedLogs.length);
}
} else {
setAllLogs(fetchedLogs);
setTotalPages(Math.ceil(fetchedLogs.length / LOGS_PER_PAGE));
setTotalLogs(fetchedLogs.length);
}
} catch (error) {
// console.error("Failed to fetch logs:", error);
setError("Error loading activity");
} finally {
setLoading(false);
setIsFetchingAll(false);
}
};
// Memoized search function
const filteredLogs = useMemo(() => {
if (!allLogs.length) return [];
if (!searchQuery.trim()) {
// When not searching, return only the current page of logs
const startIndex = (currentPage - 1) * LOGS_PER_PAGE;
const endIndex = startIndex + LOGS_PER_PAGE;
return allLogs.slice(startIndex, endIndex);
}
const query = searchQuery.toLowerCase();
return allLogs.filter(log => {
return (
log.message?.toLowerCase().includes(query) ||
log.type?.toLowerCase().includes(query) ||
log.part?.toLowerCase().includes(query) ||
(log.created && new Date(log.created).toLocaleString().toLowerCase().includes(query))
);
});
}, [searchQuery, allLogs, currentPage]);
// Update displayed logs whenever filtered results change
useEffect(() => {
setLogs(filteredLogs);
// console.log("Filtered logs updated:", filteredLogs.length, "logs");
}, [filteredLogs]);
// Debounced search handler
const debouncedSearch = useCallback(
debounce((query: string) => {
setSearchQuery(query);
// Reset to first page when searching
if (query.trim()) {
setCurrentPage(1);
}
}, 300),
[]
);
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSearch(event.target.value);
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
const handleRefresh = () => {
fetchLogs(true);
};
useEffect(() => {
const loadLogsWithRetry = async () => {
try {
await fetchLogs();
// Wait a moment for state to update
setTimeout(async () => {
// Check if logs were loaded
if (allLogs.length === 0) {
// console.log("No logs found after initial fetch, trying direct fetch");
await directFetchLogs();
}
}, 1000);
} catch (error) {
// console.error("Failed to load logs with retry:", error);
}
};
loadLogsWithRetry();
checkLogsExist();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Check if there are any logs in the database at all
const checkLogsExist = async () => {
try {
const auth = Authentication.getInstance();
const pb = auth.getPocketBase();
// Check if the logs collection exists and has any records
const result = await pb.collection(Collections.LOGS).getList(1, 1);
// console.log("Logs collection check:", {
// totalItems: result.totalItems,
// page: result.page,
// perPage: result.perPage,
// totalPages: result.totalPages
// });
} catch (error) {
// console.error("Failed to check logs collection:", error);
}
};
// Calculate log statistics
const logStats = useMemo(() => {
if (!allLogs.length) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const lastWeek = new Date(today);
lastWeek.setDate(lastWeek.getDate() - 7);
const todayLogs = allLogs.filter(log => new Date(log.created) >= today);
const weekLogs = allLogs.filter(log => new Date(log.created) >= lastWeek);
// Count by type
const typeCount: Record<string, number> = {};
allLogs.forEach(log => {
const type = log.type || 'unknown';
typeCount[type] = (typeCount[type] || 0) + 1;
});
return {
total: allLogs.length,
today: todayLogs.length,
week: weekLogs.length,
types: typeCount
};
}, [allLogs]);
// Direct fetch from PocketBase as a fallback
const directFetchLogs = async () => {
try {
setLoading(true);
setError(null);
const auth = Authentication.getInstance();
const pb = auth.getPocketBase();
const userId = auth.getPocketBase().authStore.model?.id;
if (!userId) {
setError("Not authenticated");
setLoading(false);
return;
}
// console.log("Direct fetching logs for user:", userId);
// Fetch logs directly from PocketBase
const result = await pb.collection(Collections.LOGS).getList<Log>(1, 100, {
filter: `user = "${userId}"`,
sort: "-created",
expand: "user"
});
// console.log("Direct fetch result:", {
// totalItems: result.totalItems,
// items: result.items.length
// });
if (result.items.length > 0) {
setAllLogs(result.items);
setTotalPages(Math.ceil(result.items.length / LOGS_PER_PAGE));
setTotalLogs(result.items.length);
}
} catch (error) {
// console.error("Failed to direct fetch logs:", error);
setError("Error loading activity");
} finally {
setLoading(false);
}
};
// Add a button to try direct fetch
const renderDirectFetchButton = () => (
<button
className="btn btn-sm btn-outline mt-4"
onClick={directFetchLogs}
>
Try Direct Fetch
</button>
);
if (loading && !allLogs.length) {
return (
<p className="text-base-content/70 flex items-center gap-2">
<svg className="h-5 w-5 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
{isFetchingAll ? 'Fetching your activity...' : 'Loading activity...'}
</p>
);
}
if (error) {
return (
<p className="text-base-content/70 flex items-center gap-2">
<svg className="h-5 w-5 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
{error}
</p>
);
}
// Debug logs
// console.log("Render state:", {
// logsLength: logs.length,
// allLogsLength: allLogs.length,
// searchQuery,
// loading,
// currentPage
// });
if (allLogs.length === 0 && !searchQuery && !loading) {
return (
<div>
<p className="text-base-content/70 flex items-center gap-2 mb-4">
<svg className="h-5 w-5 opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
No recent activity to display.
</p>
<div className="flex gap-2">
<button
className="btn btn-sm btn-primary"
onClick={async () => {
try {
const auth = Authentication.getInstance();
const userId = auth.getPocketBase().authStore.model?.id;
if (!userId) return;
const sendLog = SendLog.getInstance();
await sendLog.send(
"create",
"test",
"Test log created for debugging",
userId
);
// console.log("Created test log");
setTimeout(() => fetchLogs(true), 1000);
} catch (error) {
// console.error("Failed to create test log:", error);
}
}}
>
Create Test Log
</button>
<button
className="btn btn-sm btn-outline"
onClick={directFetchLogs}
>
Try Direct Fetch
</button>
</div>
</div>
);
}
return (
<div>
{/* Activity Summary */}
{logStats && !searchQuery && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="stat bg-base-200 rounded-lg p-4">
<div className="stat-title">Today</div>
<div className="stat-value">{logStats.today}</div>
<div className="stat-desc">Activities recorded today</div>
</div>
<div className="stat bg-base-200 rounded-lg p-4">
<div className="stat-title">This Week</div>
<div className="stat-value">{logStats.week}</div>
<div className="stat-desc">Activities in the last 7 days</div>
</div>
<div className="stat bg-base-200 rounded-lg p-4">
<div className="stat-title">Total</div>
<div className="stat-value">{logStats.total}</div>
<div className="stat-desc">All-time activities</div>
</div>
</div>
)}
{/* Search and Refresh Controls */}
<div className="flex gap-4 mb-4">
<div className="flex-1">
<input
type="text"
placeholder="Search activity..."
onChange={handleSearch}
className="input input-bordered w-full"
/>
</div>
<div className="dropdown dropdown-end dropdown-hover">
<button
onClick={handleRefresh}
className={`btn btn-ghost btn-square ${isFetchingAll ? 'loading' : ''}`}
title="Refresh logs"
disabled={isFetchingAll}
>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M4.755 10.059a7.5 7.5 0 0112.548-3.364l1.903 1.903h-3.183a.75.75 0 100 1.5h4.992a.75.75 0 00.75-.75V4.356a.75.75 0 00-1.5 0v3.18l-1.9-1.9A9 9 0 003.306 9.67a.75.75 0 101.45.388zm15.408 3.352a.75.75 0 00-.919.53 7.5 7.5 0 01-12.548 3.364l-1.902-1.903h3.183a.75.75 0 000-1.5H2.984a.75.75 0 00-.75.75v4.992a.75.75 0 001.5 0v-3.18l1.9 1.9a9 9 0 0015.059-4.035.75.75 0 00-.53-.918z" clipRule="evenodd" />
</svg>
</button>
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<li>
<button
onClick={() => setAutoRefresh(!autoRefresh)}
className="flex justify-between"
>
<span>Auto-refresh</span>
<input
type="checkbox"
className="toggle toggle-primary toggle-sm"
checked={autoRefresh}
onChange={() => { }}
/>
</button>
</li>
<li><button onClick={directFetchLogs}>Direct fetch from server</button></li>
</ul>
</div>
</div>
{isFetchingAll && (
<div className="mb-4">
<p className="text-sm opacity-70">Fetching all activity, please wait...</p>
</div>
)}
{/* Search Results Message */}
{searchQuery && (
<p className="text-sm opacity-70 mb-4">
Found {logs.length} results for "{searchQuery}"
</p>
)}
{/* Logs Display */}
<div className="space-y-2">
{logs.map((log) => (
<div key={log.id} className="flex items-start gap-4 p-4 rounded-lg hover:bg-base-200 transition-colors duration-200">
<div className="flex-shrink-0">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${getLogTypeColor(log.type)}`}>
{getLogTypeIcon(log.type)}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-base font-medium">{log.message}</p>
<div className="flex items-center gap-2 mt-1">
{log.part && (
<span className="badge badge-sm">{log.part}</span>
)}
<p className="text-sm opacity-50">
{new Date(log.created).toLocaleString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</div>
))}
</div>
{/* Pagination Controls */}
{!searchQuery && totalLogs > LOGS_PER_PAGE && (
<div className="flex justify-between items-center mt-6 pt-4 border-t border-base-200">
<div className="flex items-center gap-2">
<span className="text-sm opacity-70">
Showing {totalLogs ? (currentPage - 1) * LOGS_PER_PAGE + 1 : 0}-{Math.min(currentPage * LOGS_PER_PAGE, totalLogs)} of {totalLogs} results
</span>
</div>
<div className="flex gap-2">
<button
onClick={handlePrevPage}
disabled={currentPage === 1}
className="btn btn-sm btn-ghost"
>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
Previous
</button>
<button
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="btn btn-sm btn-ghost"
>
Next
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
</div>
</div>
)}
</div>
);
}
function getLogTypeColor(type: string): string {
switch (type?.toLowerCase()) {
case 'error':
return 'bg-error/10 text-error';
case 'update':
return 'bg-info/10 text-info';
case 'delete':
return 'bg-warning/10 text-warning';
case 'create':
return 'bg-success/10 text-success';
case 'login':
case 'logout':
return 'bg-primary/10 text-primary';
default:
return 'bg-base-300/50 text-base-content';
}
}
function getLogTypeIcon(type: string) {
switch (type?.toLowerCase()) {
case 'error':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
);
case 'update':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-8.4 8.4a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32l8.4-8.4z" />
<path d="M5.25 5.25a3 3 0 00-3 3v10.5a3 3 0 003 3h10.5a3 3 0 003-3V13.5a.75.75 0 00-1.5 0v5.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5V8.25a1.5 1.5 0 011.5-1.5h5.25a.75.75 0 000-1.5H5.25z" />
</svg>
);
case 'delete':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M16.5 4.478v.227a48.816 48.816 0 013.878.512.75.75 0 11-.256 1.478l-.209-.035-1.005 13.07a3 3 0 01-2.991 2.77H8.084a3 3 0 01-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 01-.256-1.478A48.567 48.567 0 017.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 013.369 0c1.603.051 2.815 1.387 2.815 2.951zm-6.136-1.452a51.196 51.196 0 013.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 00-6 0v-.113c0-.794.609-1.428 1.364-1.452zm-.355 5.945a.75.75 0 10-1.5.058l.347 9a.75.75 0 101.499-.058l-.346-9zm5.48.058a.75.75 0 10-1.498-.058l-.347 9a.75.75 0 001.5.058l.345-9z" clipRule="evenodd" />
</svg>
);
case 'create':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z" clipRule="evenodd" />
</svg>
);
case 'login':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M7.5 3.75A1.5 1.5 0 006 5.25v13.5a1.5 1.5 0 001.5 1.5h6a1.5 1.5 0 001.5-1.5V15a.75.75 0 011.5 0v3.75a3 3 0 01-3 3h-6a3 3 0 01-3-3V5.25a3 3 0 013-3h6a3 3 0 013 3V9A.75.75 0 0115 9V5.25a1.5 1.5 0 00-1.5-1.5h-6zm10.72 4.72a.75.75 0 011.06 0l3 3a.75.75 0 010 1.06l-3 3a.75.75 0 11-1.06-1.06l1.72-1.72H9a.75.75 0 010-1.5h10.94l-1.72-1.72a.75.75 0 010-1.06z" clipRule="evenodd" />
</svg>
);
case 'logout':
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M7.5 3.75A1.5 1.5 0 006 5.25v13.5a1.5 1.5 0 001.5 1.5h6a1.5 1.5 0 001.5-1.5V15a.75.75 0 011.5 0v3.75a3 3 0 01-3 3h-6a3 3 0 01-3-3V5.25a3 3 0 013-3h6a3 3 0 013 3V9A.75.75 0 0115 9V5.25a1.5 1.5 0 00-1.5-1.5h-6zm5.03 4.72a.75.75 0 010 1.06l-1.72 1.72h10.94a.75.75 0 010 1.5H10.81l1.72 1.72a.75.75 0 11-1.06 1.06l-3-3a.75.75 0 010-1.06l3-3a.75.75 0 011.06 0z" clipRule="evenodd" />
</svg>
);
default:
return (
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm1-13h-2v6l5.25 3.15.75-1.23-4-2.37z" />
</svg>
);
}
}

View file

@ -1,218 +0,0 @@
import { useEffect, useState } from "react";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import { Collections } from "../../../schemas/pocketbase/schema";
import type { Event, User, LimitedUser } from "../../../schemas/pocketbase";
import { Get } from "../../../scripts/pocketbase/Get";
import type { EventAttendee } from "../../../schemas/pocketbase";
import { Update } from "../../../scripts/pocketbase/Update";
// Extended User interface with member_type property
interface ExtendedUser extends User {
member_type?: string;
}
export function Stats() {
const [eventsAttended, setEventsAttended] = useState(0);
const [loyaltyPoints, setLoyaltyPoints] = useState(0);
const [pointsChange, setPointsChange] = useState("No activity");
const [quarterlyPoints, setQuarterlyPoints] = useState(0); // Points earned this quarter
const [membershipStatus, setMembershipStatus] = useState("Member");
const [memberSince, setMemberSince] = useState<string | null>(null);
const [upcomingEvents, setUpcomingEvents] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [user, setUser] = useState<ExtendedUser | null>(null);
const [pointsEarned, setPointsEarned] = useState(0);
const [attendancePercentage, setAttendancePercentage] = useState(0);
// Helper function to get the start date of the current quarter
const getCurrentQuarterStartDate = (): Date => {
const now = new Date();
const currentMonth = now.getMonth();
let quarterStartMonth = 0;
// Determine the start month of the current quarter
if (currentMonth >= 0 && currentMonth <= 2) {
quarterStartMonth = 0; // Q1: Jan-Mar
} else if (currentMonth >= 3 && currentMonth <= 5) {
quarterStartMonth = 3; // Q2: Apr-Jun
} else if (currentMonth >= 6 && currentMonth <= 8) {
quarterStartMonth = 6; // Q3: Jul-Sep
} else {
quarterStartMonth = 9; // Q4: Oct-Dec
}
return new Date(now.getFullYear(), quarterStartMonth, 1);
};
useEffect(() => {
const fetchStats = async () => {
try {
setIsLoading(true);
setError(null);
const auth = Authentication.getInstance();
const get = Get.getInstance();
const currentUser = auth.getCurrentUser();
if (!currentUser) {
setError("User not logged in");
return;
}
const userId = currentUser.id;
// Get user data
const userData = await get.getOne<ExtendedUser>("users", userId);
if (!userData) {
setError("Failed to load user data");
return;
}
// Set user data
setUser(userData);
// Get events attended by the user
const attendedEvents = await get.getList<EventAttendee>(
"event_attendees",
1,
1000,
`user="${userId}"`
);
setEventsAttended(attendedEvents.totalItems);
// Calculate points from attendees
let totalPoints = 0;
// Calculate quarterly points
const quarterStartDate = getCurrentQuarterStartDate();
let pointsThisQuarter = 0;
// Calculate both total and quarterly points from attendees
attendedEvents.items.forEach(attendee => {
const points = attendee.points_earned || 0;
totalPoints += points;
const checkinDate = new Date(attendee.time_checked_in);
if (checkinDate >= quarterStartDate) {
pointsThisQuarter += points;
}
});
// Try to get the LimitedUser record to check if points match
try {
const limitedUserRecord = await get.getOne(
Collections.LIMITED_USERS,
userId
);
if (limitedUserRecord && limitedUserRecord.points) {
try {
// Parse the points JSON string
const parsedPoints = JSON.parse(limitedUserRecord.points);
if (parsedPoints !== totalPoints) {
console.log(`Points mismatch: LimitedUser has ${parsedPoints}, calculated ${totalPoints}`);
}
} catch (e) {
console.error('Error parsing points from LimitedUser:', e);
}
}
} catch (e) {
// LimitedUser record might not exist yet, that's okay
}
setPointsEarned(totalPoints);
setLoyaltyPoints(totalPoints);
setQuarterlyPoints(pointsThisQuarter);
// Get current quarter name
const now = new Date();
const currentMonth = now.getMonth();
let quarterName = "";
if (currentMonth >= 0 && currentMonth <= 2) {
quarterName = "Q1";
} else if (currentMonth >= 3 && currentMonth <= 5) {
quarterName = "Q2";
} else if (currentMonth >= 6 && currentMonth <= 8) {
quarterName = "Q3";
} else {
quarterName = "Q4";
}
setPointsChange(`${pointsThisQuarter} pts in ${quarterName}`);
// Get all events to calculate percentage
const allEvents = await get.getList<Event>("events", 1, 1000);
if (allEvents.totalItems > 0) {
const percentage = (attendedEvents.totalItems / allEvents.totalItems) * 100;
setAttendancePercentage(Math.round(percentage));
} else {
setAttendancePercentage(0);
}
} catch (error) {
console.error("Error fetching stats:", error);
setError("Failed to load stats");
} finally {
setIsLoading(false);
}
};
fetchStats();
}, []);
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{[...Array(3)].map((_, i) => (
<div key={i} className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200">
<div className="stat">
<div className="stat-title skeleton h-4 w-32 mb-2"></div>
<div className="stat-value skeleton h-8 w-16"></div>
<div className="stat-desc mt-1">
<div className="skeleton h-4 w-24"></div>
</div>
</div>
</div>
))}
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform">
<div className="stat">
<div className="stat-title font-medium opacity-80">Events Attended</div>
<div className="stat-value text-primary">{eventsAttended}</div>
<div className="stat-desc flex items-center gap-2 mt-1">
<div className="badge badge-primary badge-sm">Since joining</div>
</div>
</div>
</div>
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-secondary transition-all duration-300 hover:-translate-y-1 transform">
<div className="stat">
<div className="stat-title font-medium opacity-80">Points</div>
<div className="stat-value text-secondary">{loyaltyPoints}</div>
<div className="stat-desc flex flex-col items-start gap-1 mt-1">
<div className="flex items-center justify-between w-full">
<div className="badge badge-secondary badge-sm">{quarterlyPoints} pts this quarter</div>
<div className="text-xs opacity-70">Total points</div>
</div>
</div>
</div>
</div>
<div className="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-accent transition-all duration-300 hover:-translate-y-1 transform">
<div className="stat">
<div className="stat-title font-medium opacity-80">Upcoming Events</div>
<div className="stat-value text-accent">{upcomingEvents}</div>
<div className="stat-desc flex items-center gap-2 mt-1">
<div className="badge badge-accent badge-sm">Available to attend</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,56 +0,0 @@
---
import ReimbursementForm from "./reimbursement/ReimbursementForm";
import ReimbursementList from "./reimbursement/ReimbursementList";
---
<div class="space-y-6">
<div class="mb-6">
<h2 class="text-2xl font-bold">Reimbursement</h2>
<p class="opacity-70">Manage your reimbursement requests</p>
</div>
<div class="tabs tabs-boxed">
<button class="tab tab-active" data-tab="list">My Requests</button>
<button class="tab" data-tab="form">New Request</button>
</div>
<div id="reimbursementContent">
<div id="listTab" class="tab-content block">
<ReimbursementList client:load />
</div>
<div id="formTab" class="tab-content hidden">
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<ReimbursementForm client:load />
</div>
</div>
</div>
</div>
</div>
<script>
// Tab switching logic
const tabs = document.querySelectorAll(".tab");
const tabContents = document.querySelectorAll(".tab-content");
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
const targetTab = tab.getAttribute("data-tab");
// Update tab states
tabs.forEach((t) => t.classList.remove("tab-active"));
tab.classList.add("tab-active");
// Update content visibility
tabContents.forEach((content) => {
if (content.id === `${targetTab}Tab`) {
content.classList.remove("hidden");
content.classList.add("block");
} else {
content.classList.remove("block");
content.classList.add("hidden");
}
});
});
});
</script>

View file

@ -1,187 +0,0 @@
---
import ResumeList from "./ResumeDatabase/ResumeList";
import ResumeFilters from "./ResumeDatabase/ResumeFilters";
import ResumeSearch from "./ResumeDatabase/ResumeSearch";
import ResumeDetail from "./ResumeDatabase/ResumeDetail";
---
<div class="space-y-6">
<div
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4"
>
<div>
<h2 class="text-2xl font-bold">Resume Database</h2>
<p class="text-base-content/70">
Search and filter student resumes for recruitment opportunities
</p>
</div>
<div class="flex items-center gap-2">
<button id="refreshResumesBtn" class="btn btn-sm btn-outline">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-refresh-cw"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
></path>
<path d="M21 3v5h-5"></path>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
></path>
<path d="M3 21v-5h5"></path>
</svg>
<span>Refresh</span>
</button>
</div>
</div>
<!-- Resume Database Interface -->
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<!-- Filters Panel -->
<div class="lg:col-span-1">
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Filters</h3>
<ResumeFilters client:load />
</div>
</div>
</div>
<!-- Resume List and Detail View -->
<div class="lg:col-span-3 space-y-6">
<!-- Search Bar -->
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<ResumeSearch client:load />
</div>
</div>
<!-- Resume List -->
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Student Resumes</h3>
<ResumeList client:load />
</div>
</div>
<!-- Resume Detail View (initially hidden, shown when a resume is selected) -->
<div
id="resumeDetailContainer"
class="card bg-base-100 shadow-md hidden"
>
<div class="card-body">
<div class="flex justify-between items-center">
<h3 class="card-title text-lg">Resume Details</h3>
<button
id="closeResumeDetail"
class="btn btn-sm btn-ghost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-x"
>
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
<ResumeDetail client:load />
</div>
</div>
</div>
</div>
</div>
<script>
import { Authentication } from "../../scripts/pocketbase/Authentication";
import { Get } from "../../scripts/pocketbase/Get";
import { Realtime } from "../../scripts/pocketbase/Realtime";
import { Collections } from "../../schemas/pocketbase/schema";
// Initialize services
const auth = Authentication.getInstance();
const get = Get.getInstance();
const realtime = Realtime.getInstance();
// Initialize the resume database
async function initResumeDatabase() {
if (!auth.isAuthenticated()) {
console.error("User not authenticated");
return;
}
try {
// Set up event listeners
document
.getElementById("refreshResumesBtn")
?.addEventListener("click", () => {
// Dispatch custom event to notify components to refresh
window.dispatchEvent(
new CustomEvent("resumeDatabaseRefresh")
);
});
// Close resume detail view
document
.getElementById("closeResumeDetail")
?.addEventListener("click", () => {
document
.getElementById("resumeDetailContainer")
?.classList.add("hidden");
});
// Set up realtime updates
setupRealtimeUpdates();
} catch (error) {
console.error("Error initializing resume database:", error);
}
}
// Set up realtime updates
function setupRealtimeUpdates() {
// Subscribe to users collection for resume updates
realtime.subscribeToCollection(Collections.USERS, (data) => {
console.log("User data updated:", data);
// Dispatch custom event to notify components to refresh
window.dispatchEvent(new CustomEvent("resumeDatabaseRefresh"));
});
}
// Initialize when document is ready
document.addEventListener("DOMContentLoaded", initResumeDatabase);
// Custom event listener for resume selection
window.addEventListener("resumeSelected", (e) => {
const customEvent = e as CustomEvent;
const resumeId = customEvent.detail.resumeId;
if (resumeId) {
// Show the resume detail container
document
.getElementById("resumeDetailContainer")
?.classList.remove("hidden");
// Dispatch event to the ResumeDetail component
window.dispatchEvent(
new CustomEvent("loadResumeDetail", {
detail: { resumeId },
})
);
}
});
</script>

View file

@ -1,194 +0,0 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { User } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
interface ResumeUser {
id: string;
name: string;
email: string;
major?: string;
graduation_year?: number;
resume?: string;
avatar?: string;
}
function getResumeUrl(user: ResumeUser): string | undefined {
if (!user.resume) return undefined;
return `https://pocketbase.ieeeucsd.org/api/files/users/${user.id}/${user.resume}`;
}
export default function ResumeDetail() {
const [user, setUser] = useState<ResumeUser | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for resume selection
const handleResumeSelection = (event: CustomEvent) => {
const { resumeId } = event.detail;
if (resumeId) {
loadResumeDetails(resumeId);
}
};
window.addEventListener('loadResumeDetail', handleResumeSelection as EventListener);
return () => {
window.removeEventListener('loadResumeDetail', handleResumeSelection as EventListener);
};
}, []);
const loadResumeDetails = async (userId: string) => {
try {
setLoading(true);
setError(null);
// Get user details
const user = await get.getOne<User>(Collections.USERS, userId);
if (!user || !user.resume) {
setError('Resume not found');
setUser(null);
return;
}
// Map to our simplified format
setUser({
id: user.id,
name: user.name,
email: user.email,
major: user.major,
graduation_year: user.graduation_year,
resume: user.resume,
avatar: user.avatar
});
} catch (err) {
console.error('Error loading resume details:', err);
setError('Failed to load resume details');
setUser(null);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center py-10">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="alert alert-error">
<div className="flex-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<label>{error}</label>
</div>
</div>
);
}
if (!user) {
return (
<div className="text-center py-10">
<p className="text-base-content/70">Select a resume to view details</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Student Information */}
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-shrink-0">
<div className="avatar">
<div className="w-24 h-24 rounded-xl">
{user.avatar ? (
<img src={user.avatar} alt={user.name} />
) : (
<div className="bg-primary text-primary-content flex items-center justify-center w-full h-full">
<span className="text-2xl font-bold">{user.name.charAt(0)}</span>
</div>
)}
</div>
</div>
</div>
<div className="flex-grow">
<h3 className="text-xl font-bold">{user.name}</h3>
<p className="text-base-content/70">{user.email}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<h4 className="text-sm font-semibold text-base-content/50">Major</h4>
<p>{user.major || 'Not specified'}</p>
</div>
<div>
<h4 className="text-sm font-semibold text-base-content/50">Graduation Year</h4>
<p>{user.graduation_year || 'Not specified'}</p>
</div>
</div>
</div>
</div>
{/* Resume Preview */}
<div className="border border-base-300 rounded-lg overflow-hidden">
<div className="bg-base-200 px-4 py-2 border-b border-base-300 flex justify-between items-center">
<h3 className="font-medium">Resume</h3>
<a
href={getResumeUrl(user)}
target="_blank"
rel="noopener noreferrer"
className="btn btn-sm btn-primary"
>
Download
</a>
</div>
<div className="p-4 bg-base-100">
{user.resume && user.resume.toLowerCase().endsWith('.pdf') ? (
<div className="aspect-[8.5/11] w-full">
<iframe
src={`${getResumeUrl(user)}#toolbar=0&navpanes=0`}
className="w-full h-full border-0"
title={`${user.name}'s Resume`}
/>
</div>
) : user.resume && user.resume.toLowerCase().endsWith('.docx') ? (
<div className="aspect-[8.5/11] w-full">
<iframe
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(getResumeUrl(user) ?? '')}`}
className="w-full h-full border-0"
title={`${user.name}'s Resume`}
/>
</div>
) : (
<div className="text-center py-10">
<p className="text-base-content/70">
Resume preview not available. Click the download button to view the resume.
</p>
</div>
)}
</div>
</div>
{/* Contact Button */}
<div className="flex justify-end">
<a
href={`mailto:${user.email}?subject=Regarding%20Your%20Resume&body=Hello%20${user.name},%0A%0AI%20found%20your%20resume%20in%20the%20IEEE%20UCSD%20database%20and%20would%20like%20to%20discuss%20potential%20opportunities.%0A%0ABest%20regards,`}
className="btn btn-secondary"
>
Contact Student
</a>
</div>
</div>
);
}

View file

@ -1,172 +0,0 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { User } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
export default function ResumeFilters() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [majors, setMajors] = useState<string[]>([]);
const [graduationYears, setGraduationYears] = useState<number[]>([]);
// Filter state
const [selectedMajor, setSelectedMajor] = useState<string>('all');
const [selectedGraduationYear, setSelectedGraduationYear] = useState<string>('all');
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
loadFilterOptions();
// Listen for refresh requests
const handleRefresh = () => {
loadFilterOptions();
};
window.addEventListener('resumeDatabaseRefresh', handleRefresh);
return () => {
window.removeEventListener('resumeDatabaseRefresh', handleRefresh);
};
}, []);
// When filters change, dispatch event to notify parent
useEffect(() => {
dispatchFilterChange();
}, [selectedMajor, selectedGraduationYear]);
const loadFilterOptions = async () => {
try {
setLoading(true);
// Get all users with resumes
const filter = "resume != null && resume != ''";
const users = await get.getAll<User>(Collections.USERS, filter);
// Extract unique majors
const uniqueMajors = new Set<string>();
users.forEach(user => {
if (user.major) {
uniqueMajors.add(user.major);
}
});
// Extract unique graduation years
const uniqueGradYears = new Set<number>();
users.forEach(user => {
if (user.graduation_year) {
uniqueGradYears.add(user.graduation_year);
}
});
// Sort majors alphabetically
const sortedMajors = Array.from(uniqueMajors).sort();
// Sort graduation years in ascending order
const sortedGradYears = Array.from(uniqueGradYears).sort((a, b) => a - b);
setMajors(sortedMajors);
setGraduationYears(sortedGradYears);
} catch (err) {
console.error('Error loading filter options:', err);
setError('Failed to load filter options');
} finally {
setLoading(false);
}
};
const dispatchFilterChange = () => {
window.dispatchEvent(
new CustomEvent('resumeFilterChange', {
detail: {
major: selectedMajor,
graduationYear: selectedGraduationYear
}
})
);
};
const handleMajorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedMajor(e.target.value);
};
const handleGraduationYearChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedGraduationYear(e.target.value);
};
const handleResetFilters = () => {
setSelectedMajor('all');
setSelectedGraduationYear('all');
};
if (loading) {
return (
<div className="flex justify-center items-center py-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="alert alert-error">
<div className="flex-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<label>{error}</label>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Major Filter */}
<div className="form-control">
<label className="label">
<span className="label-text">Major</span>
</label>
<select
className="select select-bordered w-full"
value={selectedMajor}
onChange={handleMajorChange}
>
<option value="all">All Majors</option>
{majors.map(major => (
<option key={major} value={major}>{major}</option>
))}
</select>
</div>
{/* Graduation Year Filter */}
<div className="form-control">
<label className="label">
<span className="label-text">Graduation Year</span>
</label>
<select
className="select select-bordered w-full"
value={selectedGraduationYear}
onChange={handleGraduationYearChange}
>
<option value="all">All Years</option>
{graduationYears.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
</div>
{/* Reset Filters Button */}
<div className="form-control mt-6">
<button
className="btn btn-outline btn-sm"
onClick={handleResetFilters}
>
Reset Filters
</button>
</div>
</div>
);
}

View file

@ -1,254 +0,0 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { User } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
interface ResumeUser {
id: string;
name: string;
major?: string;
graduation_year?: number;
resume?: string;
avatar?: string;
}
export default function ResumeList() {
const [users, setUsers] = useState<ResumeUser[]>([]);
const [filteredUsers, setFilteredUsers] = useState<ResumeUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const usersPerPage = 10;
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
loadResumes();
// Listen for filter changes
const handleFilterChange = (event: CustomEvent) => {
applyFilters(event.detail);
};
// Listen for search changes
const handleSearchChange = (event: CustomEvent) => {
applySearch(event.detail.searchQuery);
};
// Listen for refresh requests
const handleRefresh = () => {
loadResumes();
};
window.addEventListener('resumeFilterChange', handleFilterChange as EventListener);
window.addEventListener('resumeSearchChange', handleSearchChange as EventListener);
window.addEventListener('resumeDatabaseRefresh', handleRefresh);
return () => {
window.removeEventListener('resumeFilterChange', handleFilterChange as EventListener);
window.removeEventListener('resumeSearchChange', handleSearchChange as EventListener);
window.removeEventListener('resumeDatabaseRefresh', handleRefresh);
};
}, []);
const loadResumes = async () => {
try {
setLoading(true);
// Get all users with resumes
const filter = "resume != null && resume != ''";
const users = await get.getAll<User>(Collections.USERS, filter);
// Map to our simplified format
const resumeUsers = users
.filter(user => user.resume) // Ensure resume exists
.map(user => ({
id: user.id,
name: user.name,
major: user.major,
graduation_year: user.graduation_year,
resume: user.resume,
avatar: user.avatar
}));
setUsers(resumeUsers);
setFilteredUsers(resumeUsers);
setCurrentPage(1);
} catch (err) {
console.error('Error loading resumes:', err);
setError('Failed to load resume data');
} finally {
setLoading(false);
}
};
const applyFilters = (filters: any) => {
let filtered = [...users];
// Apply major filter
if (filters.major && filters.major !== 'all') {
filtered = filtered.filter(user => {
if (!user.major) return false;
return user.major.toLowerCase().includes(filters.major.toLowerCase());
});
}
// Apply graduation year filter
if (filters.graduationYear && filters.graduationYear !== 'all') {
const year = parseInt(filters.graduationYear);
filtered = filtered.filter(user => user.graduation_year === year);
}
setFilteredUsers(filtered);
setCurrentPage(1);
};
const applySearch = (searchQuery: string) => {
if (!searchQuery.trim()) {
setFilteredUsers(users);
setCurrentPage(1);
return;
}
const query = searchQuery.toLowerCase();
const filtered = users.filter(user =>
user.name.toLowerCase().includes(query) ||
(user.major && user.major.toLowerCase().includes(query))
);
setFilteredUsers(filtered);
setCurrentPage(1);
};
const handleResumeClick = (userId: string) => {
// Dispatch event to notify parent component
window.dispatchEvent(
new CustomEvent('resumeSelected', {
detail: { resumeId: userId }
})
);
};
// Get current users for pagination
const indexOfLastUser = currentPage * usersPerPage;
const indexOfFirstUser = indexOfLastUser - usersPerPage;
const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser);
const totalPages = Math.ceil(filteredUsers.length / usersPerPage);
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
if (loading) {
return (
<div className="flex justify-center items-center py-10">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="alert alert-error">
<div className="flex-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<label>{error}</label>
</div>
</div>
);
}
if (filteredUsers.length === 0) {
return (
<div className="text-center py-10">
<p className="text-base-content/70">No resumes found matching your criteria</p>
</div>
);
}
return (
<div>
<div className="overflow-x-auto">
<table className="table w-full">
<thead>
<tr>
<th>Student</th>
<th>Major</th>
<th>Graduation Year</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{currentUsers.map(user => (
<tr key={user.id} className="hover">
<td>
<div className="flex items-center space-x-3">
<div className="avatar">
<div className="mask mask-squircle w-12 h-12">
{user.avatar ? (
<img src={user.avatar} alt={user.name} />
) : (
<div className="bg-primary text-primary-content flex items-center justify-center w-full h-full">
<span className="text-lg font-bold">{user.name.charAt(0)}</span>
</div>
)}
</div>
</div>
<div>
<div className="font-bold">{user.name}</div>
</div>
</div>
</td>
<td>{user.major || 'Not specified'}</td>
<td>{user.graduation_year || 'Not specified'}</td>
<td>
<button
className="btn btn-sm btn-primary"
onClick={() => handleResumeClick(user.id)}
>
View Resume
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center mt-6">
<div className="btn-group">
<button
className="btn btn-sm"
onClick={() => paginate(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
«
</button>
{Array.from({ length: totalPages }, (_, i) => (
<button
key={i + 1}
className={`btn btn-sm ${currentPage === i + 1 ? 'btn-active' : ''}`}
onClick={() => paginate(i + 1)}
>
{i + 1}
</button>
))}
<button
className="btn btn-sm"
onClick={() => paginate(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
»
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -1,73 +0,0 @@
import { useState, useEffect } from 'react';
export default function ResumeSearch() {
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
// Debounce search input to avoid too many updates
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(searchQuery);
}, 300);
return () => {
clearTimeout(timer);
};
}, [searchQuery]);
// When debounced query changes, dispatch event to notify parent
useEffect(() => {
dispatchSearchChange();
}, [debouncedQuery]);
const dispatchSearchChange = () => {
window.dispatchEvent(
new CustomEvent('resumeSearchChange', {
detail: {
searchQuery: debouncedQuery
}
})
);
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
};
const handleClearSearch = () => {
setSearchQuery('');
};
return (
<div className="relative">
<div className="flex items-center">
<div className="relative flex-grow">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg className="w-5 h-5 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search by name or major..."
className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
bg-white/90 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 focus:outline-none
focus:ring-2 focus:ring-primary focus:border-transparent"
value={searchQuery}
onChange={handleSearchChange}
/>
{searchQuery && (
<button
className="absolute inset-y-0 right-0 flex items-center pr-3"
onClick={handleClearSearch}
>
<svg className="w-5 h-5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -1,235 +0,0 @@
---
import { Icon } from "astro-icon/components";
import UserProfileSettings from "./SettingsSection/UserProfileSettings";
import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings";
import NotificationSettings from "./SettingsSection/NotificationSettings";
import DisplaySettings from "./SettingsSection/DisplaySettings";
import ResumeSettings from "./SettingsSection/ResumeSettings";
import ThemeToggle from "./universal/ThemeToggle";
// Import environment variables
const logtoAppId = import.meta.env.LOGTO_APP_ID;
const logtoAppSecret = import.meta.env.LOGTO_APP_SECRET;
const logtoEndpoint = import.meta.env.LOGTO_ENDPOINT;
const logtoTokenEndpoint = import.meta.env.LOGTO_TOKEN_ENDPOINT;
const logtoApiEndpoint = import.meta.env.LOGTO_API_ENDPOINT;
// Define fallback values if environment variables are not set
const safeLogtoAppId = logtoAppId || "missing_app_id";
const safeLogtoAppSecret = logtoAppSecret || "missing_app_secret";
const safeLogtoEndpoint = logtoEndpoint || "https://auth.ieeeucsd.org";
const safeLogtoTokenEndpoint =
logtoTokenEndpoint || "https://auth.ieeeucsd.org/oidc/token";
const safeLogtoApiEndpoint = logtoApiEndpoint || "";
---
<div id="settings-section" class="">
<div class="mb-6">
<h2 class="text-2xl font-bold">Settings</h2>
<p class="opacity-70">Manage your account settings and preferences</p>
</div>
<!-- Debug Environment Variables (Only visible in development) -->
{
import.meta.env.DEV && (
<div class="card bg-card shadow-xl border border-warning mb-6 p-4">
<h3 class="text-lg font-bold text-warning">
Debug Environment Variables
</h3>
<p class="text-sm mb-2">
This section is only visible in development mode
</p>
<div class="overflow-x-auto">
<table class="table-auto w-full text-xs">
<thead>
<tr>
<th>Variable</th>
<th>Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>LOGTO_APP_ID</td>
<td>{logtoAppId ? "********" : "Not set"}</td>
<td>{logtoAppId ? "✅" : "❌"}</td>
</tr>
<tr>
<td>LOGTO_APP_SECRET</td>
<td>
{logtoAppSecret ? "********" : "Not set"}
</td>
<td>{logtoAppSecret ? "✅" : "❌"}</td>
</tr>
<tr>
<td>LOGTO_ENDPOINT</td>
<td>{logtoEndpoint || "Not set"}</td>
<td>{logtoEndpoint ? "✅" : "❌"}</td>
</tr>
<tr>
<td>LOGTO_TOKEN_ENDPOINT</td>
<td>{logtoTokenEndpoint || "Not set"}</td>
<td>{logtoTokenEndpoint ? "✅" : "❌"}</td>
</tr>
<tr>
<td>LOGTO_API_ENDPOINT</td>
<td>{logtoApiEndpoint || "Not set"}</td>
<td>{logtoApiEndpoint ? "✅" : "❌"}</td>
</tr>
</tbody>
</table>
</div>
</div>
)
}
<!-- Profile Settings Card -->
<div
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
>
<Icon name="heroicons:user" class="h-5 w-5" />
</div>
Profile Information
</h3>
<p class="text-sm opacity-70 mb-4">
Update your personal information and profile details
</p>
<div class="h-px w-full bg-border my-4"></div>
<UserProfileSettings
client:load
logtoApiEndpoint={logtoApiEndpoint}
/>
</div>
</div>
<!-- Resume Settings Card -->
<div
id="resume-management-section"
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
>
<Icon name="heroicons:document-text" class="h-5 w-5" />
</div>
Resume Management
</h3>
<p class="text-sm opacity-70 mb-4">
Upload and manage your resume for recruiters and career
opportunities
</p>
<div class="h-px w-full bg-border my-4"></div>
<ResumeSettings client:load />
</div>
</div>
<!-- Account Security Settings Card -->
<div
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
>
<Icon name="heroicons:lock-closed" class="h-5 w-5" />
</div>
Account Security
</h3>
<p class="text-sm opacity-70 mb-4">
Manage your account security settings and authentication options
</p>
<div class="h-px w-full bg-border my-4"></div>
<AccountSecuritySettings
client:load
logtoAppId={safeLogtoAppId}
logtoAppSecret={safeLogtoAppSecret}
logtoEndpoint={safeLogtoEndpoint}
logtoTokenEndpoint={safeLogtoTokenEndpoint}
logtoApiEndpoint={safeLogtoApiEndpoint}
/>
</div>
</div>
<!-- Notification Settings Card -->
<div
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6 relative group"
>
<!-- Coming Soon Overlay -->
<div
class="absolute inset-0 bg-muted bg-opacity-90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10 rounded-xl"
>
<div class="text-center">
<h4 class="text-xl font-bold">Coming Soon</h4>
<p class="text-sm opacity-70">
Notification settings will be available in a future update
</p>
</div>
</div>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div
class="inline-flex items-center justify-center p-3 rounded-full bg-primary text-primary-foreground"
>
<Icon name="heroicons:bell" class="h-5 w-5" />
</div>
Notification Preferences
</h3>
<p class="text-sm opacity-70 mb-4">
Customize how and when you receive notifications
</p>
<div class="h-px w-full bg-border my-4"></div>
<NotificationSettings client:load />
</div>
</div>
<!-- Display Settings Card -->
<div
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
>
<div class="card-body">
<h3 class="card-title flex items-center gap-3">
<div class="badge badge-primary p-3">
<Icon name="heroicons:computer-desktop" class="h-5 w-5" />
</div>
Display Settings
</h3>
<p class="text-sm opacity-70 mb-4">
Customize your dashboard appearance and display preferences
</p>
<div class="divider"></div>
<div class="alert alert-warning mb-4">
<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"
></path></svg
>
<div>
<h3 class="font-bold">Light Mode Experimental</h3>
<p class="text-sm">
Light mode is still experimental and some UI elements
may not display correctly.
</p>
</div>
</div>
<DisplaySettings client:load />
</div>
</div>
</div>

View file

@ -1,185 +0,0 @@
import { useState, useEffect } from 'react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { SendLog } from '../../../scripts/pocketbase/SendLog';
import { toast } from 'react-hot-toast';
import PasswordChangeSettings from './PasswordChangeSettings';
interface AccountSecuritySettingsProps {
logtoAppId: string;
logtoAppSecret: string;
logtoEndpoint: string;
logtoTokenEndpoint: string;
logtoApiEndpoint: string;
}
export default function AccountSecuritySettings({
logtoAppId,
logtoAppSecret,
logtoEndpoint,
logtoTokenEndpoint,
logtoApiEndpoint
}: AccountSecuritySettingsProps) {
const auth = Authentication.getInstance();
const logger = SendLog.getInstance();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
const [sessionInfo, setSessionInfo] = useState({
lastLogin: '',
browser: '',
device: '',
});
useEffect(() => {
const checkAuth = () => {
const authenticated = auth.isAuthenticated();
setIsAuthenticated(authenticated);
if (authenticated) {
const user = auth.getCurrentUser();
if (user) {
// Get last login time
const lastLogin = user.last_login || user.updated;
// Get browser and device info
const userAgent = navigator.userAgent;
const browser = detectBrowser(userAgent);
const device = detectDevice(userAgent);
setSessionInfo({
lastLogin: formatDate(lastLogin),
browser,
device,
});
}
}
setLoading(false);
};
checkAuth();
}, []);
// No logout functions needed here as logout is handled in the dashboard menu
const detectBrowser = (userAgent: string): string => {
if (userAgent.indexOf('Chrome') > -1) return 'Chrome';
if (userAgent.indexOf('Safari') > -1) return 'Safari';
if (userAgent.indexOf('Firefox') > -1) return 'Firefox';
if (userAgent.indexOf('MSIE') > -1 || userAgent.indexOf('Trident') > -1) return 'Internet Explorer';
if (userAgent.indexOf('Edge') > -1) return 'Edge';
return 'Unknown Browser';
};
const detectDevice = (userAgent: string): string => {
if (/Android/i.test(userAgent)) return 'Android Device';
if (/iPhone|iPad|iPod/i.test(userAgent)) return 'iOS Device';
if (/Windows/i.test(userAgent)) return 'Windows Device';
if (/Mac/i.test(userAgent)) return 'Mac Device';
if (/Linux/i.test(userAgent)) return 'Linux Device';
return 'Unknown Device';
};
const formatDate = (dateString: string): string => {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
}).format(date);
};
if (loading) {
return (
<div className="flex justify-center items-center py-8">
<div className="loading loading-spinner loading-lg"></div>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="p-4 text-error bg-error bg-opacity-10 rounded-lg">
<span>You must be logged in to access this page.</span>
</div>
);
}
return (
<div>
<div className="space-y-6">
{/* Current Session Information */}
<div className="bg-base-200 p-4 rounded-lg">
<h4 className="font-semibold text-lg mb-2">Current Session</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm opacity-70">Last Login</p>
<p className="font-medium">{sessionInfo.lastLogin}</p>
</div>
<div>
<p className="text-sm opacity-70">Browser</p>
<p className="font-medium">{sessionInfo.browser}</p>
</div>
<div>
<p className="text-sm opacity-70">Device</p>
<p className="font-medium">{sessionInfo.device}</p>
</div>
</div>
</div>
{/* Password Change Section */}
<div>
<h4 className="font-semibold text-lg mb-2">Change Password</h4>
<p className="text-sm opacity-70 mb-4">
Update your account password. For security reasons, you'll need to provide your current password.
</p>
<div className="rounded-md bg-yellow-600 p-4 mb-4">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-white">
Please note: This will only update your password for the IEEE UCSD SSO. This will not update the password for your @ieeeucsd.org mail account.
</p>
</div>
</div>
</div>
<PasswordChangeSettings
logtoAppId={logtoAppId}
logtoAppSecret={logtoAppSecret}
logtoEndpoint={logtoEndpoint}
logtoTokenEndpoint={logtoTokenEndpoint}
logtoApiEndpoint={logtoApiEndpoint}
/>
</div>
{/* Authentication Options */}
<div>
<h4 className="font-semibold text-lg mb-2">Authentication Options</h4>
<p className="text-sm opacity-70 mb-4">
IEEE UCSD uses Single Sign-On (SSO) for authentication.
Password management is handled through your IEEEUCSD account.
</p>
</div>
{/* Account Actions */}
<div>
<h4 className="font-semibold text-lg mb-2">Account Actions</h4>
<div className="space-y-4">
<p className="text-sm text-warning p-3 bg-warning bg-opacity-10 rounded-lg">
If you need to delete your account or have other account-related issues,
please contact an IEEE UCSD administrator.
</p>
<p className="text-sm text-info p-3 bg-info bg-opacity-10 rounded-lg">
To log out of your account, use the Logout option in the dashboard menu.
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,354 +0,0 @@
import { useState, useEffect } from 'react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Update } from '../../../scripts/pocketbase/Update';
import { Collections } from '../../../schemas/pocketbase/schema';
import { toast } from 'react-hot-toast';
import { ThemeService, DEFAULT_THEME_SETTINGS, type ThemeSettings } from '../../../scripts/database/ThemeService';
export default function DisplaySettings() {
const auth = Authentication.getInstance();
const update = Update.getInstance();
const themeService = ThemeService.getInstance();
// Current applied settings
const [currentSettings, setCurrentSettings] = useState<ThemeSettings | null>(null);
// Form state (unsaved changes)
const [theme, setTheme] = useState<'light' | 'dark'>(DEFAULT_THEME_SETTINGS.theme);
const [fontSize, setFontSize] = useState(DEFAULT_THEME_SETTINGS.fontSize);
const [colorBlindMode, setColorBlindMode] = useState(DEFAULT_THEME_SETTINGS.colorBlindMode);
const [reducedMotion, setReducedMotion] = useState(DEFAULT_THEME_SETTINGS.reducedMotion);
const [saving, setSaving] = useState(false);
// Track if form has unsaved changes
const [hasChanges, setHasChanges] = useState(false);
// Load saved preferences on component mount
useEffect(() => {
const loadPreferences = async () => {
try {
// First load theme settings from IndexedDB
const themeSettings = await themeService.getThemeSettings();
// Store current settings
setCurrentSettings(themeSettings);
// Set form state from theme settings
setTheme(themeSettings.theme);
setFontSize(themeSettings.fontSize);
setColorBlindMode(themeSettings.colorBlindMode);
setReducedMotion(themeSettings.reducedMotion);
// Reset changes flag
setHasChanges(false);
// Then check if user has saved preferences in their profile
const user = auth.getCurrentUser();
if (user) {
let needsDisplayPrefsUpdate = false;
let needsAccessibilityUpdate = false;
// Check and handle display preferences
if (user.display_preferences && typeof user.display_preferences === 'string' && user.display_preferences.trim() !== '') {
try {
const userPrefs = JSON.parse(user.display_preferences);
// Only update if values exist and are different from IndexedDB
if (userPrefs.theme && ['light', 'dark'].includes(userPrefs.theme) && userPrefs.theme !== themeSettings.theme) {
setTheme(userPrefs.theme as 'light' | 'dark');
// Don't update theme service yet, wait for save
setHasChanges(true);
} else if (!['light', 'dark'].includes(userPrefs.theme)) {
// If theme is not valid, mark for update
needsDisplayPrefsUpdate = true;
}
if (userPrefs.fontSize && userPrefs.fontSize !== themeSettings.fontSize) {
setFontSize(userPrefs.fontSize);
// Don't update theme service yet, wait for save
setHasChanges(true);
}
} catch (e) {
console.error('Error parsing display preferences:', e);
needsDisplayPrefsUpdate = true;
}
} else {
needsDisplayPrefsUpdate = true;
}
// Check and handle accessibility settings
if (user.accessibility_settings && typeof user.accessibility_settings === 'string' && user.accessibility_settings.trim() !== '') {
try {
const accessibilityPrefs = JSON.parse(user.accessibility_settings);
if (typeof accessibilityPrefs.colorBlindMode === 'boolean' &&
accessibilityPrefs.colorBlindMode !== themeSettings.colorBlindMode) {
setColorBlindMode(accessibilityPrefs.colorBlindMode);
// Don't update theme service yet, wait for save
setHasChanges(true);
}
if (typeof accessibilityPrefs.reducedMotion === 'boolean' &&
accessibilityPrefs.reducedMotion !== themeSettings.reducedMotion) {
setReducedMotion(accessibilityPrefs.reducedMotion);
// Don't update theme service yet, wait for save
setHasChanges(true);
}
} catch (e) {
console.error('Error parsing accessibility settings:', e);
needsAccessibilityUpdate = true;
}
} else {
needsAccessibilityUpdate = true;
}
// Initialize default settings if needed
if (needsDisplayPrefsUpdate || needsAccessibilityUpdate) {
await initializeDefaultSettings(user.id, needsDisplayPrefsUpdate, needsAccessibilityUpdate);
}
}
} catch (error) {
console.error('Error loading preferences:', error);
toast.error('Failed to load display preferences');
}
};
loadPreferences();
}, []);
// Check for changes when form values change
useEffect(() => {
if (!currentSettings) return;
const hasThemeChanged = theme !== currentSettings.theme;
const hasFontSizeChanged = fontSize !== currentSettings.fontSize;
const hasColorBlindModeChanged = colorBlindMode !== currentSettings.colorBlindMode;
const hasReducedMotionChanged = reducedMotion !== currentSettings.reducedMotion;
setHasChanges(
hasThemeChanged ||
hasFontSizeChanged ||
hasColorBlindModeChanged ||
hasReducedMotionChanged
);
}, [theme, fontSize, colorBlindMode, reducedMotion, currentSettings]);
// Initialize default settings if not set
const initializeDefaultSettings = async (userId: string, updateDisplayPrefs: boolean, updateAccessibility: boolean) => {
try {
const updateData: any = {};
if (updateDisplayPrefs) {
updateData.display_preferences = JSON.stringify({
theme,
fontSize
});
}
if (updateAccessibility) {
updateData.accessibility_settings = JSON.stringify({
colorBlindMode,
reducedMotion
});
}
if (Object.keys(updateData).length > 0) {
await update.updateFields(Collections.USERS, userId, updateData);
}
} catch (error) {
console.error('Error initializing default settings:', error);
}
};
// Handle theme change
const handleThemeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newTheme = e.target.value as 'light' | 'dark';
setTheme(newTheme);
// Changes will be applied on save
};
// Handle font size change
const handleFontSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newSize = e.target.value as 'small' | 'medium' | 'large' | 'extra-large';
setFontSize(newSize);
// Changes will be applied on save
};
// Handle color blind mode toggle
const handleColorBlindModeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const enabled = e.target.checked;
setColorBlindMode(enabled);
// Changes will be applied on save
};
// Handle reduced motion toggle
const handleReducedMotionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const enabled = e.target.checked;
setReducedMotion(enabled);
// Changes will be applied on save
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const user = auth.getCurrentUser();
if (!user) throw new Error('User not authenticated');
// Save display preferences to user record
const displayPreferences = {
theme,
fontSize
};
// Save accessibility settings to user record
const accessibilitySettings = {
colorBlindMode,
reducedMotion
};
// First update IndexedDB with the new settings
await themeService.saveThemeSettings({
id: "current",
theme,
fontSize,
colorBlindMode,
reducedMotion,
updatedAt: Date.now()
});
// Then update user record in PocketBase
await update.updateFields(
Collections.USERS,
user.id,
{
display_preferences: JSON.stringify(displayPreferences),
accessibility_settings: JSON.stringify(accessibilitySettings)
}
);
// Update current settings state to match the new settings
setCurrentSettings({
id: "current",
theme,
fontSize,
colorBlindMode,
reducedMotion,
updatedAt: Date.now()
});
// Reset changes flag
setHasChanges(false);
// Show success message
toast.success('Display settings saved successfully!');
} catch (error) {
console.error('Error saving display settings:', error);
toast.error('Failed to save display settings to your profile');
} finally {
setSaving(false);
}
};
return (
<div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Theme Settings */}
<div>
<h4 className="font-semibold text-lg mb-2">Theme</h4>
<div className="form-control w-full max-w-xs">
<select
value={theme}
onChange={handleThemeChange}
className="select select-bordered"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<label className="label">
<span className="label-text-alt">Select your preferred theme</span>
</label>
</div>
</div>
{/* Font Size Settings */}
<div>
<h4 className="font-semibold text-lg mb-2">Font Size</h4>
<div className="form-control w-full max-w-xs">
<select
value={fontSize}
onChange={handleFontSizeChange}
className="select select-bordered"
>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
<option value="extra-large">Extra Large</option>
</select>
<label className="label">
<span className="label-text-alt">Select your preferred font size</span>
</label>
</div>
</div>
{/* Accessibility Settings */}
<div>
<h4 className="font-semibold text-lg mb-2">Accessibility</h4>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
checked={colorBlindMode}
onChange={handleColorBlindModeChange}
className="toggle toggle-primary"
/>
<div>
<span className="label-text font-medium">Color Blind Mode</span>
<p className="text-xs opacity-70">Enhances color contrast and uses color-blind friendly palettes</p>
</div>
</label>
</div>
<div className="form-control mt-2">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
checked={reducedMotion}
onChange={handleReducedMotionChange}
className="toggle toggle-primary"
/>
<div>
<span className="label-text font-medium">Reduced Motion</span>
<p className="text-xs opacity-70">Minimizes animations and transitions</p>
</div>
</label>
</div>
</div>
<p className="text-sm text-info">
These settings are saved to your browser using IndexedDB and your IEEE UCSD account. They will be applied whenever you log in.
</p>
<div className="form-control">
<div className="flex flex-col gap-2">
{hasChanges && (
<p className="text-sm text-warning">
You have unsaved changes. Click "Save Settings" to apply them.
</p>
)}
<button
type="submit"
className={`btn btn-primary ${saving ? 'loading' : ''}`}
disabled={saving || !hasChanges}
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
</form>
</div>
);
}

View file

@ -1,489 +0,0 @@
import { useState, useEffect } from 'react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Collections, type User } from '../../../schemas/pocketbase/schema';
import { toast } from 'react-hot-toast';
export default function EmailRequestSettings() {
const auth = Authentication.getInstance();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [requesting, setRequesting] = useState(false);
const [resettingPassword, setResettingPassword] = useState(false);
const [isOfficer, setIsOfficer] = useState(false);
const [createdEmail, setCreatedEmail] = useState<string | null>(null);
const [showPasswordReset, setShowPasswordReset] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
// For initial email creation
const [initialPassword, setInitialPassword] = useState('');
const [initialConfirmPassword, setInitialConfirmPassword] = useState('');
const [initialPasswordError, setInitialPasswordError] = useState('');
const [showEmailForm, setShowEmailForm] = useState(false);
useEffect(() => {
const loadUserData = async () => {
try {
setLoading(true);
const currentUser = auth.getCurrentUser();
if (!currentUser) {
// Don't show toast on dashboard page for unauthenticated users
if (!window.location.pathname.includes('/dashboard')) {
toast.error('You must be logged in to access this page');
}
return;
}
setUser(currentUser);
// Check if user is an officer
const pb = auth.getPocketBase();
try {
const officerRecord = await pb.collection('officers').getFirstListItem(`user="${currentUser.id}"`);
if (officerRecord) {
setIsOfficer(true);
}
} catch (error) {
// Not an officer, which is fine
console.log('User is not an officer');
}
} catch (error) {
console.error('Error loading user data:', error);
// Don't show toast on dashboard page for unauthenticated users
if (auth.isAuthenticated() || !window.location.pathname.includes('/dashboard')) {
toast.error('Failed to load user data. Please try again later.');
}
} finally {
setLoading(false);
}
};
loadUserData();
}, []);
const toggleEmailForm = () => {
setShowEmailForm(!showEmailForm);
setInitialPassword('');
setInitialConfirmPassword('');
setInitialPasswordError('');
};
const validateInitialPassword = () => {
if (initialPassword.length < 8) {
setInitialPasswordError('Password must be at least 8 characters long');
return false;
}
if (initialPassword !== initialConfirmPassword) {
setInitialPasswordError('Passwords do not match');
return false;
}
setInitialPasswordError('');
return true;
};
const handleRequestEmail = async () => {
if (!user) return;
if (initialPassword && !validateInitialPassword()) {
return;
}
try {
setRequesting(true);
// Determine what the email will be
const emailUsername = user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9]/g, "");
const emailDomain = import.meta.env.PUBLIC_MXROUTE_EMAIL_DOMAIN || 'ieeeucsd.org';
const expectedEmail = `${emailUsername}@${emailDomain}`;
// Call the API to create the email account
const response = await fetch('/api/create-ieee-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
name: user.name,
email: user.email,
password: initialPassword || undefined
})
});
const result = await response.json();
if (response.ok && result.success) {
// Email created successfully
setCreatedEmail(result.data.ieeeEmail);
// Update the user record to mark email as requested
const pb = auth.getPocketBase();
await pb.collection(Collections.USERS).update(user.id, {
requested_email: true
});
toast.success('IEEE email created successfully!');
setShowEmailForm(false);
} else {
toast.error(result.message || 'Failed to create email. Please contact the webmaster for assistance.');
}
} catch (error) {
console.error('Error requesting email:', error);
toast.error('Failed to create email. Please try again later.');
} finally {
setRequesting(false);
}
};
const togglePasswordReset = () => {
setShowPasswordReset(!showPasswordReset);
setNewPassword('');
setConfirmPassword('');
setPasswordError('');
};
const validatePassword = () => {
if (newPassword.length < 8) {
setPasswordError('Password must be at least 8 characters long');
return false;
}
if (newPassword !== confirmPassword) {
setPasswordError('Passwords do not match');
return false;
}
setPasswordError('');
return true;
};
const handleResetPassword = async () => {
if (!user || !user.requested_email) return;
if (!validatePassword()) {
return;
}
// Determine the email address
const emailAddress = createdEmail || (user ? `${user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9]/g, "")}@ieeeucsd.org` : '');
try {
setResettingPassword(true);
// Call the API to reset the password
const response = await fetch('/api/reset-email-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: emailAddress,
password: newPassword
})
});
const result = await response.json();
if (response.ok && result.success) {
toast.success('Password reset successfully!');
setShowPasswordReset(false);
setNewPassword('');
setConfirmPassword('');
} else {
toast.error(result.message || 'Failed to reset password. Please contact the webmaster for assistance.');
}
} catch (error) {
console.error('Error resetting password:', error);
toast.error('Failed to reset password. Please try again later.');
} finally {
setResettingPassword(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center p-8">
<span className="loading loading-spinner loading-lg text-primary"></span>
</div>
);
}
if (!isOfficer) {
return (
<div className="p-4 bg-base-200 rounded-lg">
<p>IEEE email addresses are only available to officers. If you are an officer and don't see the option to request an email, please contact the webmaster.</p>
</div>
);
}
if (user?.requested_email || createdEmail) {
return (
<div className="space-y-6">
<div className="p-4 bg-base-200 rounded-lg">
<h3 className="font-bold text-lg mb-2">
{createdEmail ? 'Your IEEE Email Address' : 'Email Request Status'}
</h3>
<div className="mb-4">
<p className="text-xl font-mono bg-base-100 p-2 rounded">
{createdEmail || (user ? `${user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9]/g, "")}@ieeeucsd.org` : '')}
</p>
{initialPassword ? (
<p className="mt-2 text-sm">Your email has been created with the password you provided.</p>
) : (
<p className="mt-2 text-sm">Check your personal email for login instructions.</p>
)}
</div>
<div className="mb-4">
<h4 className="font-semibold mb-1">Access Your Email</h4>
<ul className="list-disc list-inside space-y-1">
<li>Webmail: <a href="https://mail.ieeeucsd.org" target="_blank" rel="noopener noreferrer" className="link link-primary">https://mail.ieeeucsd.org</a></li>
</ul>
</div>
<div className="mt-4">
{!showPasswordReset ? (
<button
className="btn btn-secondary w-full"
onClick={togglePasswordReset}
>
Reset Email Password
</button>
) : (
<div className="space-y-4 p-4 bg-base-100 rounded-lg">
<h4 className="font-semibold">Reset Your Email Password</h4>
<div className="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="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>
<strong>Important:</strong> After resetting your password, you'll need to update it in any email clients, Gmail integrations, or mobile devices where you've set up this email account.
</span>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">New Password</span>
</label>
<input
type="password"
className="input input-bordered"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Confirm Password</span>
</label>
<input
type="password"
className="input input-bordered"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
/>
</div>
{passwordError && (
<div className="text-error text-sm">{passwordError}</div>
)}
<div className="flex gap-2">
<button
className="btn btn-secondary flex-1"
onClick={handleResetPassword}
disabled={resettingPassword}
>
{resettingPassword ? (
<>
<span className="loading loading-spinner loading-sm"></span>
Resetting...
</>
) : (
'Reset Password'
)}
</button>
<button
className="btn btn-outline"
onClick={togglePasswordReset}
disabled={resettingPassword}
>
Cancel
</button>
</div>
</div>
)}
{!showPasswordReset && (
<p className="text-xs mt-2 opacity-70">
Reset your IEEE email password to a new password of your choice.
</p>
)}
</div>
</div>
<div className="p-4 bg-base-200 rounded-lg">
<h3 className="font-bold text-lg mb-4">Setting Up Your IEEE Email in Gmail</h3>
<div className="mb-6">
<h4 className="font-semibold mb-2">First Step: Set Up Sending From Your IEEE Email</h4>
<ol className="list-decimal list-inside space-y-2">
<li>Go to settings (gear icon) Accounts and Import</li>
<li>In the section that says <span className="text-blue-600">Send mail as:</span>, select <span className="text-blue-600">Reply from the same address the message was sent to</span></li>
<li>In that same section, select <span className="text-blue-600">Add another email address</span></li>
<li>For the Name, put your actual name (e.g. Charles Nguyen) if this is your personal ieeeucsd.org or put the department name (e.g. IEEEUCSD Webmaster)</li>
<li>For the Email address, put the email that was provided for you</li>
<li>Make sure the <span className="text-blue-600">Treat as an alias</span> button is selected. Go to the next step</li>
<li>For the SMTP Server, put <span className="text-blue-600">mail.ieeeucsd.org</span></li>
<li>For the username, put in your <span className="text-blue-600">FULL ieeeucsd email address</span></li>
<li>For the password, put in the email's password</li>
<li>For the port, put in <span className="text-blue-600">587</span></li>
<li>Make sure you select <span className="text-blue-600">Secured connection with TLS</span></li>
<li>Go back to mail.ieeeucsd.org and verify the email that Google has sent you</li>
</ol>
</div>
<div>
<h4 className="font-semibold mb-2">Second Step: Set Up Receiving Your IEEE Email</h4>
<ol className="list-decimal list-inside space-y-2">
<li>Go to settings (gear icon) Accounts and Import</li>
<li>In the section that says <span className="text-blue-600">Check mail from other accounts:</span>, select <span className="text-blue-600">Add a mail account</span></li>
<li>Put in the ieeeucsd email and hit next</li>
<li>Make sure <span className="text-blue-600">Import emails from my other account (POP3)</span> is selected, then hit next</li>
<li>For the username, put in your full ieeeucsd.org email</li>
<li>For the password, put in your ieeeucsd.org password</li>
<li>For the POP Server, put in <span className="text-blue-600">mail.ieeeucsd.org</span></li>
<li>For the Port, put in <span className="text-blue-600">995</span></li>
<li>Select <span className="text-blue-600">Leave a copy of retrieved message on the server</span></li>
<li>Select <span className="text-blue-600">Always use a secure connection (SSL) when retrieving mail</span></li>
<li>Select <span className="text-blue-600">Label incoming messages</span></li>
<li>Then hit <span className="text-blue-600">Add Account</span></li>
</ol>
</div>
</div>
<div className="p-4 bg-base-200 rounded-lg">
<p className="text-sm">
If you have any questions or need help with your IEEE email, please contact <a href="mailto:webmaster@ieeeatucsd.org" className="underline">webmaster@ieeeatucsd.org</a>
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{!showEmailForm ? (
<>
<p className="text-sm">
As an IEEE officer, you're eligible for an official IEEE UCSD email address. This email can be used for all IEEE-related communications and provides a professional identity when representing the organization.
</p>
<div className="p-4 bg-base-200 rounded-lg">
<h3 className="font-bold text-lg mb-2">Benefits of an IEEE email:</h3>
<ul className="list-disc list-inside space-y-1">
<li>Professional communication with sponsors and partners</li>
<li>Consistent branding for IEEE UCSD</li>
<li>Separation between personal and IEEE communications</li>
<li>Access to IEEE UCSD shared resources</li>
</ul>
</div>
<div className="p-4 bg-base-200 rounded-lg">
<h3 className="font-bold text-lg mb-2">Your IEEE Email Address</h3>
<p className="text-sm mb-2">When you request an email, you'll receive:</p>
<p className="text-xl font-mono bg-base-100 p-2 rounded">
{user?.email ? `${user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9]/g, "")}@ieeeucsd.org` : 'Loading...'}
</p>
</div>
<button
className="btn btn-primary w-full"
onClick={toggleEmailForm}
>
Request IEEE Email Address
</button>
<div className="text-xs opacity-70">
<p>By requesting an email, you agree to use it responsibly and in accordance with IEEE UCSD policies.</p>
</div>
</>
) : (
<div className="p-4 bg-base-200 rounded-lg space-y-4">
<h3 className="font-bold text-lg">Create Your IEEE Email</h3>
<div className="p-4 bg-base-100 rounded-lg">
<p className="font-semibold">Your email address will be:</p>
<p className="text-xl font-mono mt-2">
{user?.email ? `${user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9]/g, "")}@ieeeucsd.org` : 'Loading...'}
</p>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Choose a Password</span>
</label>
<input
type="password"
className="input input-bordered"
value={initialPassword}
onChange={(e) => setInitialPassword(e.target.value)}
placeholder="Enter password (min. 8 characters)"
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Confirm Password</span>
</label>
<input
type="password"
className="input input-bordered"
value={initialConfirmPassword}
onChange={(e) => setInitialConfirmPassword(e.target.value)}
placeholder="Confirm password"
/>
</div>
{initialPasswordError && (
<div className="text-error text-sm">{initialPasswordError}</div>
)}
<p className="text-sm opacity-70">
Leave the password fields empty if you want a secure random password to be generated and sent to your personal email.
</p>
<div className="flex gap-2">
<button
className="btn btn-primary flex-1"
onClick={handleRequestEmail}
disabled={requesting}
>
{requesting ? (
<>
<span className="loading loading-spinner loading-sm"></span>
Creating Email...
</>
) : (
'Create IEEE Email'
)}
</button>
<button
className="btn btn-outline"
onClick={toggleEmailForm}
disabled={requesting}
>
Cancel
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -1,233 +0,0 @@
import { useState, useEffect } from 'react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Update } from '../../../scripts/pocketbase/Update';
import { Collections } from '../../../schemas/pocketbase/schema';
import { toast } from 'react-hot-toast';
// Default notification preferences
const DEFAULT_NOTIFICATION_PREFERENCES = {
emailNotifications: true,
eventReminders: true,
eventUpdates: true,
reimbursementUpdates: true,
officerAnnouncements: true,
marketingEmails: false
};
export default function NotificationSettings() {
const auth = Authentication.getInstance();
const update = Update.getInstance();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Notification preferences
const [preferences, setPreferences] = useState(DEFAULT_NOTIFICATION_PREFERENCES);
useEffect(() => {
const loadPreferences = async () => {
try {
const user = auth.getCurrentUser();
if (user) {
// If user has notification_preferences, parse and use them
// Otherwise use defaults
if (user.notification_preferences && typeof user.notification_preferences === 'string' && user.notification_preferences.trim() !== '') {
try {
const savedPrefs = JSON.parse(user.notification_preferences);
setPreferences(prev => ({
...prev,
...savedPrefs
}));
} catch (e) {
console.error('Error parsing notification preferences:', e);
// Initialize with defaults and save to user profile
await initializeDefaultPreferences(user.id);
}
} else {
// Initialize with defaults and save to user profile
await initializeDefaultPreferences(user.id);
}
}
} catch (error) {
console.error('Error loading notification preferences:', error);
toast.error('Failed to load notification preferences');
} finally {
setLoading(false);
}
};
loadPreferences();
}, []);
// Initialize default preferences if not set
const initializeDefaultPreferences = async (userId: string) => {
try {
await update.updateFields(
Collections.USERS,
userId,
{ notification_preferences: JSON.stringify(DEFAULT_NOTIFICATION_PREFERENCES) }
);
setPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
// console.log('Initialized default notification preferences');
} catch (error) {
console.error('Error initializing default notification preferences:', error);
}
};
const handleToggleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, checked } = e.target;
setPreferences(prev => ({
...prev,
[name]: checked
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const user = auth.getCurrentUser();
if (!user) throw new Error('User not authenticated');
// Save preferences as JSON string
await update.updateFields(
Collections.USERS,
user.id,
{ notification_preferences: JSON.stringify(preferences) }
);
toast.success('Notification preferences saved successfully!');
} catch (error) {
console.error('Error saving notification preferences:', error);
toast.error('Failed to save notification preferences');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center py-8">
<div className="loading loading-spinner loading-lg"></div>
</div>
);
}
return (
<div>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="emailNotifications"
className="toggle toggle-primary"
checked={preferences.emailNotifications}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Email Notifications</span>
<p className="text-xs opacity-70">Receive notifications via email</p>
</div>
</label>
</div>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="eventReminders"
className="toggle toggle-primary"
checked={preferences.eventReminders}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Event Reminders</span>
<p className="text-xs opacity-70">Receive reminders about upcoming events</p>
</div>
</label>
</div>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="eventUpdates"
className="toggle toggle-primary"
checked={preferences.eventUpdates}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Event Updates</span>
<p className="text-xs opacity-70">Receive updates about events you've registered for</p>
</div>
</label>
</div>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="reimbursementUpdates"
className="toggle toggle-primary"
checked={preferences.reimbursementUpdates}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Reimbursement Updates</span>
<p className="text-xs opacity-70">Receive updates about your reimbursement requests</p>
</div>
</label>
</div>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="officerAnnouncements"
className="toggle toggle-primary"
checked={preferences.officerAnnouncements}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Officer Announcements</span>
<p className="text-xs opacity-70">Receive important announcements from IEEE UCSD officers</p>
</div>
</label>
</div>
<div className="form-control">
<label className="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
name="marketingEmails"
className="toggle toggle-primary"
checked={preferences.marketingEmails}
onChange={handleToggleChange}
/>
<div>
<span className="label-text font-medium">Marketing Emails</span>
<p className="text-xs opacity-70">Receive promotional emails about IEEE UCSD events and opportunities</p>
</div>
</label>
</div>
</div>
<p className="text-sm text-info mt-6 mb-6">
Note: Some critical notifications about your account cannot be disabled.
</p>
<div className="form-control">
<button
type="submit"
className={`btn btn-primary ${saving ? 'loading' : ''}`}
disabled={saving}
>
{saving ? 'Saving...' : 'Save Preferences'}
</button>
</div>
</form>
</div>
);
}

View file

@ -1,550 +0,0 @@
import { useState, useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { SendLog } from '../../../scripts/pocketbase/SendLog';
interface PasswordChangeSettingsProps {
logtoAppId?: string;
logtoAppSecret?: string;
logtoEndpoint?: string;
logtoTokenEndpoint?: string;
logtoApiEndpoint?: string;
}
export default function PasswordChangeSettings({
logtoAppId: propLogtoAppId,
logtoAppSecret: propLogtoAppSecret,
logtoEndpoint: propLogtoEndpoint,
logtoTokenEndpoint: propLogtoTokenEndpoint,
logtoApiEndpoint: propLogtoApiEndpoint
}: PasswordChangeSettingsProps) {
const auth = Authentication.getInstance();
const logger = SendLog.getInstance();
const [isLoading, setIsLoading] = useState(false);
const [isCheckingEnv, setIsCheckingEnv] = useState(false);
const [useFormSubmission, setUseFormSubmission] = useState(false); // Default to using JSON
const [formData, setFormData] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [logtoUserId, setLogtoUserId] = useState('');
const [debugInfo, setDebugInfo] = useState<any>(null);
// Access environment variables directly
const envLogtoAppId = import.meta.env.LOGTO_APP_ID;
const envLogtoAppSecret = import.meta.env.LOGTO_APP_SECRET;
const envLogtoEndpoint = import.meta.env.LOGTO_ENDPOINT;
const envLogtoTokenEndpoint = import.meta.env.LOGTO_TOKEN_ENDPOINT;
const envLogtoApiEndpoint = import.meta.env.LOGTO_API_ENDPOINT;
// Use environment variables or props (fallback)
const logtoAppId = envLogtoAppId || propLogtoAppId;
const logtoAppSecret = envLogtoAppSecret || propLogtoAppSecret;
const logtoEndpoint = envLogtoEndpoint || propLogtoEndpoint;
const logtoTokenEndpoint = envLogtoTokenEndpoint || propLogtoTokenEndpoint;
const logtoApiEndpoint = envLogtoApiEndpoint || propLogtoApiEndpoint;
// Get the user's Logto ID on component mount
useEffect(() => {
const fetchLogtoUserId = async () => {
try {
const user = auth.getCurrentUser();
if (!user) {
// Don't show error on dashboard page for unauthenticated users
if (!window.location.pathname.includes('/dashboard')) {
console.error("User not authenticated");
toast.error("You must be logged in to change your password");
}
return;
}
// console.log("Current user:", user);
const pb = auth.getPocketBase();
try {
const externalAuthRecord = await pb.collection('_externalAuths').getFirstListItem(`recordRef="${user.id}" && provider="oidc"`);
// console.log("Found external auth record:", externalAuthRecord);
const userId = externalAuthRecord.providerId;
if (userId) {
setLogtoUserId(userId);
// console.log("Set Logto user ID:", userId);
} else {
console.error("No providerId found in external auth record");
toast.error("Could not determine your user ID. Please try again later or contact support.");
}
} catch (error) {
console.error("Error fetching external auth record:", error);
toast.error("Error retrieving your user information. Please try again later.");
// Try to get more information about the error
if (error instanceof Error) {
console.error("Error details:", error.message);
if ('data' in error) {
console.error("Error data:", (error as any).data);
}
}
}
} catch (error) {
console.error("Error fetching Logto user ID:", error);
toast.error("Error retrieving your user information. Please try again later.");
}
};
fetchLogtoUserId();
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const validateForm = () => {
if (!formData.currentPassword) {
toast.error('Current password is required');
return false;
}
if (!formData.newPassword) {
toast.error('New password is required');
return false;
}
if (formData.newPassword.length < 8) {
toast.error('New password must be at least 8 characters long');
return false;
}
if (formData.newPassword !== formData.confirmPassword) {
toast.error('New passwords do not match');
return false;
}
if (!logtoUserId) {
toast.error('Could not determine your user ID. Please try again later.');
return false;
}
return true;
};
const checkEnvironmentVariables = async () => {
setIsCheckingEnv(true);
try {
const response = await fetch('/api/check-env');
const data = await response.json();
// console.log("Environment variables status:", data);
// Check if all required environment variables are set
const { envStatus } = data;
const missingVars = Object.entries(envStatus)
.filter(([_, isSet]) => !isSet)
.map(([name]) => name);
if (missingVars.length > 0) {
toast.error(`Missing environment variables: ${missingVars.join(', ')}`);
} else {
toast.success('All environment variables are set');
}
} catch (error) {
console.error("Error checking environment variables:", error);
toast.error('Failed to check environment variables');
} finally {
setIsCheckingEnv(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
setDebugInfo(null);
try {
if (useFormSubmission) {
// Use a traditional form submission approach
const formElement = document.createElement('form');
formElement.method = 'POST';
formElement.action = '/api/change-password';
formElement.enctype = 'application/x-www-form-urlencoded';
// Add the userId field
const userIdField = document.createElement('input');
userIdField.type = 'hidden';
userIdField.name = 'userId';
userIdField.value = logtoUserId;
formElement.appendChild(userIdField);
// Add the newPassword field
const newPasswordField = document.createElement('input');
newPasswordField.type = 'hidden';
newPasswordField.name = 'newPassword';
newPasswordField.value = formData.newPassword;
formElement.appendChild(newPasswordField);
// If not using hardcoded endpoint, add the Logto credentials
const logtoAppIdField = document.createElement('input');
logtoAppIdField.type = 'hidden';
logtoAppIdField.name = 'logtoAppId';
logtoAppIdField.value = logtoAppId;
formElement.appendChild(logtoAppIdField);
const logtoAppSecretField = document.createElement('input');
logtoAppSecretField.type = 'hidden';
logtoAppSecretField.name = 'logtoAppSecret';
logtoAppSecretField.value = logtoAppSecret;
formElement.appendChild(logtoAppSecretField);
const logtoTokenEndpointField = document.createElement('input');
logtoTokenEndpointField.type = 'hidden';
logtoTokenEndpointField.name = 'logtoTokenEndpoint';
logtoTokenEndpointField.value = logtoTokenEndpoint;
formElement.appendChild(logtoTokenEndpointField);
const logtoApiEndpointField = document.createElement('input');
logtoApiEndpointField.type = 'hidden';
logtoApiEndpointField.name = 'logtoApiEndpoint';
logtoApiEndpointField.value = logtoApiEndpoint;
formElement.appendChild(logtoApiEndpointField);
// Create an iframe to handle the form submission
const iframe = document.createElement('iframe');
iframe.name = 'password-change-frame';
iframe.style.display = 'none';
document.body.appendChild(iframe);
// Set up the iframe load event to handle the response
iframe.onload = async () => {
try {
// Try to get the response from the iframe
const iframeDocument = iframe.contentDocument || iframe.contentWindow?.document;
if (iframeDocument) {
const responseText = iframeDocument.body.innerText;
// console.log("Response from iframe:", responseText);
if (responseText) {
try {
const result = JSON.parse(responseText);
if (result.success) {
// Log the password change
await logger.send('update', 'password', 'User changed their password');
toast.success('Password changed successfully');
// Clear form
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
} else {
toast.error(result.message || 'Failed to change password');
}
} catch (error) {
console.error("Error parsing response:", error);
toast.error('Failed to parse response from server');
}
} else {
// If no response text, assume success
// Log the password change
await logger.send('update', 'password', 'User changed their password');
toast.success('Password changed successfully');
// Clear form
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
}
} else {
console.error("Could not access iframe document");
toast.error('Could not access response from server');
}
} catch (error) {
console.error("Error handling iframe response:", error);
toast.error('Error handling response from server');
} finally {
// Clean up
document.body.removeChild(iframe);
setIsLoading(false);
}
};
// Set the target to the iframe
formElement.target = 'password-change-frame';
// Append the form to the document, submit it, and then remove it
document.body.appendChild(formElement);
formElement.submit();
document.body.removeChild(formElement);
// Note: setIsLoading(false) is called in the iframe.onload handler
} else {
// Use the fetch API with JSON
const endpoint = '/api/change-password';
// console.log(`Calling server-side API endpoint: ${endpoint}`);
// Ensure we have the Logto user ID
if (!logtoUserId) {
console.error("Logto user ID is missing");
throw new Error("User ID is missing. Please try again or contact support.");
}
// Log the values we're about to use
// console.log("Values being used for API call:");
// console.log("- logtoUserId:", logtoUserId);
// console.log("- newPassword:", formData.newPassword ? "[PRESENT]" : "[MISSING]");
// console.log("- logtoAppId:", logtoAppId);
// console.log("- logtoAppSecret:", logtoAppSecret ? "[PRESENT]" : "[MISSING]");
// console.log("- logtoTokenEndpoint:", logtoTokenEndpoint);
// console.log("- logtoApiEndpoint:", logtoApiEndpoint);
// Prepare request data with explicit values (not relying on variable references that might be undefined)
const requestData = {
userId: logtoUserId,
currentPassword: formData.currentPassword,
newPassword: formData.newPassword,
logtoAppId: logtoAppId || "",
logtoAppSecret: logtoAppSecret || "",
logtoTokenEndpoint: logtoTokenEndpoint || `${logtoEndpoint}/oidc/token`,
logtoApiEndpoint: logtoApiEndpoint || logtoEndpoint
};
// console.log("Request data:", {
// ...requestData,
// currentPassword: "[REDACTED]",
// newPassword: "[REDACTED]",
// logtoAppSecret: "[REDACTED]"
// });
// Validate request data before sending
if (!requestData.userId) {
throw new Error("Missing userId. Please try again or contact support.");
}
if (!requestData.newPassword) {
throw new Error("Missing newPassword. Please enter a new password.");
}
if (!requestData.logtoAppId) {
throw new Error("Missing logtoAppId configuration. Please contact support.");
}
if (!requestData.logtoAppSecret) {
throw new Error("Missing logtoAppSecret configuration. Please contact support.");
}
if (!requestData.logtoTokenEndpoint) {
throw new Error("Missing logtoTokenEndpoint configuration. Please contact support.");
}
if (!requestData.logtoApiEndpoint) {
throw new Error("Missing logtoApiEndpoint configuration. Please contact support.");
}
// Stringify the request data to ensure it's valid JSON
const requestBody = JSON.stringify(requestData);
// console.log("Request body (stringified):", requestBody);
// Create a debug object to display in the UI
const debugObj = {
endpoint,
requestData,
requestBody,
logtoUserId,
hasNewPassword: !!formData.newPassword,
hasLogtoAppId: !!logtoAppId,
hasLogtoAppSecret: !!logtoAppSecret,
hasLogtoTokenEndpoint: !!logtoTokenEndpoint,
hasLogtoApiEndpoint: !!logtoApiEndpoint
};
setDebugInfo(debugObj);
// Call our server-side API endpoint to change the password
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: requestBody
});
// console.log("Response status:", response.status);
// Process the response
let result: any;
try {
const responseText = await response.text();
// console.log("Raw response:", responseText);
if (responseText) {
result = JSON.parse(responseText);
} else {
result = { success: false, message: 'Empty response from server' };
}
// console.log("API response:", result);
// Add response to debug info
setDebugInfo((prev: any) => ({
...prev,
responseStatus: response.status,
responseText,
parsedResponse: result
}));
} catch (error) {
console.error("Error parsing API response:", error);
setDebugInfo((prev: any) => ({
...prev,
responseError: error instanceof Error ? error.message : String(error)
}));
throw new Error(`Invalid response from server: ${error instanceof Error ? error.message : String(error)}`);
}
// Check if the request was successful
if (response.ok && result.success) {
// Log the password change
await logger.send('update', 'password', 'User changed their password');
toast.success('Password changed successfully');
// Clear form
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
} else {
throw new Error(result.message || `Failed to change password: ${response.status}`);
}
setIsLoading(false);
}
} catch (error) {
console.error('Error changing password:', error);
toast.error(error instanceof Error ? error.message : 'Failed to change password');
setIsLoading(false);
}
};
return (
<div>
{process.env.NODE_ENV === 'development' && (
<div className="mb-4 p-3 bg-base-200 rounded-lg">
<h4 className="text-sm font-semibold mb-2">Debug Tools (Development Only)</h4>
<div className="flex flex-wrap gap-2 mb-2">
<button
type="button"
className={`btn btn-sm btn-info ${isCheckingEnv ? 'loading' : ''}`}
onClick={checkEnvironmentVariables}
disabled={isCheckingEnv}
>
Check Environment Variables
</button>
<button
type="button"
className="btn btn-sm btn-warning"
onClick={() => {
// console.log("Debug Info:");
// console.log("- logtoUserId:", logtoUserId);
// console.log("- Environment Variables:");
// console.log(" - LOGTO_APP_ID:", import.meta.env.LOGTO_APP_ID);
// console.log(" - LOGTO_ENDPOINT:", import.meta.env.LOGTO_ENDPOINT);
// console.log(" - LOGTO_TOKEN_ENDPOINT:", import.meta.env.LOGTO_TOKEN_ENDPOINT);
// console.log(" - LOGTO_API_ENDPOINT:", import.meta.env.LOGTO_API_ENDPOINT);
toast.success("Debug info logged to console");
}}
>
Log Debug Info
</button>
<button
type="button"
className={`btn btn-sm ${!useFormSubmission ? 'btn-success' : 'btn-outline'}`}
onClick={() => setUseFormSubmission(false)}
>
Use JSON API
</button>
</div>
<div className="mt-2 text-xs">
<p>Using fixed LogTo implementation with {useFormSubmission ? 'form submission' : 'JSON'}</p>
<p>Logto User ID: {logtoUserId || 'Not found'}</p>
</div>
{debugInfo && (
<div className="mt-4 border-t pt-2">
<p className="font-semibold">Debug Info:</p>
<div className="overflow-auto max-h-60 bg-base-300 p-2 rounded text-xs">
<pre>{JSON.stringify(debugInfo, null, 2)}</pre>
</div>
</div>
)}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="form-control">
<label className="label">
<span className="label-text">Current Password</span>
</label>
<input
type="password"
name="currentPassword"
value={formData.currentPassword}
onChange={handleInputChange}
className="input input-bordered w-full"
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">New Password</span>
</label>
<input
type="password"
name="newPassword"
value={formData.newPassword}
onChange={handleInputChange}
className="input input-bordered w-full"
required
minLength={8}
/>
<label className="label">
<span className="label-text-alt">Password must be at least 8 characters long</span>
</label>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Confirm New Password</span>
</label>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
className="input input-bordered w-full"
required
/>
</div>
<div className="form-control mt-6">
<button
type="submit"
className={`btn btn-primary ${isLoading ? 'loading' : ''}`}
disabled={isLoading}
>
{isLoading ? 'Changing Password...' : 'Change Password'}
</button>
</div>
</form>
</div>
);
}

View file

@ -1,580 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { FileManager } from '../../../scripts/pocketbase/FileManager';
import { Update } from '../../../scripts/pocketbase/Update';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections, type User } from '../../../schemas/pocketbase/schema';
import { toast } from 'react-hot-toast';
import FilePreview from '../universal/FilePreview';
export default function ResumeSettings() {
const auth = Authentication.getInstance();
const fileManager = FileManager.getInstance();
const update = Update.getInstance();
const get = Get.getInstance();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [resumeUrl, setResumeUrl] = useState<string | null>(null);
const [resumeFilename, setResumeFilename] = useState<string | null>(null);
const [resumeFile, setResumeFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Fetch user data on component mount
useEffect(() => {
const fetchUserData = async () => {
try {
setLoading(true);
const currentUser = auth.getCurrentUser();
if (!currentUser) {
// Don't show error toast on dashboard page for unauthenticated users
if (!window.location.pathname.includes('/dashboard')) {
throw new Error('User not authenticated');
}
return;
}
const userData = await get.getOne<User>('users', currentUser.id);
setUser(userData);
// Check if user has a resume
if (userData.resume) {
const resumeFile = userData.resume;
const fileUrl = fileManager.getFileUrl('users', userData.id, resumeFile);
setResumeUrl(fileUrl);
setResumeFilename(resumeFile);
}
} catch (error) {
console.error('Error fetching user data:', error);
// Don't show error toast on dashboard page for unauthenticated users
if (auth.isAuthenticated() || !window.location.pathname.includes('/dashboard')) {
toast.error('Failed to load user data');
}
} finally {
setLoading(false);
}
};
fetchUserData();
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
// Validate file type (PDF or DOCX)
const allowedTypes = ['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
const fileExtension = file.name.split('.').pop()?.toLowerCase();
// Check both MIME type and file extension
if (!allowedTypes.includes(file.type) &&
!(fileExtension === 'pdf' || fileExtension === 'docx')) {
toast.error('Please upload a PDF or DOCX file');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
return;
}
// Validate file size (max 50MB)
const maxSize = 50 * 1024 * 1024; // 50MB in bytes
if (file.size > maxSize) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
toast.error(`File too large (${sizeMB}MB). Maximum size is 50MB.`);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
return;
}
setResumeFile(file);
setResumeFilename(file.name);
toast.success(`File "${file.name}" selected. Click Upload to confirm.`);
}
};
const handleUpload = async () => {
if (!resumeFile || !user) {
toast.error('No file selected or user not authenticated');
return;
}
try {
setUploading(true);
// Create a FormData object for the file upload
const formData = new FormData();
formData.append('resume', resumeFile);
// Show progress toast
const loadingToast = toast.loading('Uploading resume...');
// Log the file being uploaded for debugging
// console.log('Uploading file:', {
// name: resumeFile.name,
// size: resumeFile.size,
// type: resumeFile.type
// });
let updatedUserData: User;
try {
// Use the FileManager to upload the file directly
// console.log('Using FileManager to upload resume file');
// Upload the file using the FileManager's uploadFile method
const result = await fileManager.uploadFile<User>(
Collections.USERS,
user.id,
'resume',
resumeFile,
false // Don't append, replace existing file
);
// Verify the file was uploaded by checking the response
if (!result || !result.resume) {
throw new Error('Resume was not properly saved to the user record');
}
// console.log('Resume upload successful:', result.resume);
// Store the updated user data
updatedUserData = result;
// Fetch the updated user record to ensure we have the latest data
const refreshedUser = await get.getOne<User>(Collections.USERS, user.id);
// console.log('Refreshed user data:', refreshedUser);
// Double check that the resume field is populated
if (!refreshedUser.resume) {
// console.warn('Resume field is missing in the refreshed user data');
}
} catch (uploadError) {
console.error('Error in file upload process:', uploadError);
toast.dismiss(loadingToast);
toast.error('Failed to upload resume: ' + (uploadError instanceof Error ? uploadError.message : 'Unknown error'));
setUploading(false);
return;
}
// Get the URL of the uploaded file
if (!updatedUserData.resume) {
throw new Error('Resume filename is missing in the updated user data');
}
const fileUrl = fileManager.getFileUrl('users', user.id, updatedUserData.resume);
// Update state with the new resume information
setResumeUrl(fileUrl);
setResumeFilename(updatedUserData.resume);
setResumeFile(null);
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
// Dismiss loading toast and show success message
toast.dismiss(loadingToast);
toast.success('Resume uploaded successfully');
// Log the successful upload
// console.log('Resume uploaded successfully:', updatedUserData.resume);
// Dispatch a custom event to notify the dashboard about the resume upload
const event = new CustomEvent('resumeUploaded', {
detail: { hasResume: true }
});
window.dispatchEvent(event);
} catch (error) {
console.error('Error uploading resume:', error);
// Provide more specific error messages based on the error type
if (error instanceof Error) {
if (error.message.includes('size')) {
toast.error('File size exceeds the maximum allowed limit (50MB)');
} else if (error.message.includes('type')) {
toast.error('Invalid file type. Please upload a PDF or DOCX file');
} else if (error.message.includes('network')) {
toast.error('Network error. Please check your connection and try again');
} else if (error.message.includes('permission')) {
toast.error('Permission denied. You may not have the right permissions');
} else {
toast.error(`Upload failed: ${error.message}`);
}
} else {
toast.error('Failed to upload resume. Please try again later');
}
} finally {
setUploading(false);
}
};
const handleDelete = async () => {
if (!user) {
toast.error('User not authenticated');
return;
}
// Ask for confirmation before deleting
if (!confirm('Are you sure you want to delete your resume? This action cannot be undone.')) {
return;
}
try {
setUploading(true);
// Show progress toast
const loadingToast = toast.loading('Deleting resume...');
// Log the deletion attempt
// console.log('Attempting to delete resume for user:', user.id);
// Create a FormData with empty resume field to remove the file
const formData = new FormData();
formData.append('resume', '');
try {
// console.log('Using FileManager to delete resume file');
// Use the FileManager's deleteFile method to remove the file
const result = await fileManager.deleteFile<User>(
Collections.USERS,
user.id,
'resume'
);
// Verify the file was deleted
if (result.resume) {
// console.warn('Resume field still exists after deletion attempt:', result.resume);
toast.dismiss(loadingToast);
toast.error('Failed to completely remove the resume. Please try again.');
setUploading(false);
return;
}
// console.log('Resume deletion successful for user:', user.id);
// Fetch the updated user record to ensure we have the latest data
const refreshedUser = await get.getOne<User>(Collections.USERS, user.id);
// console.log('Refreshed user data after deletion:', refreshedUser);
// Double check that the resume field is empty
if (refreshedUser.resume) {
// console.warn('Resume field is still present in the refreshed user data:', refreshedUser.resume);
}
} catch (deleteError) {
console.error('Error in file deletion process:', deleteError);
toast.dismiss(loadingToast);
toast.error('Failed to delete resume: ' + (deleteError instanceof Error ? deleteError.message : 'Unknown error'));
setUploading(false);
return;
}
// Update state to reflect the deletion
setResumeUrl(null);
setResumeFilename(null);
// Dismiss loading toast and show success message
toast.dismiss(loadingToast);
toast.success('Resume deleted successfully');
// Log the successful deletion
// console.log('Resume deleted successfully for user:', user.id);
// Dispatch a custom event to notify the dashboard about the resume deletion
const event = new CustomEvent('resumeUploaded', {
detail: { hasResume: false }
});
window.dispatchEvent(event);
} catch (error) {
console.error('Error deleting resume:', error);
// Provide more specific error messages based on the error type
if (error instanceof Error) {
if (error.message.includes('permission')) {
toast.error('Permission denied. You may not have the right permissions');
} else if (error.message.includes('network')) {
toast.error('Network error. Please check your connection and try again');
} else {
toast.error(`Deletion failed: ${error.message}`);
}
} else {
toast.error('Failed to delete resume. Please try again later');
}
} finally {
setUploading(false);
}
};
const handleReplace = async () => {
if (!user) {
toast.error('User not authenticated');
return;
}
// Create a file input element
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.pdf,.docx';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
// Trigger click to open file dialog
fileInput.click();
// Handle file selection
fileInput.onchange = async (e) => {
const input = e.target as HTMLInputElement;
if (!input.files || input.files.length === 0) {
document.body.removeChild(fileInput);
return;
}
const file = input.files[0];
// Validate file type (PDF or DOCX)
const allowedTypes = ['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (!allowedTypes.includes(file.type) &&
!(fileExtension === 'pdf' || fileExtension === 'docx')) {
toast.error('Please upload a PDF or DOCX file');
document.body.removeChild(fileInput);
return;
}
// Validate file size (max 50MB)
const maxSize = 50 * 1024 * 1024; // 50MB in bytes
if (file.size > maxSize) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
toast.error(`File too large (${sizeMB}MB). Maximum size is 50MB.`);
document.body.removeChild(fileInput);
return;
}
try {
setUploading(true);
// Show progress toast
const loadingToast = toast.loading('Replacing resume...');
// Log the file being uploaded for debugging
// console.log('Replacing resume with file:', {
// name: file.name,
// size: file.size,
// type: file.type
// });
// Create a FormData object for the file upload
const formData = new FormData();
formData.append('resume', file);
// Use the update service to directly update the user record
const result = await update.updateFields<User>(
Collections.USERS,
user.id,
formData
);
// Verify the file was uploaded
if (!result || !result.resume) {
throw new Error('Resume was not properly saved to the user record');
}
// Get the URL of the uploaded file
const fileUrl = fileManager.getFileUrl('users', user.id, result.resume);
// Update state with the new resume information
setResumeUrl(fileUrl);
setResumeFilename(result.resume);
setResumeFile(null);
// Dismiss loading toast and show success message
toast.dismiss(loadingToast);
toast.success('Resume replaced successfully');
// Dispatch a custom event to notify the dashboard about the resume upload
const event = new CustomEvent('resumeUploaded', {
detail: { hasResume: true }
});
window.dispatchEvent(event);
} catch (error) {
console.error('Error replacing resume:', error);
toast.error('Failed to replace resume: ' + (error instanceof Error ? error.message : 'Unknown error'));
} finally {
setUploading(false);
document.body.removeChild(fileInput);
}
};
};
return (
<div className="space-y-6">
{loading ? (
<div className="flex justify-center items-center py-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
) : (
<>
{/* Resume Upload Section */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-lg font-medium">Resume</h3>
<p className="text-sm opacity-70">
Upload your resume for recruiters and career opportunities
</p>
</div>
{!resumeUrl && (
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".pdf,.docx"
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="btn btn-primary btn-sm gap-2"
disabled={uploading}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
Select Resume
</button>
</div>
)}
</div>
{/* File selected but not uploaded yet */}
{resumeFile && !uploading && (
<div className="bg-base-200 p-4 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-primary/10 p-2 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-primary" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
</svg>
</div>
<div>
<p className="font-medium truncate max-w-xs">{resumeFile.name}</p>
<p className="text-xs opacity-70">{(resumeFile.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleUpload}
className="btn btn-primary btn-sm"
disabled={uploading}
>
Upload
</button>
<button
onClick={() => {
setResumeFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
className="btn btn-ghost btn-sm"
disabled={uploading}
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Uploading state */}
{uploading && (
<div className="bg-base-200 p-4 rounded-lg">
<div className="flex items-center gap-3">
<span className="loading loading-spinner loading-sm"></span>
<span>Processing your resume...</span>
</div>
<progress className="progress progress-primary w-full mt-2"></progress>
</div>
)}
{/* Resume preview */}
{resumeUrl && resumeFilename && !resumeFile && !uploading && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h4 className="font-medium">Current Resume</h4>
<div className="flex gap-2">
<button
onClick={handleReplace}
className="btn btn-sm btn-outline gap-1"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
</svg>
Replace
</button>
<button
onClick={handleDelete}
className="btn btn-sm btn-error btn-outline gap-1"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
Delete
</button>
</div>
</div>
<div className="border border-base-300 rounded-lg overflow-hidden">
<FilePreview
url={resumeUrl}
filename={resumeFilename}
/>
</div>
</div>
)}
{/* Resume upload guidelines */}
<div className="bg-base-200/50 p-4 rounded-lg mt-4">
<h4 className="font-medium mb-2 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-info" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
Resume Guidelines
</h4>
<ul className="list-disc list-inside text-sm opacity-70 space-y-1">
<li>Accepted formats: PDF, DOCX</li>
<li>Maximum file size: 50MB</li>
<li>Keep your resume up-to-date for better opportunities</li>
<li>Highlight your skills, experience, and education</li>
</ul>
</div>
{/* Resume Benefits */}
<div className="bg-primary/10 p-4 rounded-lg mt-4">
<h4 className="font-medium mb-2 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Why Upload Your Resume?
</h4>
<ul className="list-disc list-inside text-sm space-y-1">
<li className="text-primary-focus">Increase visibility to recruiters and industry partners</li>
<li className="text-primary-focus">Get matched with relevant job opportunities</li>
<li className="text-primary-focus">Simplify application process for IEEE UCSD events</li>
<li className="text-primary-focus">Access personalized career resources and recommendations</li>
</ul>
</div>
</div>
</>
)}
</div>
);
}

View file

@ -1,561 +0,0 @@
import { useState, useEffect } from 'react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Update } from '../../../scripts/pocketbase/Update';
import { Collections, type User } from '../../../schemas/pocketbase/schema';
import { toast } from 'react-hot-toast';
// Define the majors directly in the component file to avoid import issues
const UCSD_MAJORS = [
"Astronomy & Astrophysics",
"Anthropology",
"Bioengineering",
"Biology with Specialization in Bioinformatics",
"Ecology, Behavior and Evolution",
"General Biology",
"Human Biology",
"Microbiology",
"Molecular and Cell Biology",
"Neurobiology / Physiology and Neuroscience",
"Biochemistry and Cell Biology",
"Biochemistry",
"Chemistry",
"Environmental Chemistry",
"Molecular Synthesis",
"Pharmacological Chemistry",
"Chinese Studies",
"Cinematic Arts",
"Classical Studies",
"Cognitive Science",
"Cognitive Science Clinical Aspects of Cognition",
"Cognitive Science Design and Interaction",
"Cognitive Science Language and Culture",
"Cognitive Science Machine Learning and Neural Computation",
"Cognitive Science Neuroscience",
"Cognitive and Behavioral Neuroscience",
"Communication",
"Media Industries and Communication",
"Computer Engineering",
"Computer Science",
"Computer Science Bioinformatics",
"Critical Gender Studies",
"Dance",
"Data Science",
"Business Economics",
"Economics",
"Economics and Mathematics Joint Major",
"Economics-Public Policy",
"Education Sciences",
"Electrical Engineering",
"Electrical Engineering and Society",
"Engineering Physics",
"Environmental Systems (Earth Sciences)",
"Environmental Systems (Ecology, Behavior & Evolution)",
"Environmental Systems (Environmental Chemistry)",
"Environmental Systems (Environmental Policy)",
"Ethnic Studies",
"German Studies",
"Global Health",
"Global South Studies",
"Public Health",
"Public Health Biostatistics",
"Public Health Climate and Environmental Sciences",
"Public Health Community Health Sciences",
"Public Health Epidemiology",
"Public Health Health Policy and Management Sciences",
"Public Health Medicine Sciences",
"History",
"Human Developmental Sciences",
"Human Developmental Sciences Equity and Diversity",
"Human Developmental Sciences Healthy Aging",
"International Studies Anthropology",
"International Studies Economics",
"International Studies Economics (Joint BA/MIA)",
"International Studies History",
"International Studies International Business",
"International Studies International Business (Joint BA/MIA)",
"International Studies Linguistics",
"International Studies Literature",
"International Studies Philosophy",
"International Studies Political Science",
"International Studies Political Science (Joint BA/MIA)",
"International Studies Sociology",
"Italian Studies",
"Japanese Studies",
"Jewish Studies",
"Latin American Studies",
"Latin American Studies Mexico",
"Latin American Studies Migration and Border Studies",
"Linguistics",
"Linguistics Cognition and Language",
"Linguistics Language and Society",
"Linguistics: Language Studies",
"Linguistics Speech and Language Sciences",
"Literary Arts",
"Literatures in English",
"Spanish Literature",
"World Literature and Culture",
"Mathematical Biology",
"Mathematics (Applied)",
"Mathematics",
"Mathematics Applied Science",
"Mathematics Computer Science",
"Mathematics Secondary Education",
"Probability and Statistics",
"Aerospace Engineering",
"Aerospace Engineering Aerothermodynamics",
"Aerospace Engineering Astrodynamics and Space Applications",
"Aerospace Engineering Flight Dynamics and Controls",
"Mechanical Engineering",
"Mechanical Engineering Controls and Robotics",
"Mechanical Engineering Fluid Mechanics and Thermal Systems",
"Mechanical Engineering Materials Science and Engineering",
"Mechanical Engineering Mechanics of Materials",
"Mechanical Engineering Renewable Energy and Environmental Flows",
"Music",
"Music Humanities",
"Interdisciplinary Computing and the Arts",
"Chemical Engineering",
"NanoEngineering",
"Philosophy",
"General Physics",
"General Physics/Secondary Education",
"Physics",
"Physics Astrophysics",
"Physics Biophysics",
"Physics Computational Physics",
"Physics Earth Sciences",
"Physics Materials Physics",
"Political Science",
"Political Science American Politics",
"Political Science Comparative Politics",
"Political Science Data Analytics",
"Political Science International Affairs",
"Political Science International Relations",
"Political Science Political Theory",
"Political Science Public Law",
"Political Science Public Policy",
"Political Science Race, Ethnicity, and Politics",
"Psychology",
"Psychology Clinical Psychology",
"Psychology Cognitive Psychology",
"Psychology Developmental Psychology",
"Psychology Human Health",
"Psychology Sensation and Perception",
"Psychology Social Psychology",
"Business Psychology",
"Study of Religion",
"Russian, East European & Eurasian Studies",
"Geosciences",
"Marine Biology",
"Oceanic and Atmospheric Sciences",
"Sociology",
"Sociology International Studies",
"Sociology American Studies",
"Sociology Science and Medicine",
"Sociology Economy and Society",
"Sociology Culture and Communication",
"Sociology Social Inequality",
"Sociology Law and Society",
"Structural Engineering",
"Structural Engineering Aerospace Structures",
"Structural Engineering Civil Structures",
"Structural Engineering Geotechnical Engineering",
"Structural Engineering Structural Health Monitoring/Non-Destructive Evaluation",
"Theatre",
"Undeclared Humanities/Arts",
"Undeclared Physical Sciences",
"Undeclared Social Sciences",
"Urban Studies and Planning",
"Real Estate and Development",
"Art History/Criticism",
"Media",
"Speculative Design",
"Studio",
"Other"
].sort();
interface UserProfileSettingsProps {
logtoApiEndpoint?: string;
}
export default function UserProfileSettings({
logtoApiEndpoint: propLogtoApiEndpoint
}: UserProfileSettingsProps) {
const auth = Authentication.getInstance();
const update = Update.getInstance();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [logtoUserId, setLogtoUserId] = useState('');
const [formData, setFormData] = useState({
name: '',
email: '',
username: '',
major: '',
graduation_year: '',
zelle_information: '',
pid: '',
member_id: ''
});
// Access environment variables directly
const envLogtoApiEndpoint = import.meta.env.LOGTO_API_ENDPOINT;
// Use environment variables or props (fallback)
const logtoApiEndpoint = envLogtoApiEndpoint || propLogtoApiEndpoint;
useEffect(() => {
const loadUserData = async () => {
try {
const currentUser = auth.getCurrentUser();
if (!currentUser) {
// Don't show error toast on dashboard page for unauthenticated users
if (!window.location.pathname.includes('/dashboard')) {
throw new Error('User not authenticated');
}
return;
}
// Get the Logto user ID from PocketBase's external auth collection
const pb = auth.getPocketBase();
try {
const externalAuthRecord = await pb.collection('_externalAuths').getFirstListItem(`recordRef="${currentUser.id}" && provider="oidc"`);
const logtoId = externalAuthRecord.providerId;
if (!logtoId) {
throw new Error('No Logto ID found in external auth record');
}
setLogtoUserId(logtoId);
// Fetch user data from Logto through our server-side API
const logtoResponse = await fetch('/api/get-logto-user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: logtoId,
logtoApiEndpoint: logtoApiEndpoint
})
});
if (!logtoResponse.ok) {
throw new Error('Failed to fetch Logto user data');
}
const logtoUser = await logtoResponse.json();
// Extract username from Logto data or email if not set
const defaultUsername = logtoUser.data?.username || currentUser.email?.split('@')[0] || '';
// Remove all the major matching logic and just use the server value directly
setUser(currentUser);
setFormData({
name: currentUser.name || '',
email: currentUser.email || '',
username: defaultUsername,
major: currentUser.major || '',
graduation_year: currentUser.graduation_year?.toString() || '',
zelle_information: currentUser.zelle_information || '',
pid: currentUser.pid || '',
member_id: currentUser.member_id || ''
});
// If username is blank in Logto, update it
if (!logtoUser.data?.username && currentUser.email) {
try {
const emailUsername = currentUser.email.split('@')[0];
await updateLogtoUser(logtoId, emailUsername);
} catch (error) {
console.error('Error setting default username:', error);
}
}
} catch (error) {
console.error('Error fetching external auth record:', error);
// Don't show error toast on dashboard page for unauthenticated users
if (auth.isAuthenticated() || !window.location.pathname.includes('/dashboard')) {
toast.error('Could not determine your user ID. Please try again later or contact support.');
}
}
} catch (error) {
console.error('Error loading user data:', error);
// Don't show error toast on dashboard page for unauthenticated users
if (auth.isAuthenticated() || !window.location.pathname.includes('/dashboard')) {
toast.error('Failed to load user data. Please try again later.');
}
} finally {
setLoading(false);
}
};
loadUserData();
}, [logtoApiEndpoint]);
const updateLogtoUser = async (userId: string, username: string) => {
try {
// First get the current user data from Logto through our server-side API
const getCurrentResponse = await fetch('/api/get-logto-user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId,
logtoApiEndpoint
})
});
if (!getCurrentResponse.ok) {
throw new Error('Failed to fetch current Logto user data');
}
const currentLogtoUser = await getCurrentResponse.json();
// Now update the user with new username through our server-side API
const response = await fetch('/api/update-logto-user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId,
username,
logtoApiEndpoint,
profile: {
...currentLogtoUser.data?.profile,
preferredUsername: username
}
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update Logto username');
}
} catch (error) {
console.error('Error updating Logto username:', error);
throw error;
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
if (!user) throw new Error('User not authenticated');
if (!logtoUserId) throw new Error('Could not determine your user ID');
// Update username in Logto if changed
if (formData.username !== user.username) {
await updateLogtoUser(logtoUserId, formData.username);
}
const updateData: Partial<User> = {
name: formData.name,
major: formData.major || undefined,
zelle_information: formData.zelle_information || undefined,
pid: formData.pid || undefined,
member_id: formData.member_id || undefined
};
// Only include graduation_year if it's a valid number
if (formData.graduation_year && !isNaN(Number(formData.graduation_year))) {
updateData.graduation_year = Number(formData.graduation_year);
}
await update.updateFields(Collections.USERS, user.id, updateData);
// Update local user state
setUser(prev => prev ? { ...prev, ...updateData } : null);
toast.success('Profile updated successfully!');
} catch (error) {
console.error('Error updating profile:', error);
toast.error('Failed to update profile. Please try again.');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center py-8">
<div className="loading loading-spinner loading-lg"></div>
</div>
);
}
if (!user) {
return (
<div className="alert alert-error">
<div>
<span>You must be logged in to access this page.</span>
</div>
</div>
);
}
return (
<div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="form-control">
<label className="label">
<span className="label-text">Full Name</span>
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
className="input input-bordered w-full"
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Username</span>
</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleInputChange}
className="input input-bordered w-full"
pattern="^[A-Z_a-z]\w*$"
title="Username must start with a letter or underscore and can contain only letters, numbers, and underscores"
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Email Address</span>
<span className="label-text-alt text-info">Cannot be changed</span>
</label>
<input
type="email"
name="email"
value={formData.email}
className="input input-bordered w-full"
disabled
/>
<label className="label">
<span className="label-text-alt">Email changes must be processed by an administrator</span>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">PID</span>
<span className="label-text-alt text-info">UCSD Student ID</span>
</label>
<input
type="text"
name="pid"
value={formData.pid}
onChange={handleInputChange}
className="input input-bordered w-full"
placeholder="A12345678"
pattern="[A-Za-z][0-9]{8}"
title="PID format: A12345678"
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">IEEE Member ID</span>
<span className="label-text-alt text-info">Optional</span>
</label>
<input
type="text"
name="member_id"
value={formData.member_id}
onChange={handleInputChange}
className="input input-bordered w-full"
placeholder="IEEE Membership Number"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">Major</span>
</label>
<select
name="major"
value={formData.major}
onChange={handleInputChange}
className="select select-bordered w-full"
>
<option value="">Select a major</option>
{(() => {
// Create a list including both standard majors and the user's major if it's not in the list
const displayMajors = [...UCSD_MAJORS];
if (formData.major && !displayMajors.includes(formData.major)) {
displayMajors.push(formData.major);
displayMajors.sort((a, b) => a.localeCompare(b));
}
return displayMajors.map((major, index) => (
<option key={index} value={major}>
{major}
</option>
));
})()}
</select>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Graduation Year</span>
</label>
<input
type="number"
name="graduation_year"
value={formData.graduation_year}
onChange={handleInputChange}
className="input input-bordered w-full"
min="2000"
max="2100"
/>
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Zelle Information (for reimbursements)</span>
</label>
<input
type="text"
name="zelle_information"
value={formData.zelle_information}
onChange={handleInputChange}
className="input input-bordered w-full"
placeholder="Email or phone number associated with your Zelle account"
/>
</div>
<div className="form-control mt-6">
<button
type="submit"
className={`btn btn-primary ${saving ? 'loading' : ''}`}
disabled={saving}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
);
}

View file

@ -1,334 +0,0 @@
---
import EventAttendanceChart from "./SponsorAnalyticsSection/EventAttendanceChart";
import EventTypeDistribution from "./SponsorAnalyticsSection/EventTypeDistribution";
import MajorDistribution from "./SponsorAnalyticsSection/MajorDistribution";
import EventEngagementMetrics from "./SponsorAnalyticsSection/EventEngagementMetrics";
import EventTimeline from "./SponsorAnalyticsSection/EventTimeline";
---
<div class="space-y-6">
<div
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4"
>
<div>
<h2 class="text-2xl font-bold">Event Analytics</h2>
<p class="text-base-content/70">
Insights and analytics about IEEE UCSD events and student
engagement
</p>
</div>
<div class="flex items-center gap-2">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm">
<span>Time Range</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down"
>
<path d="m6 9 6 6 6-6"></path>
</svg>
</div>
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
>
<li><a data-time-range="30">Last 30 Days</a></li>
<li><a data-time-range="90">Last 90 Days</a></li>
<li><a data-time-range="180">Last 6 Months</a></li>
<li><a data-time-range="365">Last Year</a></li>
<li><a data-time-range="all">All Time</a></li>
</ul>
</div>
<button id="refreshAnalyticsBtn" class="btn btn-sm btn-outline">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-refresh-cw"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
></path>
<path d="M21 3v5h-5"></path>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
></path>
<path d="M3 21v-5h5"></path>
</svg>
<span>Refresh</span>
</button>
</div>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<h3 class="text-sm font-medium text-base-content/70">
Total Events
</h3>
<p class="text-3xl font-bold" id="totalEventsCount">--</p>
<div class="text-xs text-base-content/50 mt-1">
<span id="eventsTrend" class="font-medium"></span> vs previous
period
</div>
</div>
</div>
<div class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<h3 class="text-sm font-medium text-base-content/70">
Total Attendees
</h3>
<p class="text-3xl font-bold" id="totalAttendeesCount">--</p>
<div class="text-xs text-base-content/50 mt-1">
<span id="attendeesTrend" class="font-medium"></span> vs previous
period
</div>
</div>
</div>
<div class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<h3 class="text-sm font-medium text-base-content/70">
Unique Students
</h3>
<p class="text-3xl font-bold" id="uniqueStudentsCount">--</p>
<div class="text-xs text-base-content/50 mt-1">
<span id="uniqueStudentsTrend" class="font-medium"></span> vs
previous period
</div>
</div>
</div>
<div class="card bg-base-100 shadow-md">
<div class="card-body p-4">
<h3 class="text-sm font-medium text-base-content/70">
Avg. Attendance
</h3>
<p class="text-3xl font-bold" id="avgAttendanceCount">--</p>
<div class="text-xs text-base-content/50 mt-1">
<span id="avgAttendanceTrend" class="font-medium"></span> vs
previous period
</div>
</div>
</div>
</div>
<!-- Charts Row 1 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Event Attendance Over Time</h3>
<div class="h-80">
<EventAttendanceChart client:load />
</div>
</div>
</div>
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Major Distribution</h3>
<div class="h-80">
<MajorDistribution client:load />
</div>
</div>
</div>
</div>
<!-- Charts Row 2 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Event Type Distribution</h3>
<div class="h-80">
<EventTypeDistribution client:load />
</div>
</div>
</div>
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Engagement Metrics</h3>
<div class="h-80">
<EventEngagementMetrics client:load />
</div>
</div>
</div>
</div>
<!-- Event Timeline -->
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h3 class="card-title text-lg">Event Timeline</h3>
<div class="overflow-x-auto">
<EventTimeline client:load />
</div>
</div>
</div>
</div>
<script>
import { Authentication } from "../../scripts/pocketbase/Authentication";
import { Get } from "../../scripts/pocketbase/Get";
import { Realtime } from "../../scripts/pocketbase/Realtime";
import { Collections } from "../../schemas/pocketbase/schema";
// Initialize services
const auth = Authentication.getInstance();
const get = Get.getInstance();
const realtime = Realtime.getInstance();
// Default time range (30 days)
let currentTimeRange = 30;
// Initialize the analytics dashboard
async function initAnalytics() {
if (!auth.isAuthenticated()) {
console.error("User not authenticated");
return;
}
try {
await loadSummaryData(currentTimeRange);
// Set up event listeners
document
.querySelectorAll("[data-time-range]")
.forEach((element) => {
element.addEventListener("click", (e) => {
const range =
parseInt(
e.currentTarget.getAttribute("data-time-range")
) || 30;
currentTimeRange = isNaN(range) ? "all" : range;
loadSummaryData(currentTimeRange);
});
});
// Refresh button
document
.getElementById("refreshAnalyticsBtn")
?.addEventListener("click", () => {
loadSummaryData(currentTimeRange);
});
// Set up realtime updates
setupRealtimeUpdates();
} catch (error) {
console.error("Error initializing analytics:", error);
}
}
// Load summary data
async function loadSummaryData(timeRange) {
try {
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - timeRange);
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Build filter
const filter =
timeRange === "all"
? "published = true"
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
// Get events
const events = await get.getAll(Collections.EVENTS, filter);
// Get event attendees
const attendeesFilter =
timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll(
Collections.EVENT_ATTENDEES,
attendeesFilter
);
// Calculate metrics
const totalEvents = events.length;
const totalAttendees = attendees.length;
// Calculate unique students
const uniqueStudentIds = new Set(attendees.map((a) => a.user));
const uniqueStudents = uniqueStudentIds.size;
// Calculate average attendance
const avgAttendance =
totalEvents > 0 ? Math.round(totalAttendees / totalEvents) : 0;
// Update UI
document.getElementById("totalEventsCount").textContent =
totalEvents;
document.getElementById("totalAttendeesCount").textContent =
totalAttendees;
document.getElementById("uniqueStudentsCount").textContent =
uniqueStudents;
document.getElementById("avgAttendanceCount").textContent =
avgAttendance;
// Calculate trends (simplified - would need previous period data for real implementation)
document.getElementById("eventsTrend").textContent = "+5%";
document.getElementById("attendeesTrend").textContent = "+12%";
document.getElementById("uniqueStudentsTrend").textContent = "+8%";
document.getElementById("avgAttendanceTrend").textContent = "+3%";
// Dispatch custom event to notify charts to update
window.dispatchEvent(
new CustomEvent("analyticsDataUpdated", {
detail: {
events,
attendees,
timeRange,
},
})
);
} catch (error) {
console.error("Error loading summary data:", error);
}
}
// Set up realtime updates
function setupRealtimeUpdates() {
// Subscribe to events collection
realtime.subscribeToCollection(Collections.EVENTS, (data) => {
console.log("Event data updated:", data);
loadSummaryData(currentTimeRange);
});
// Subscribe to event attendees collection
realtime.subscribeToCollection(Collections.EVENT_ATTENDEES, (data) => {
console.log("Attendee data updated:", data);
loadSummaryData(currentTimeRange);
});
}
// Initialize when document is ready
document.addEventListener("DOMContentLoaded", initAnalytics);
</script>

View file

@ -1,249 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { Event, EventAttendee } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
// Import Chart.js
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
BarElement,
} from 'chart.js';
import type { ChartOptions } from 'chart.js';
import { Line } from 'react-chartjs-2';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend
);
export default function EventAttendanceChart() {
const [chartData, setChartData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
const chartRef = useRef<any>(null);
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for analytics data updates from the parent component
const handleAnalyticsUpdate = (event: CustomEvent) => {
const { events, attendees, timeRange } = event.detail;
setTimeRange(timeRange);
processChartData(events, attendees);
};
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
// Initial data load
loadData();
return () => {
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
};
}, []);
const loadData = async () => {
try {
setLoading(true);
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Build filter
const filter = timeRange === "all"
? "published = true"
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
// Get events
const events = await get.getAll<Event>(Collections.EVENTS, filter);
// Get event attendees
const attendeesFilter = timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
processChartData(events, attendees);
} catch (err) {
console.error('Error loading event attendance data:', err);
setError('Failed to load event attendance data');
} finally {
setLoading(false);
}
};
const processChartData = (events: Event[], attendees: EventAttendee[]) => {
if (!events || events.length === 0) {
setChartData(null);
return;
}
// Group events by date
const eventsByDate = new Map<string, Event[]>();
events.forEach(event => {
// Format date to YYYY-MM-DD
const date = new Date(event.start_date);
const dateStr = date.toISOString().split('T')[0];
if (!eventsByDate.has(dateStr)) {
eventsByDate.set(dateStr, []);
}
eventsByDate.get(dateStr)!.push(event);
});
// Count attendees per event
const attendeesByEvent = new Map<string, number>();
attendees.forEach(attendee => {
if (!attendeesByEvent.has(attendee.event)) {
attendeesByEvent.set(attendee.event, 0);
}
attendeesByEvent.set(attendee.event, attendeesByEvent.get(attendee.event)! + 1);
});
// Calculate average attendance per date
const attendanceByDate = new Map<string, { total: number, count: number }>();
events.forEach(event => {
const date = new Date(event.start_date);
const dateStr = date.toISOString().split('T')[0];
if (!attendanceByDate.has(dateStr)) {
attendanceByDate.set(dateStr, { total: 0, count: 0 });
}
const attendeeCount = attendeesByEvent.get(event.id) || 0;
const current = attendanceByDate.get(dateStr)!;
attendanceByDate.set(dateStr, {
total: current.total + attendeeCount,
count: current.count + 1
});
});
// Sort dates
const sortedDates = Array.from(attendanceByDate.keys()).sort();
// Calculate average attendance per date
const averageAttendance = sortedDates.map(date => {
const { total, count } = attendanceByDate.get(date)!;
return count > 0 ? Math.round(total / count) : 0;
});
// Format dates for display
const formattedDates = sortedDates.map(date => {
const [year, month, day] = date.split('-');
return `${month}/${day}`;
});
// Create chart data
const data = {
labels: formattedDates,
datasets: [
{
label: 'Average Attendance',
data: averageAttendance,
borderColor: 'rgba(75, 192, 192, 1)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.4,
fill: true,
}
]
};
setChartData(data);
};
const chartOptions: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
tooltip: {
mode: 'index',
intersect: false,
},
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Average Attendance'
}
},
x: {
title: {
display: true,
text: 'Date'
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-error">{error}</div>
</div>
);
}
if (!chartData) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-center text-base-content/70">
<p>No event data available for the selected time period</p>
</div>
</div>
);
}
return (
<div className="h-full">
<Line data={chartData} options={chartOptions} />
</div>
);
}

View file

@ -1,334 +0,0 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { Event, EventAttendee, User } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
// Import Chart.js
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
RadialLinearScale,
} from 'chart.js';
import { Radar } from 'react-chartjs-2';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
RadialLinearScale,
Title,
Tooltip,
Legend
);
export default function EventEngagementMetrics() {
const [chartData, setChartData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for analytics data updates from the parent component
const handleAnalyticsUpdate = (event: CustomEvent) => {
const { events, attendees, timeRange } = event.detail;
setTimeRange(timeRange);
loadUserData(events, attendees);
};
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
// Initial data load
loadData();
return () => {
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
};
}, []);
const loadData = async () => {
try {
setLoading(true);
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Build filter
const filter = timeRange === "all"
? "published = true"
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
// Get events
const events = await get.getAll<Event>(Collections.EVENTS, filter);
// Get event attendees
const attendeesFilter = timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
await loadUserData(events, attendees);
} catch (err) {
console.error('Error loading engagement metrics data:', err);
setError('Failed to load engagement metrics data');
} finally {
setLoading(false);
}
};
const loadUserData = async (events: Event[], attendees: EventAttendee[]) => {
try {
if (!events || events.length === 0 || !attendees || attendees.length === 0) {
setChartData(null);
return;
}
// Get unique user IDs from attendees
const userIds = [...new Set(attendees.map(a => a.user))];
// Fetch user data to get graduation years
const users = await get.getMany<User>(Collections.USERS, userIds);
processChartData(events, attendees, users);
} catch (err) {
console.error('Error loading user data:', err);
setError('Failed to load user data');
}
};
const processChartData = (events: Event[], attendees: EventAttendee[], users: User[]) => {
if (!events || events.length === 0 || !attendees || attendees.length === 0) {
setChartData(null);
return;
}
// Create a map of user IDs to graduation years
const userGradYearMap = new Map<string, number>();
users.forEach(user => {
if (user.graduation_year) {
userGradYearMap.set(user.id, user.graduation_year);
}
});
// Calculate metrics
// 1. Attendance by time of day
const timeOfDayAttendance = {
'Morning (8am-12pm)': 0,
'Afternoon (12pm-5pm)': 0,
'Evening (5pm-9pm)': 0,
'Night (9pm-8am)': 0,
};
events.forEach(event => {
const startDate = new Date(event.start_date);
const hour = startDate.getHours();
// Count the event in the appropriate time slot
if (hour >= 8 && hour < 12) {
timeOfDayAttendance['Morning (8am-12pm)']++;
} else if (hour >= 12 && hour < 17) {
timeOfDayAttendance['Afternoon (12pm-5pm)']++;
} else if (hour >= 17 && hour < 21) {
timeOfDayAttendance['Evening (5pm-9pm)']++;
} else {
timeOfDayAttendance['Night (9pm-8am)']++;
}
});
// 2. Attendance by day of week
const dayOfWeekAttendance = {
'Sunday': 0,
'Monday': 0,
'Tuesday': 0,
'Wednesday': 0,
'Thursday': 0,
'Friday': 0,
'Saturday': 0,
};
const daysOfWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
events.forEach(event => {
const startDate = new Date(event.start_date);
const dayOfWeek = daysOfWeek[startDate.getDay()];
// Use type assertion to avoid TypeScript error
(dayOfWeekAttendance as Record<string, number>)[dayOfWeek]++;
});
// 3. Attendance by graduation year
const gradYearAttendance: Record<string, number> = {};
attendees.forEach(attendee => {
const userId = attendee.user;
const gradYear = userGradYearMap.get(userId);
if (gradYear) {
const gradYearStr = gradYear.toString();
if (!gradYearAttendance[gradYearStr]) {
gradYearAttendance[gradYearStr] = 0;
}
gradYearAttendance[gradYearStr]++;
}
});
// 4. Food vs. No Food events
const foodEvents = events.filter(event => event.has_food).length;
const noFoodEvents = events.length - foodEvents;
// 5. Average attendance per event
const attendanceByEvent = new Map<string, number>();
attendees.forEach(attendee => {
if (!attendanceByEvent.has(attendee.event)) {
attendanceByEvent.set(attendee.event, 0);
}
attendanceByEvent.set(attendee.event, attendanceByEvent.get(attendee.event)! + 1);
});
const avgAttendance = events.length > 0
? Math.round(attendees.length / events.length)
: 0;
// Prepare radar chart data
// Normalize all metrics to a 0-100 scale for the radar chart
const maxTimeOfDay = Math.max(...Object.values(timeOfDayAttendance));
const maxDayOfWeek = Math.max(...Object.values(dayOfWeekAttendance));
const foodRatio = events.length > 0 ? (foodEvents / events.length) * 100 : 0;
// Calculate repeat attendance rate (% of users who attended more than one event)
const userAttendanceCounts = new Map<string, number>();
attendees.forEach(attendee => {
if (!userAttendanceCounts.has(attendee.user)) {
userAttendanceCounts.set(attendee.user, 0);
}
userAttendanceCounts.set(attendee.user, userAttendanceCounts.get(attendee.user)! + 1);
});
const repeatAttendees = [...userAttendanceCounts.values()].filter(count => count > 1).length;
const repeatRate = userAttendanceCounts.size > 0
? (repeatAttendees / userAttendanceCounts.size) * 100
: 0;
// Normalize metrics for radar chart (0-100 scale)
const normalizeValue = (value: number, max: number) => max > 0 ? (value / max) * 100 : 0;
const radarData = {
labels: [
'Morning Events',
'Afternoon Events',
'Evening Events',
'Weekday Events',
'Weekend Events',
'Food Events',
'Repeat Attendance'
],
datasets: [
{
label: 'Engagement Metrics',
data: [
normalizeValue(timeOfDayAttendance['Morning (8am-12pm)'], maxTimeOfDay),
normalizeValue(timeOfDayAttendance['Afternoon (12pm-5pm)'], maxTimeOfDay),
normalizeValue(timeOfDayAttendance['Evening (5pm-9pm)'], maxTimeOfDay),
normalizeValue(
dayOfWeekAttendance['Monday'] +
dayOfWeekAttendance['Tuesday'] +
dayOfWeekAttendance['Wednesday'] +
dayOfWeekAttendance['Thursday'] +
dayOfWeekAttendance['Friday'],
maxDayOfWeek * 5
),
normalizeValue(
dayOfWeekAttendance['Saturday'] +
dayOfWeekAttendance['Sunday'],
maxDayOfWeek * 2
),
foodRatio,
repeatRate
],
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 2,
pointBackgroundColor: 'rgba(54, 162, 235, 1)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgba(54, 162, 235, 1)',
}
]
};
setChartData(radarData);
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
},
tooltip: {
callbacks: {
label: function (context: any) {
return `${context.label}: ${Math.round(context.raw)}%`;
}
}
}
},
};
if (loading) {
return (
<div className="flex justify-center items-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-error">{error}</div>
</div>
);
}
if (!chartData) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-center text-base-content/70">
<p>No event data available for the selected time period</p>
</div>
</div>
);
}
return (
<div className="h-full">
<Radar data={chartData} options={chartOptions} />
</div>
);
}

View file

@ -1,198 +0,0 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { Event, EventAttendee } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
export default function EventTimeline() {
const [events, setEvents] = useState<Event[]>([]);
const [attendeesByEvent, setAttendeesByEvent] = useState<Map<string, number>>(new Map());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for analytics data updates from the parent component
const handleAnalyticsUpdate = (event: CustomEvent) => {
const { events, attendees, timeRange } = event.detail;
setTimeRange(timeRange);
processData(events, attendees);
};
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
// Initial data load
loadData();
return () => {
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
};
}, []);
const loadData = async () => {
try {
setLoading(true);
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Build filter
const filter = timeRange === "all"
? "published = true"
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
// Get events
const events = await get.getAll<Event>(Collections.EVENTS, filter, "start_date");
// Get event attendees
const attendeesFilter = timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
processData(events, attendees);
} catch (err) {
console.error('Error loading event timeline data:', err);
setError('Failed to load event timeline data');
} finally {
setLoading(false);
}
};
const processData = (events: Event[], attendees: EventAttendee[]) => {
if (!events || events.length === 0) {
setEvents([]);
setAttendeesByEvent(new Map());
return;
}
// Sort events by date (newest first)
const sortedEvents = [...events].sort((a, b) => {
return new Date(b.start_date).getTime() - new Date(a.start_date).getTime();
});
// Count attendees per event
const attendeesByEvent = new Map<string, number>();
attendees.forEach(attendee => {
if (!attendeesByEvent.has(attendee.event)) {
attendeesByEvent.set(attendee.event, 0);
}
attendeesByEvent.set(attendee.event, attendeesByEvent.get(attendee.event)! + 1);
});
setEvents(sortedEvents);
setAttendeesByEvent(attendeesByEvent);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatDuration = (startDate: string, endDate: string) => {
const start = new Date(startDate);
const end = new Date(endDate);
const durationMs = end.getTime() - start.getTime();
const hours = Math.floor(durationMs / (1000 * 60 * 60));
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours === 0) {
return `${minutes} min`;
} else if (minutes === 0) {
return `${hours} hr`;
} else {
return `${hours} hr ${minutes} min`;
}
};
if (loading) {
return (
<div className="flex justify-center items-center py-10">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="alert alert-error">
<div className="flex-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mx-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<label>{error}</label>
</div>
</div>
);
}
if (events.length === 0) {
return (
<div className="text-center py-10">
<p className="text-base-content/70">No events found for the selected time period</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="table w-full">
<thead>
<tr>
<th>Event Name</th>
<th>Date</th>
<th>Duration</th>
<th>Location</th>
<th>Attendees</th>
<th>Food</th>
</tr>
</thead>
<tbody>
{events.map(event => (
<tr key={event.id} className="hover">
<td className="font-medium">{event.event_name}</td>
<td>{formatDate(event.start_date)}</td>
<td>{formatDuration(event.start_date, event.end_date)}</td>
<td>{event.location}</td>
<td>
<div className="badge badge-primary">
{attendeesByEvent.get(event.id) || 0}
</div>
</td>
<td>
{event.has_food ? (
<div className="badge badge-success">Yes</div>
) : (
<div className="badge badge-ghost">No</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -1,205 +0,0 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { Event, EventAttendee } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
// Import Chart.js
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
CategoryScale,
LinearScale,
} from 'chart.js';
import { Pie } from 'react-chartjs-2';
// Register Chart.js components
ChartJS.register(
ArcElement,
Tooltip,
Legend,
CategoryScale,
LinearScale
);
// Define event types and their colors
const EVENT_TYPES = [
{ name: 'Social', key: 'social', color: 'rgba(255, 99, 132, 0.8)' },
{ name: 'Technical', key: 'technical', color: 'rgba(54, 162, 235, 0.8)' },
{ name: 'Outreach', key: 'outreach', color: 'rgba(255, 206, 86, 0.8)' },
{ name: 'Professional', key: 'professional', color: 'rgba(75, 192, 192, 0.8)' },
{ name: 'Projects', key: 'projects', color: 'rgba(153, 102, 255, 0.8)' },
{ name: 'Other', key: 'other', color: 'rgba(255, 159, 64, 0.8)' },
];
export default function EventTypeDistribution() {
const [chartData, setChartData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for analytics data updates from the parent component
const handleAnalyticsUpdate = (event: CustomEvent) => {
const { events, attendees, timeRange } = event.detail;
setTimeRange(timeRange);
processChartData(events, attendees);
};
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
// Initial data load
loadData();
return () => {
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
};
}, []);
const loadData = async () => {
try {
setLoading(true);
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Build filter
const filter = timeRange === "all"
? "published = true"
: `published = true && start_date >= "${startDateStr}" && start_date <= "${endDateStr}"`;
// Get events
const events = await get.getAll<Event>(Collections.EVENTS, filter);
// Get event attendees
const attendeesFilter = timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
processChartData(events, attendees);
} catch (err) {
console.error('Error loading event type distribution data:', err);
setError('Failed to load event type distribution data');
} finally {
setLoading(false);
}
};
const processChartData = (events: Event[], attendees: EventAttendee[]) => {
if (!events || events.length === 0) {
setChartData(null);
return;
}
// Categorize events by type
// For this demo, we'll use a simple heuristic based on event name/description
// In a real implementation, you might have an event_type field in your schema
const eventTypeCount = EVENT_TYPES.reduce((acc, type) => {
acc[type.name] = 0;
return acc;
}, {} as Record<string, number>);
// Count events by event_type field from schema
events.forEach(event => {
const type = event.event_type && EVENT_TYPES.find(t => t.key === event.event_type) ? event.event_type : 'other';
const typeObj = EVENT_TYPES.find(t => t.key === type);
if (typeObj) {
eventTypeCount[typeObj.name]++;
}
});
// Prepare data for chart
const labels = Object.keys(eventTypeCount);
const data = Object.values(eventTypeCount);
const backgroundColor = labels.map(label =>
EVENT_TYPES.find(type => type.name === label)?.color || 'rgba(128, 128, 128, 0.8)'
);
const chartData = {
labels,
datasets: [
{
data,
backgroundColor,
borderColor: backgroundColor.map(color => color.replace('0.8', '1')),
borderWidth: 1,
},
],
};
setChartData(chartData);
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right' as const,
labels: {
padding: 20,
boxWidth: 12,
},
},
tooltip: {
callbacks: {
label: function (context: any) {
// Only show the label, not the value
return context.label || '';
}
}
}
},
};
if (loading) {
return (
<div className="flex justify-center items-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-error">{error}</div>
</div>
);
}
if (!chartData) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-center text-base-content/70">
<p>No event data available for the selected time period</p>
</div>
</div>
);
}
return (
<div className="h-full">
<Pie data={chartData} options={chartOptions} />
</div>
);
}

View file

@ -1,262 +0,0 @@
import { useState, useEffect } from 'react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Collections } from '../../../schemas/pocketbase/schema';
import type { User, EventAttendee } from '../../../schemas/pocketbase/schema';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
// Import Chart.js
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import { Bar } from 'react-chartjs-2';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
);
// Define major categories and their colors
const MAJOR_CATEGORIES = [
{ name: 'Computer Science', color: 'rgba(54, 162, 235, 0.8)' },
{ name: 'Electrical Engineering', color: 'rgba(255, 99, 132, 0.8)' },
{ name: 'Computer Engineering', color: 'rgba(75, 192, 192, 0.8)' },
{ name: 'Mechanical Engineering', color: 'rgba(255, 206, 86, 0.8)' },
{ name: 'Data Science', color: 'rgba(153, 102, 255, 0.8)' },
{ name: 'Mathematics', color: 'rgba(255, 159, 64, 0.8)' },
{ name: 'Physics', color: 'rgba(201, 203, 207, 0.8)' },
{ name: 'Other Engineering', color: 'rgba(100, 149, 237, 0.8)' },
{ name: 'Other', color: 'rgba(169, 169, 169, 0.8)' },
];
export default function MajorDistribution() {
const [chartData, setChartData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<number | string>(30); // Default to 30 days
const get = Get.getInstance();
const auth = Authentication.getInstance();
useEffect(() => {
// Listen for analytics data updates from the parent component
const handleAnalyticsUpdate = (event: CustomEvent) => {
const { events, attendees, timeRange } = event.detail;
setTimeRange(timeRange);
loadUserData(attendees);
};
window.addEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
// Initial data load
loadData();
return () => {
window.removeEventListener('analyticsDataUpdated', handleAnalyticsUpdate as EventListener);
};
}, []);
const loadData = async () => {
try {
setLoading(true);
// Calculate date range
const endDate = new Date();
let startDate;
if (timeRange === "all") {
startDate = new Date(0); // Beginning of time
} else {
startDate = new Date();
startDate.setDate(startDate.getDate() - (typeof timeRange === 'number' ? timeRange : 30));
}
// Format dates for filter
const startDateStr = startDate.toISOString();
const endDateStr = endDate.toISOString();
// Get event attendees
const attendeesFilter = timeRange === "all"
? ""
: `time_checked_in >= "${startDateStr}" && time_checked_in <= "${endDateStr}"`;
const attendees = await get.getAll<EventAttendee>(Collections.EVENT_ATTENDEES, attendeesFilter);
await loadUserData(attendees);
} catch (err) {
console.error('Error loading major distribution data:', err);
setError('Failed to load major distribution data');
} finally {
setLoading(false);
}
};
const loadUserData = async (attendees: EventAttendee[]) => {
try {
if (!attendees || attendees.length === 0) {
setChartData(null);
return;
}
// Get unique user IDs from attendees
const userIds = [...new Set(attendees.map(a => a.user))];
// Fetch user data to get majors
const users = await get.getMany<User>(Collections.USERS, userIds);
processChartData(users);
} catch (err) {
console.error('Error loading user data:', err);
setError('Failed to load user data');
}
};
const processChartData = (users: User[]) => {
if (!users || users.length === 0) {
setChartData(null);
return;
}
// Categorize users by major
const majorCounts = MAJOR_CATEGORIES.reduce((acc, category) => {
acc[category.name] = 0;
return acc;
}, {} as Record<string, number>);
users.forEach(user => {
if (!user.major) {
majorCounts['Other']++;
return;
}
const major = user.major.toLowerCase();
// Categorize majors
if (major.includes('computer science') || major.includes('cs')) {
majorCounts['Computer Science']++;
} else if (major.includes('electrical') || major.includes('ee')) {
majorCounts['Electrical Engineering']++;
} else if (major.includes('computer eng') || major.includes('ce')) {
majorCounts['Computer Engineering']++;
} else if (major.includes('mechanical') || major.includes('me')) {
majorCounts['Mechanical Engineering']++;
} else if (major.includes('data science') || major.includes('ds')) {
majorCounts['Data Science']++;
} else if (major.includes('math')) {
majorCounts['Mathematics']++;
} else if (major.includes('physics')) {
majorCounts['Physics']++;
} else if (major.includes('engineering')) {
majorCounts['Other Engineering']++;
} else {
majorCounts['Other']++;
}
});
// Sort by count (descending)
const sortedMajors = Object.entries(majorCounts)
.sort((a, b) => b[1] - a[1])
.filter(([_, count]) => count > 0); // Only include majors with at least one student
// Prepare data for chart
const labels = sortedMajors.map(([major]) => major);
const data = sortedMajors.map(([_, count]) => count);
const backgroundColor = labels.map(label =>
MAJOR_CATEGORIES.find(category => category.name === label)?.color || 'rgba(128, 128, 128, 0.8)'
);
const chartData = {
labels,
datasets: [
{
label: 'Number of Students',
data,
backgroundColor,
borderColor: backgroundColor.map(color => color.replace('0.8', '1')),
borderWidth: 1,
},
],
};
setChartData(chartData);
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y' as const,
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: function (context: any) {
const label = context.label || '';
const value = context.raw || 0;
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0);
const percentage = Math.round((value / total) * 100);
return `${value} students (${percentage}%)`;
}
}
}
},
scales: {
x: {
beginAtZero: true,
title: {
display: true,
text: 'Number of Students'
}
},
y: {
title: {
display: true,
text: 'Major'
}
}
},
};
if (loading) {
return (
<div className="flex justify-center items-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-error">{error}</div>
</div>
);
}
if (!chartData) {
return (
<div className="flex justify-center items-center h-full">
<div className="text-center text-base-content/70">
<p>No student data available for the selected time period</p>
</div>
</div>
);
}
return (
<div className="h-full">
<Bar data={chartData} options={chartOptions} />
</div>
);
}

View file

@ -1,699 +0,0 @@
import React, { useState } from 'react';
import { Icon } from '@iconify/react';
import FilePreview from '../universal/FilePreview';
import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
import type { ItemizedExpense } from '../../../schemas/pocketbase';
// import ZoomablePreview from '../universal/ZoomablePreview';
interface ReceiptFormData {
file: File;
itemized_expenses: ItemizedExpense[];
tax: number;
date: string;
location_name: string;
location_address: string;
notes: string;
}
interface ReceiptFormProps {
onSubmit: (data: ReceiptFormData) => void;
onCancel: () => void;
}
const EXPENSE_CATEGORIES = [
'Travel',
'Meals',
'Supplies',
'Equipment',
'Software',
'Event Expenses',
'Other'
];
// Add these animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string>('');
const [itemizedExpenses, setItemizedExpenses] = useState<ItemizedExpense[]>([
{ description: '', amount: 0, category: '' }
]);
const [tax, setTax] = useState<number>(0);
const [date, setDate] = useState<string>(new Date().toISOString().split('T')[0]);
const [locationName, setLocationName] = useState<string>('');
const [locationAddress, setLocationAddress] = useState<string>('');
const [notes, setNotes] = useState<string>('');
const [error, setError] = useState<string>('');
const [jsonInput, setJsonInput] = useState<string>('');
const [showJsonInput, setShowJsonInput] = useState<boolean>(false);
const [zoomLevel, setZoomLevel] = useState<number>(1);
// Sample JSON data for users to copy
const sampleJsonData = {
itemized_expenses: [
{
description: "Presentation supplies for IEEE workshop",
category: "Supplies",
amount: 45.99
},
{
description: "Team lunch during planning meeting",
category: "Meals",
amount: 82.50
},
{
description: "Transportation to conference venue",
category: "Travel",
amount: 28.75
}
],
tax: 12.65,
date: "2024-01-15",
location_name: "Office Depot & Local Restaurant",
location_address: "1234 Campus Drive, San Diego, CA 92093",
notes: "Expenses for January IEEE workshop preparation and team coordination meeting"
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0];
// Validate file type
if (!selectedFile.type.match('image/*') && selectedFile.type !== 'application/pdf') {
toast.error('Only images and PDF files are allowed');
setError('Only images and PDF files are allowed');
return;
}
// Validate file size (5MB limit)
if (selectedFile.size > 5 * 1024 * 1024) {
toast.error('File size must be less than 5MB');
setError('File size must be less than 5MB');
return;
}
setFile(selectedFile);
setPreviewUrl(URL.createObjectURL(selectedFile));
setError('');
toast.success('File uploaded successfully');
}
};
const addExpenseItem = () => {
setItemizedExpenses([...itemizedExpenses, { description: '', amount: 0, category: '' }]);
};
const removeExpenseItem = (index: number) => {
if (itemizedExpenses.length === 1) return;
setItemizedExpenses(itemizedExpenses.filter((_, i) => i !== index));
};
const handleExpenseItemChange = (index: number, field: keyof ItemizedExpense, value: string | number) => {
const newItems = [...itemizedExpenses];
newItems[index] = {
...newItems[index],
[field]: value
};
setItemizedExpenses(newItems);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!file) {
setError('Please upload a receipt');
return;
}
if (!locationName.trim()) {
setError('Location name is required');
return;
}
if (!locationAddress.trim()) {
setError('Location address is required');
return;
}
if (itemizedExpenses.some(item => !item.description || !item.category || item.amount <= 0)) {
setError('All expense items must be filled out completely');
return;
}
onSubmit({
file: file,
itemized_expenses: itemizedExpenses,
tax,
date,
location_name: locationName,
location_address: locationAddress,
notes
});
};
const parseJsonData = () => {
try {
if (!jsonInput.trim()) {
toast.error('Please enter JSON data to parse');
return;
}
const parsed = JSON.parse(jsonInput);
// Validate the structure
if (!parsed.itemized_expenses || !Array.isArray(parsed.itemized_expenses)) {
throw new Error('itemized_expenses must be an array');
}
// Validate each expense item
for (const item of parsed.itemized_expenses) {
if (!item.description || !item.category || typeof item.amount !== 'number') {
throw new Error('Each expense item must have description, category, and amount');
}
if (!EXPENSE_CATEGORIES.includes(item.category)) {
throw new Error(`Invalid category: ${item.category}. Must be one of: ${EXPENSE_CATEGORIES.join(', ')}`);
}
}
// Populate the form fields
setItemizedExpenses(parsed.itemized_expenses);
if (parsed.tax !== undefined) setTax(Number(parsed.tax) || 0);
if (parsed.date) setDate(parsed.date);
if (parsed.location_name) setLocationName(parsed.location_name);
if (parsed.location_address) setLocationAddress(parsed.location_address);
if (parsed.notes) setNotes(parsed.notes);
setError('');
toast.success(`Successfully imported ${parsed.itemized_expenses.length} expense items`);
setShowJsonInput(false);
setJsonInput('');
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Invalid JSON format';
setError(`JSON Parse Error: ${errorMessage}`);
toast.error(`Failed to parse JSON: ${errorMessage}`);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
toast.success('Sample data copied to clipboard!');
}).catch(() => {
toast.error('Failed to copy to clipboard');
});
};
const zoomIn = () => {
setZoomLevel(prev => Math.min(prev + 0.25, 3));
};
const zoomOut = () => {
setZoomLevel(prev => Math.max(prev - 0.25, 0.5));
};
const resetZoom = () => {
setZoomLevel(1);
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-2 gap-6 h-full"
>
{/* Left side - Form */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-4 overflow-y-auto max-h-[70vh] pr-8 overflow-x-hidden"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
}}
>
<form onSubmit={handleSubmit} className="space-y-6">
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="alert alert-error shadow-lg"
>
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
<span>{error}</span>
</motion.div>
)}
</AnimatePresence>
{/* File Upload */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Upload Receipt</span>
<span className="label-text-alt text-error">*</span>
</label>
<div className="relative">
<input
type="file"
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
onChange={handleFileChange}
accept="image/*,.pdf"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-base-content/50">
<Icon icon="heroicons:cloud-arrow-up" className="h-5 w-5" />
</div>
</div>
</motion.div>
{/* Date and Location in Grid */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Date</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="date"
className="input input-bordered focus:input-primary transition-all duration-300"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Tax Amount ($)</span>
</label>
<input
type="number"
className="input input-bordered focus:input-primary transition-all duration-300"
value={tax === 0 ? '' : tax}
onChange={(e) => setTax(Number(e.target.value))}
min="0"
step="0.01"
placeholder="0.00"
/>
</div>
</motion.div>
{/* Location Fields */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Location Name</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300"
value={locationName}
onChange={(e) => setLocationName(e.target.value)}
placeholder="Store/vendor name"
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Location Address</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300"
value={locationAddress}
onChange={(e) => setLocationAddress(e.target.value)}
placeholder="Full address"
required
/>
</div>
</motion.div>
{/* Notes - Reduced height */}
<motion.div variants={itemVariants} className="form-control">
<label className="label">
<span className="label-text font-medium">Notes</span>
</label>
<textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
placeholder="Additional notes..."
/>
</motion.div>
{/* JSON Import Section */}
<motion.div variants={itemVariants} className="space-y-4">
<div className="card bg-base-200/30 border border-primary/20 shadow-sm">
<div className="card-body p-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium text-primary">Quick Import from JSON</h3>
<p className="text-sm text-base-content/70">Paste receipt data in JSON format to auto-populate fields</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-primary btn-sm gap-2"
onClick={() => setShowJsonInput(!showJsonInput)}
>
<Icon icon={showJsonInput ? "heroicons:chevron-up" : "heroicons:chevron-down"} className="h-4 w-4" />
{showJsonInput ? 'Hide' : 'Show'} JSON Import
</motion.button>
</div>
<AnimatePresence>
{showJsonInput && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="space-y-4 mt-4 overflow-hidden"
>
{/* Sample Data Section */}
<div className="bg-base-100/50 rounded-lg p-4 border border-base-300/50">
<div className="flex justify-between items-center mb-3">
<h4 className="font-medium text-sm">Sample JSON Format:</h4>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-xs btn-ghost gap-1"
onClick={() => copyToClipboard(JSON.stringify(sampleJsonData, null, 2))}
>
<Icon icon="heroicons:clipboard-document" className="h-3 w-3" />
Copy Sample
</motion.button>
</div>
<pre className="text-xs bg-base-200/50 p-3 rounded border overflow-x-auto">
<code>{JSON.stringify(sampleJsonData, null, 2)}</code>
</pre>
<div className="mt-2 text-xs text-base-content/60">
<p><strong>Required fields:</strong> itemized_expenses (array)</p>
<p><strong>Optional fields:</strong> tax, date, location_name, location_address, notes</p>
<p><strong>Valid categories:</strong> {EXPENSE_CATEGORIES.join(', ')}</p>
</div>
</div>
{/* JSON Input Area */}
<div className="space-y-3">
<label className="label">
<span className="label-text font-medium">Paste your JSON data:</span>
</label>
<textarea
className="textarea textarea-bordered w-full min-h-[150px] font-mono text-sm"
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder="Paste your JSON data here..."
/>
<div className="flex justify-end gap-2">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setJsonInput('')}
>
Clear
</motion.button>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="button"
className="btn btn-primary btn-sm gap-2"
onClick={parseJsonData}
>
<Icon icon="heroicons:arrow-down-tray" className="h-4 w-4" />
Import Data
</motion.button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
{/* Itemized Expenses */}
<motion.div variants={itemVariants} className="space-y-4">
<div className="flex justify-between items-center">
<label className="text-lg font-medium">Itemized Expenses</label>
</div>
<AnimatePresence>
{itemizedExpenses.map((item, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm"
>
<div className="card-body p-3">
<div className="flex justify-between items-center mb-2">
<h4 className="text-sm font-medium">Item #{index + 1}</h4>
{itemizedExpenses.length > 1 && (
<button
type="button"
className="btn btn-xs btn-ghost text-error hover:bg-error/10"
onClick={() => removeExpenseItem(index)}
aria-label="Remove item"
>
<Icon icon="heroicons:trash" className="h-3 w-3" />
</button>
)}
</div>
<div className="grid gap-3">
<div className="form-control">
<label className="label py-1">
<span className="label-text text-xs">Description</span>
</label>
<input
type="text"
className="input input-bordered input-sm"
value={item.description}
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
placeholder="What was purchased?"
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label py-1">
<span className="label-text text-xs">Category</span>
</label>
<select
className="select select-bordered select-sm w-full"
value={item.category}
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
required
>
<option value="">Select...</option>
{EXPENSE_CATEGORIES.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
<div className="form-control">
<label className="label py-1">
<span className="label-text text-xs">Amount ($)</span>
</label>
<input
type="number"
className="input input-bordered input-sm w-full"
value={item.amount === 0 ? '' : item.amount}
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
min="0"
step="0.01"
placeholder="0.00"
required
/>
</div>
</div>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
{/* Add Item Button - Moved to bottom */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-center pt-2"
>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
onClick={addExpenseItem}
>
<Icon icon="heroicons:plus" className="h-4 w-4" />
Add Item
</motion.button>
</motion.div>
</motion.div>
{/* Total */}
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-3 shadow-sm">
<div className="space-y-1">
<div className="flex justify-between items-center text-sm text-base-content/70">
<span>Subtotal:</span>
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
</div>
<div className="flex justify-between items-center text-sm text-base-content/70">
<span>Tax:</span>
<span className="font-mono">${tax.toFixed(2)}</span>
</div>
<div className="divider my-1"></div>
<div className="flex justify-between items-center font-medium">
<span>Total:</span>
<span className="font-mono text-primary text-lg">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
</div>
</div>
</motion.div>
{/* Action Buttons */}
<motion.div variants={itemVariants} className="flex justify-end gap-3 mt-8">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="button"
className="btn btn-ghost hover:btn-error transition-all duration-300"
onClick={onCancel}
>
Cancel
</motion.button>
<motion.button
whileHover={{ scale: 1.02, boxShadow: "0 4px 20px rgba(0, 0, 0, 0.1)" }}
whileTap={{ scale: 0.98 }}
type="submit"
className="btn btn-primary shadow-md hover:shadow-lg transition-all duration-300"
>
Add Receipt
</motion.button>
</motion.div>
</form>
</motion.div>
{/* Right side - Preview */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="border-l border-base-300 pl-6"
>
<AnimatePresence mode="wait">
{previewUrl ? (
<motion.div
key="preview"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="bg-base-200/50 backdrop-blur-sm rounded-xl shadow-sm relative"
>
{/* Zoom Controls */}
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2 bg-base-100/90 backdrop-blur-sm rounded-lg p-2 shadow-lg">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={zoomIn}
disabled={zoomLevel >= 3}
title="Zoom In"
>
<Icon icon="heroicons:plus" className="h-3 w-3" />
</motion.button>
<div className="text-xs text-center font-mono px-1">
{Math.round(zoomLevel * 100)}%
</div>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={zoomOut}
disabled={zoomLevel <= 0.5}
title="Zoom Out"
>
<Icon icon="heroicons:minus" className="h-3 w-3" />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={resetZoom}
disabled={zoomLevel === 1}
title="Reset Zoom"
>
<Icon icon="heroicons:arrows-pointing-out" className="h-3 w-3" />
</motion.button>
</div>
{/* Preview with Zoom */}
<div
className="overflow-auto h-full rounded-xl"
style={{
transform: `scale(${zoomLevel})`,
transformOrigin: 'top left',
height: zoomLevel > 1 ? `${100 / zoomLevel}%` : '100%',
width: zoomLevel > 1 ? `${100 / zoomLevel}%` : '100%'
}}
>
<FilePreview url={previewUrl} filename={file?.name || ''} />
</div>
</motion.div>
) : (
<motion.div
key="placeholder"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex items-center justify-center h-full text-base-content/70"
>
<div className="text-center">
<Icon icon="heroicons:document" className="h-16 w-16 mx-auto mb-4 text-base-content/30" />
<p className="text-lg">Upload a receipt to preview</p>
<p className="text-sm text-base-content/50 mt-2">Supported formats: Images, PDF</p>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</motion.div>
);
}

View file

@ -1,702 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Icon } from '@iconify/react';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import { EmailClient } from '../../../scripts/email/EmailClient';
import ReceiptForm from './ReceiptForm';
import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion';
import FilePreview from '../universal/FilePreview';
import type { ItemizedExpense, Reimbursement } from '../../../schemas/pocketbase';
interface ReceiptFormData {
file: File;
itemized_expenses: ItemizedExpense[];
tax: number;
date: string;
location_name: string;
location_address: string;
notes: string;
}
// Extended Reimbursement interface with form-specific fields
interface ReimbursementRequest extends Partial<Omit<Reimbursement, 'receipts'>> {
title: string;
total_amount: number;
date_of_purchase: string;
payment_method: string;
status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'paid' | 'in_progress';
additional_info: string;
receipts: string[];
department: 'internal' | 'external' | 'projects' | 'events' | 'other';
}
const PAYMENT_METHODS = [
'Personal Credit Card',
'Personal Debit Card',
'Cash',
'Personal Check',
'Other'
];
const DEPARTMENTS = [
'internal',
'external',
'projects',
'events',
'other'
] as const;
const DEPARTMENT_LABELS = {
internal: 'Internal',
external: 'External',
projects: 'Projects',
events: 'Events',
other: 'Other'
};
// Add these animation variants
const containerVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 30,
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 24
}
}
};
export default function ReimbursementForm() {
const [request, setRequest] = useState<ReimbursementRequest>({
title: '',
total_amount: 0,
date_of_purchase: new Date().toISOString().split('T')[0],
payment_method: '',
status: 'submitted',
additional_info: '',
receipts: [],
department: 'internal'
});
const [receipts, setReceipts] = useState<(ReceiptFormData & { id: string })[]>([]);
const [showReceiptForm, setShowReceiptForm] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string>('');
const [showReceiptDetails, setShowReceiptDetails] = useState(false);
const [selectedReceiptDetails, setSelectedReceiptDetails] = useState<ReceiptFormData | null>(null);
const [hasZelleInfo, setHasZelleInfo] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
const auth = Authentication.getInstance();
useEffect(() => {
checkZelleInformation();
}, []);
const checkZelleInformation = async () => {
try {
setIsLoading(true);
const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id;
if (!userId) {
// Silently return without error when on dashboard page
if (window.location.pathname.includes('/dashboard')) {
setIsLoading(false);
return;
}
throw new Error('User not authenticated');
}
const user = await pb.collection('users').getOne(userId);
setHasZelleInfo(!!user.zelle_information);
} catch (error) {
// Only log error if not on dashboard page or if it's not an authentication error
if (!window.location.pathname.includes('/dashboard') ||
!(error instanceof Error && error.message === 'User not authenticated')) {
console.error('Error checking Zelle information:', error);
}
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<div className="loading loading-spinner loading-lg text-primary"></div>
<p className="mt-4 text-base-content/70">Loading...</p>
</div>
);
}
if (hasZelleInfo === false) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-2xl mx-auto text-center py-12"
>
<div className="card bg-base-200 p-8">
<Icon icon="heroicons:exclamation-triangle" className="h-16 w-16 mx-auto text-warning" />
<h2 className="text-2xl font-bold mt-6">Zelle Information Required</h2>
<p className="mt-4 text-base-content/70">
Before submitting a reimbursement request, you need to provide your Zelle information.
This is required for processing your reimbursement payments.
</p>
<div className="mt-8">
<button
className="btn btn-primary gap-2"
onClick={() => {
const profileBtn = document.querySelector('[data-section="settings"]') as HTMLButtonElement;
if (profileBtn) profileBtn.click();
}}
>
<Icon icon="heroicons:user-circle" className="h-5 w-5" />
Update Profile
</button>
</div>
</div>
</motion.div>
);
}
const handleAddReceipt = async (receiptData: ReceiptFormData) => {
try {
const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id;
if (!userId) {
// Silently return without error when on dashboard page
if (window.location.pathname.includes('/dashboard')) {
return;
}
toast.error('User not authenticated');
throw new Error('User not authenticated');
}
// Create receipt record
const formData = new FormData();
formData.append('file', receiptData.file);
formData.append('created_by', userId);
formData.append('itemized_expenses', JSON.stringify(receiptData.itemized_expenses));
formData.append('tax', receiptData.tax.toString());
formData.append('date', new Date(receiptData.date).toISOString());
formData.append('location_name', receiptData.location_name);
formData.append('location_address', receiptData.location_address);
formData.append('notes', receiptData.notes);
const response = await pb.collection('receipts').create(formData);
// Sync the receipts collection to update IndexedDB
const dataSync = DataSyncService.getInstance();
await dataSync.syncCollection(Collections.RECEIPTS);
// Add receipt to state
setReceipts(prev => [...prev, { ...receiptData, id: response.id }]);
// Update total amount
const totalAmount = receiptData.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + receiptData.tax;
setRequest(prev => ({
...prev,
total_amount: prev.total_amount + totalAmount,
receipts: [...prev.receipts, response.id]
}));
setShowReceiptForm(false);
toast.success('Receipt added successfully');
} catch (error) {
console.error('Error creating receipt:', error);
toast.error('Failed to add receipt');
setError('Failed to add receipt. Please try again.');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
if (!request.title.trim()) {
toast.error('Title is required');
setError('Title is required');
return;
}
if (!request.payment_method) {
toast.error('Payment method is required');
setError('Payment method is required');
return;
}
if (receipts.length === 0) {
toast.error('At least one receipt is required');
setError('At least one receipt is required');
return;
}
setIsSubmitting(true);
setError('');
try {
const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id;
if (!userId) {
// Silently return without error when on dashboard page
if (window.location.pathname.includes('/dashboard')) {
setIsSubmitting(false);
return;
}
throw new Error('User not authenticated');
}
// Create reimbursement record
const formData = new FormData();
formData.append('title', request.title);
formData.append('total_amount', request.total_amount.toString());
formData.append('date_of_purchase', new Date(request.date_of_purchase).toISOString());
formData.append('payment_method', request.payment_method);
formData.append('status', 'submitted');
formData.append('submitted_by', userId);
formData.append('additional_info', request.additional_info);
formData.append('receipts', JSON.stringify(request.receipts));
formData.append('department', request.department);
// Create the reimbursement record
const newReimbursement = await pb.collection('reimbursement').create(formData);
// Sync the reimbursements collection to update IndexedDB
const dataSync = DataSyncService.getInstance();
// Force sync with specific filter to ensure the new record is fetched
await dataSync.syncCollection(
Collections.REIMBURSEMENTS,
`submitted_by="${userId}"`,
'-created',
'audit_notes'
);
// Verify the new record is in IndexedDB
const syncedData = await dataSync.getData(
Collections.REIMBURSEMENTS,
true, // Force sync again to be sure
`id="${newReimbursement.id}"`
);
if (syncedData.length === 0) {
console.warn('New reimbursement not found in IndexedDB after sync, forcing another sync');
// Try one more time with a slight delay
setTimeout(async () => {
await dataSync.syncCollection(Collections.REIMBURSEMENTS);
}, 500);
}
// Reset form
setRequest({
title: '',
total_amount: 0,
date_of_purchase: new Date().toISOString().split('T')[0],
payment_method: '',
status: 'submitted',
additional_info: '',
receipts: [],
department: 'internal'
});
setReceipts([]);
setError('');
toast.success('🎉 Reimbursement request submitted successfully! Check "My Requests" to view it.', {
duration: 5000,
position: 'top-center',
style: {
background: '#10B981',
color: '#FFFFFF',
padding: '16px',
borderRadius: '8px',
}
});
// Send email notification
try {
await EmailClient.notifySubmission(newReimbursement.id);
} catch (emailError) {
console.error('Failed to send submission email notification:', emailError);
// Don't fail the entire operation if email fails
}
} catch (error) {
console.error('Error submitting reimbursement request:', error);
toast.error('Failed to submit reimbursement request. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
<>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="max-w-4xl mx-auto"
>
<form onSubmit={handleSubmit} className="space-y-8">
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="alert alert-error shadow-lg"
>
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
<span>{error}</span>
</motion.div>
)}
</AnimatePresence>
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Title */}
<div className="form-control md:col-span-2">
<label className="label">
<span className="label-text font-medium">Title</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="text"
className="input input-bordered focus:input-primary transition-all duration-300"
value={request.title}
onChange={(e) => setRequest(prev => ({ ...prev, title: e.target.value }))}
required
/>
</div>
{/* Date of Purchase */}
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Date of Purchase</span>
<span className="label-text-alt text-error">*</span>
</label>
<input
type="date"
className="input input-bordered focus:input-primary transition-all duration-300"
value={request.date_of_purchase}
onChange={(e) => setRequest(prev => ({ ...prev, date_of_purchase: e.target.value }))}
required
/>
</div>
{/* Payment Method */}
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Payment Method</span>
<span className="label-text-alt text-error">*</span>
</label>
<select
className="select select-bordered focus:select-primary transition-all duration-300"
value={request.payment_method}
onChange={(e) => setRequest(prev => ({ ...prev, payment_method: e.target.value }))}
required
>
<option value="">Select payment method</option>
{PAYMENT_METHODS.map(method => (
<option key={method} value={method}>{method}</option>
))}
</select>
</div>
{/* Department */}
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Department</span>
<span className="label-text-alt text-error">*</span>
</label>
<select
className="select select-bordered focus:select-primary transition-all duration-300"
value={request.department}
onChange={(e) => setRequest(prev => ({ ...prev, department: e.target.value as typeof DEPARTMENTS[number] }))}
required
>
{DEPARTMENTS.map(dept => (
<option key={dept} value={dept}>{DEPARTMENT_LABELS[dept]}</option>
))}
</select>
</div>
{/* Additional Info */}
<div className="form-control md:col-span-2">
<label className="label">
<span className="label-text font-medium">Additional Information</span>
</label>
<textarea
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px]"
value={request.additional_info}
onChange={(e) => setRequest(prev => ({ ...prev, additional_info: e.target.value }))}
rows={3}
/>
</div>
</motion.div>
{/* Receipts */}
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-6 shadow-sm">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Receipts</h3>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-primary btn-sm gap-2 hover:shadow-lg transition-all duration-300"
onClick={() => setShowReceiptForm(true)}
>
<Icon icon="heroicons:plus" className="h-4 w-4" />
Add Receipt
</motion.button>
</div>
{receipts.length > 0 ? (
<motion.div layout className="grid gap-4">
<AnimatePresence>
{receipts.map((receipt, index) => (
<motion.div
key={receipt.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 shadow-sm"
>
<div className="card-body p-4">
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium text-lg">{receipt.location_name}</h3>
<p className="text-sm text-base-content/70">{receipt.location_address}</p>
<p className="text-sm mt-2">
Total: <span className="font-mono font-medium text-primary">${(receipt.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + receipt.tax).toFixed(2)}</span>
</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
className="btn btn-ghost btn-sm gap-2"
onClick={() => {
setSelectedReceiptDetails(receipt);
setShowReceiptDetails(true);
}}
>
<Icon icon="heroicons:eye" className="h-4 w-4" />
View Details
</motion.button>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-12 bg-base-100 rounded-lg"
>
<Icon icon="heroicons:receipt" className="h-16 w-16 mx-auto text-base-content/30" />
<h3 className="mt-4 text-lg font-medium">No receipts added</h3>
<p className="text-base-content/70 mt-2">Add receipts to continue</p>
</motion.div>
)}
{receipts.length > 0 && (
<div className="mt-4 p-4 bg-base-100 rounded-lg">
<div className="flex justify-between items-center text-lg font-medium">
<span>Total Amount:</span>
<span className="font-mono text-primary">${request.total_amount.toFixed(2)}</span>
</div>
</div>
)}
</motion.div>
{/* Submit Button */}
<motion.div
variants={itemVariants}
className="mt-8"
>
<motion.button
whileHover={{ scale: 1.01, boxShadow: "0 4px 20px rgba(0, 0, 0, 0.1)" }}
whileTap={{ scale: 0.99 }}
type="submit"
className="btn btn-primary w-full h-12 shadow-md hover:shadow-lg transition-all duration-300 text-lg"
disabled={isSubmitting || receipts.length === 0}
>
{isSubmitting ? (
<span className="loading loading-spinner loading-md"></span>
) : (
<>
<Icon icon="heroicons:paper-airplane" className="h-5 w-5" />
Submit Reimbursement Request
</>
)}
</motion.button>
</motion.div>
</form>
{/* Receipt Form Modal */}
<AnimatePresence>
{showReceiptForm && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="modal-box max-w-5xl bg-base-100/95 backdrop-blur-md"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">Add Receipt</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => setShowReceiptForm(false)}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<ReceiptForm
onSubmit={handleAddReceipt}
onCancel={() => setShowReceiptForm(false)}
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Receipt Details Modal */}
<AnimatePresence>
{showReceiptDetails && selectedReceiptDetails && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="modal-box max-w-4xl bg-base-100/95 backdrop-blur-md"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Receipt Details
</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => {
setShowReceiptDetails(false);
setSelectedReceiptDetails(null);
}}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Location</label>
<p className="mt-1">{selectedReceiptDetails.location_name}</p>
<p className="text-sm text-base-content/70">{selectedReceiptDetails.location_address}</p>
</div>
<div>
<label className="text-sm font-medium">Date</label>
<p className="mt-1">{new Date(selectedReceiptDetails.date).toLocaleDateString()}</p>
</div>
{selectedReceiptDetails.notes && (
<div>
<label className="text-sm font-medium">Notes</label>
<p className="mt-1">{selectedReceiptDetails.notes}</p>
</div>
)}
<div>
<label className="text-sm font-medium">Itemized Expenses</label>
<div className="mt-2 space-y-2">
{selectedReceiptDetails.itemized_expenses.map((item, index) => (
<div key={index} className="card bg-base-200 p-3">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs font-medium">Description</label>
<p>{item.description}</p>
</div>
<div>
<label className="text-xs font-medium">Category</label>
<p>{item.category}</p>
</div>
<div>
<label className="text-xs font-medium">Amount</label>
<p>${item.amount.toFixed(2)}</p>
</div>
</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Tax</label>
<p className="mt-1">${selectedReceiptDetails.tax.toFixed(2)}</p>
</div>
<div>
<label className="text-sm font-medium">Total</label>
<p className="mt-1">${(selectedReceiptDetails.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + selectedReceiptDetails.tax).toFixed(2)}</p>
</div>
</div>
</div>
<div className="border-l border-base-300 pl-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Receipt Image</h3>
</div>
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
<FilePreview
url={URL.createObjectURL(selectedReceiptDetails.file)}
filename={selectedReceiptDetails.file.name}
isModal={false}
/>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</>
);
}

View file

@ -1,874 +0,0 @@
import { useState, useEffect } from 'react';
import { Icon } from '@iconify/react';
import { Get } from '../../../scripts/pocketbase/Get';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { FileManager } from '../../../scripts/pocketbase/FileManager';
import FilePreview from '../universal/FilePreview';
import { motion, AnimatePresence } from 'framer-motion';
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema';
import { toast } from 'react-hot-toast';
interface AuditNote {
note: string;
auditor_id: string;
timestamp: string;
is_private: boolean;
}
// Extended Reimbursement interface with component-specific properties
interface ReimbursementRequest extends Omit<Reimbursement, 'audit_notes'> {
audit_notes: AuditNote[] | null;
}
// Extended Receipt interface with component-specific properties
interface ReceiptDetails extends Omit<Receipt, 'itemized_expenses' | 'audited_by'> {
file: string;
itemized_expenses: ItemizedExpense[];
audited_by: string[];
created: string;
updated: string;
}
const STATUS_COLORS = {
submitted: 'badge-primary',
under_review: 'badge-warning',
approved: 'badge-success',
rejected: 'badge-error',
paid: 'badge-success',
in_progress: 'badge-info'
};
const STATUS_LABELS = {
submitted: 'Submitted',
under_review: 'Under Review',
approved: 'Approved',
rejected: 'Rejected',
paid: 'Paid',
in_progress: 'In Progress'
};
const DEPARTMENT_LABELS = {
internal: 'Internal',
external: 'External',
projects: 'Projects',
events: 'Events',
other: 'Other'
};
// Add this after the STATUS_LABELS constant
const STATUS_ORDER = ['submitted', 'under_review', 'approved', 'rejected', 'in_progress', 'paid'] as const;
const STATUS_ICONS = {
submitted: 'heroicons:paper-airplane',
under_review: 'heroicons:eye',
approved: 'heroicons:check-circle',
rejected: 'heroicons:x-circle',
in_progress: 'heroicons:clock',
paid: 'heroicons:banknotes'
} as const;
// Add these animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
duration: 0.3,
when: "beforeChildren",
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
type: "spring",
stiffness: 100,
damping: 15
}
}
};
export default function ReimbursementList() {
const [requests, setRequests] = useState<ReimbursementRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selectedRequest, setSelectedRequest] = useState<ReimbursementRequest | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [previewFilename, setPreviewFilename] = useState('');
const [selectedReceipt, setSelectedReceipt] = useState<ReceiptDetails | null>(null);
const [receiptDetailsMap, setReceiptDetailsMap] = useState<Record<string, ReceiptDetails>>({});
const get = Get.getInstance();
const auth = Authentication.getInstance();
const fileManager = FileManager.getInstance();
useEffect(() => {
// console.log('Component mounted');
fetchReimbursements();
// Set up an interval to refresh the reimbursements list periodically
const refreshInterval = setInterval(() => {
if (document.visibilityState === 'visible') {
fetchReimbursements();
}
}, 30000); // Refresh every 30 seconds when tab is visible
// Listen for visibility changes to refresh when user returns to the tab
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
fetchReimbursements();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearInterval(refreshInterval);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
// Add effect to monitor requests state
useEffect(() => {
// console.log('Requests state updated:', requests);
// console.log('Number of requests:', requests.length);
}, [requests]);
// Add a useEffect to log preview URL and filename changes
useEffect(() => {
// console.log('Preview URL changed:', previewUrl);
// console.log('Preview filename changed:', previewFilename);
}, [previewUrl, previewFilename]);
// Add a useEffect to log when the preview modal is shown/hidden
useEffect(() => {
// console.log('Show preview changed:', showPreview);
if (showPreview) {
// console.log('Selected receipt:', selectedReceipt);
}
}, [showPreview, selectedReceipt]);
const fetchReimbursements = async () => {
setLoading(true);
setError('');
try {
const pb = auth.getPocketBase();
const userId = pb.authStore.model?.id;
if (!userId) {
// Silently return without error when on dashboard page
if (window.location.pathname.includes('/dashboard')) {
setLoading(false);
return;
}
throw new Error('User not authenticated');
}
// Use DataSyncService to get data from IndexedDB with forced sync
const dataSync = DataSyncService.getInstance();
// Sync reimbursements collection with force sync
await dataSync.syncCollection(
Collections.REIMBURSEMENTS,
`submitted_by="${userId}"`,
'-created',
'audit_notes'
);
// Get reimbursements from IndexedDB with forced sync to ensure latest data
const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
Collections.REIMBURSEMENTS,
true, // Force sync to ensure we have the latest data
`submitted_by="${userId}"`,
'-created'
);
// console.log('Reimbursement records from IndexedDB:', reimbursementRecords);
// Process the records
const processedRecords = reimbursementRecords.map(record => {
// Process audit notes if they exist
let auditNotes = null;
if (record.audit_notes) {
try {
// If it's a string, parse it
if (typeof record.audit_notes === 'string') {
auditNotes = JSON.parse(record.audit_notes);
} else {
// Otherwise use it directly
auditNotes = record.audit_notes;
}
} catch (e) {
// console.error('Error parsing audit notes:', e);
}
}
return {
...record,
audit_notes: auditNotes
};
});
setRequests(processedRecords);
// Fetch receipt details for each reimbursement
for (const record of processedRecords) {
if (record.receipts && record.receipts.length > 0) {
for (const receiptId of record.receipts) {
try {
// Get receipt from IndexedDB
const receiptRecord = await dataSync.getItem<ReceiptDetails>(
Collections.RECEIPTS,
receiptId
);
if (receiptRecord) {
// Process itemized expenses
let itemizedExpenses: ItemizedExpense[] = [];
if (receiptRecord.itemized_expenses) {
try {
if (typeof receiptRecord.itemized_expenses === 'string') {
itemizedExpenses = JSON.parse(receiptRecord.itemized_expenses);
} else {
itemizedExpenses = receiptRecord.itemized_expenses as ItemizedExpense[];
}
} catch (e) {
// console.error('Error parsing itemized expenses:', e);
}
}
// Add receipt to state
setReceiptDetailsMap(prevMap => ({
...prevMap,
[receiptId]: {
id: receiptRecord.id,
file: receiptRecord.file,
created_by: receiptRecord.created_by,
date: receiptRecord.date,
location_name: receiptRecord.location_name,
location_address: receiptRecord.location_address,
notes: receiptRecord.notes,
tax: receiptRecord.tax,
created: receiptRecord.created,
updated: receiptRecord.updated,
itemized_expenses: itemizedExpenses,
audited_by: receiptRecord.audited_by || []
}
}));
}
} catch (e) {
// console.error(`Error fetching receipt ${receiptId}:`, e);
}
}
}
}
} catch (err) {
// console.error('Error fetching reimbursements:', err);
setError('Failed to load reimbursements. Please try again.');
} finally {
setLoading(false);
}
};
const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => {
try {
// console.log('Previewing file for receipt ID:', receiptId);
const pb = auth.getPocketBase();
const fileManager = FileManager.getInstance();
// Set the selected request
setSelectedRequest(request);
// Check if we already have the receipt details in our map
if (receiptDetailsMap[receiptId]) {
// console.log('Using cached receipt details');
// Use the cached receipt details
setSelectedReceipt(receiptDetailsMap[receiptId]);
// Check if the receipt has a file
if (!receiptDetailsMap[receiptId].file) {
// console.error('Receipt has no file attached');
toast.error('This receipt has no file attached');
setPreviewUrl('');
setPreviewFilename('');
setShowPreview(true);
return;
}
// Get the file URL with token for protected files
// console.log('Getting file URL with token');
const url = await fileManager.getFileUrlWithToken(
'receipts',
receiptId,
receiptDetailsMap[receiptId].file,
true // Use token for protected files
);
// Check if the URL is empty
if (!url) {
// console.error('Failed to get file URL: Empty URL returned');
toast.error('Failed to load receipt: Could not generate file URL');
// Still show the preview modal but with empty URL to display the error message
setPreviewUrl('');
setPreviewFilename(receiptDetailsMap[receiptId].file || '');
setShowPreview(true);
return;
}
// console.log('Got URL:', url.substring(0, 50) + '...');
// Set the preview URL and filename
setPreviewUrl(url);
setPreviewFilename(receiptDetailsMap[receiptId].file);
// Show the preview modal
setShowPreview(true);
// Log the current state
// console.log('Current state after setting:', {
// previewUrl: url,
// previewFilename: receiptDetailsMap[receiptId].file,
// showPreview: true
// });
return;
}
// If not in the map, get the receipt record using its ID
// console.log('Fetching receipt details from server');
const receiptRecord = await pb.collection('receipts').getOne(receiptId, {
$autoCancel: false
});
if (receiptRecord) {
// console.log('Receipt record found:', receiptRecord.id);
// console.log('Receipt file:', receiptRecord.file);
// Check if the receipt has a file
if (!receiptRecord.file) {
// console.error('Receipt has no file attached');
toast.error('This receipt has no file attached');
setPreviewUrl('');
setPreviewFilename('');
setShowPreview(true);
return;
}
// Parse the itemized expenses if it's a string
const itemizedExpenses = typeof receiptRecord.itemized_expenses === 'string'
? JSON.parse(receiptRecord.itemized_expenses)
: receiptRecord.itemized_expenses;
const receiptDetails: ReceiptDetails = {
id: receiptRecord.id,
file: receiptRecord.file,
created_by: receiptRecord.created_by,
itemized_expenses: itemizedExpenses,
tax: receiptRecord.tax,
date: receiptRecord.date,
location_name: receiptRecord.location_name,
location_address: receiptRecord.location_address,
notes: receiptRecord.notes || '',
audited_by: receiptRecord.audited_by || [],
created: receiptRecord.created,
updated: receiptRecord.updated
};
// Add to the map for future use
setReceiptDetailsMap(prevMap => ({
...prevMap,
[receiptId]: receiptDetails
}));
setSelectedReceipt(receiptDetails);
// Get the file URL with token for protected files
// console.log('Getting file URL with token for new receipt');
const url = await fileManager.getFileUrlWithToken(
'receipts',
receiptRecord.id,
receiptRecord.file,
true // Use token for protected files
);
// Check if the URL is empty
if (!url) {
// console.error('Failed to get file URL: Empty URL returned');
toast.error('Failed to load receipt: Could not generate file URL');
// Still show the preview modal but with empty URL to display the error message
setPreviewUrl('');
setPreviewFilename(receiptRecord.file || '');
setShowPreview(true);
return;
}
// console.log('Got URL:', url.substring(0, 50) + '...');
// Set the preview URL and filename
setPreviewUrl(url);
setPreviewFilename(receiptRecord.file);
// Show the preview modal
setShowPreview(true);
// Log the current state
// console.log('Current state after setting:', {
// previewUrl: url,
// previewFilename: receiptRecord.file,
// showPreview: true
// });
} else {
throw new Error('Receipt not found');
}
} catch (error) {
// console.error('Error loading receipt:', error);
toast.error('Failed to load receipt. Please try again.');
// Show the preview modal with empty URL to display the error message
setPreviewUrl('');
setPreviewFilename('');
setShowPreview(true);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
if (loading) {
// console.log('Rendering loading state');
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center min-h-[400px] p-8"
>
<div className="loading loading-spinner loading-lg text-primary mb-4"></div>
<p className="text-base-content/70 animate-pulse">Loading your reimbursements...</p>
</motion.div>
);
}
if (error) {
// console.log('Rendering error state:', error);
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="alert alert-error shadow-lg max-w-2xl mx-auto"
>
<Icon icon="heroicons:exclamation-circle" className="h-5 w-5" />
<span>{error}</span>
</motion.div>
);
}
// console.log('Rendering main component. Requests:', requests);
// console.log('Requests length:', requests.length);
return (
<>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-6"
>
{requests.length === 0 ? (
<motion.div
variants={itemVariants}
initial="hidden"
animate="visible"
className="text-center py-16 bg-base-200/50 backdrop-blur-sm rounded-2xl border-2 border-dashed border-base-300"
>
<Icon icon="heroicons:document" className="h-16 w-16 mx-auto text-base-content/30" />
<h3 className="mt-6 text-xl font-medium">No reimbursement requests</h3>
<p className="text-base-content/70 mt-2">Create a new request to get started</p>
</motion.div>
) : (
<motion.div
layout
initial="hidden"
animate="visible"
variants={containerVariants}
className="grid gap-4"
>
<AnimatePresence mode="popLayout">
{requests.map((request, index) => {
// console.log('Rendering request:', request);
return (
<motion.div
key={request.id}
variants={itemVariants}
initial="hidden"
animate="visible"
layout
className="card bg-base-100 hover:bg-base-200 transition-all duration-300 border border-base-200 hover:border-primary shadow-sm hover:shadow-md"
>
<div className="card-body p-5">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="flex-1 min-w-0">
<h3 className="card-title text-lg font-bold truncate">{request.title}</h3>
<div className="flex flex-wrap gap-2 mt-2">
<div className="badge badge-outline badge-lg font-mono">
${request.total_amount.toFixed(2)}
</div>
<div className="badge badge-ghost badge-lg gap-1">
<Icon icon="heroicons:calendar" className="h-4 w-4" />
{formatDate(request.date_of_purchase)}
</div>
{request.audit_notes && request.audit_notes.filter(note => !note.is_private).length > 0 && (
<div className="badge badge-ghost badge-lg gap-1">
<Icon icon="heroicons:chat-bubble-left-right" className="h-4 w-4" />
{request.audit_notes.filter(note => !note.is_private).length} Notes
</div>
)}
</div>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="btn btn-primary btn-sm gap-2 shadow-sm hover:shadow-md transition-all duration-300"
onClick={() => setSelectedRequest(request)}
>
<Icon icon="heroicons:eye" className="h-4 w-4" />
View Details
</motion.button>
</div>
<div className="mt-4 card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<div className="flex items-center justify-between w-full relative py-2">
<div className="absolute left-0 right-0 top-1/2 h-0.5 bg-base-300 -translate-y-[1.0rem]" />
{STATUS_ORDER.map((status, index) => {
if (status === 'rejected' && request.status !== 'rejected') return null;
if (status === 'approved' && request.status === 'rejected') return null;
const isActive = STATUS_ORDER.indexOf(request.status) >= STATUS_ORDER.indexOf(status);
const isCurrent = request.status === status;
return (
<div key={status} className="relative flex flex-col items-center gap-2 z-10">
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${isCurrent
? status === 'rejected'
? 'bg-error text-error-content ring-2 ring-error/20'
: status === 'paid'
? 'bg-success text-success-content ring-2 ring-success/20'
: status === 'in_progress'
? 'bg-warning text-warning-content ring-2 ring-warning/20'
: 'bg-primary text-white ring-2 ring-primary/20'
: isActive
? status === 'rejected'
? 'bg-error/20 text-error'
: status === 'paid'
? 'bg-success/20 text-success'
: 'bg-primary/20 text-primary'
: 'bg-base-300 text-base-content/40'
}`}>
<Icon icon={STATUS_ICONS[status]} className="h-3.5 w-3.5" />
</div>
<span className={`text-[10px] font-medium whitespace-nowrap mt-1 ${isCurrent
? status === 'rejected'
? 'text-error'
: status === 'paid'
? 'text-success'
: status === 'in_progress'
? 'text-warning'
: 'text-primary'
: isActive
? 'text-base-content'
: 'text-base-content/40'
}`}>
{STATUS_LABELS[status]}
</span>
</div>
);
})}
</div>
</div>
</div>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
)}
{/* Details Modal */}
<AnimatePresence>
{selectedRequest && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="modal-box max-w-3xl bg-base-100/95 backdrop-blur-md"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
{selectedRequest.title}
</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => setSelectedRequest(null)}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<div className="grid gap-6">
<div className="grid grid-cols-2 gap-4">
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Status</label>
<div className={`badge ${STATUS_COLORS[selectedRequest.status]} badge-lg gap-1 mt-1`}>
<Icon icon={STATUS_ICONS[selectedRequest.status]} className="h-4 w-4" />
{STATUS_LABELS[selectedRequest.status]}
</div>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Department</label>
<div className="badge badge-outline badge-lg mt-1">
{DEPARTMENT_LABELS[selectedRequest.department]}
</div>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Total Amount</label>
<p className="mt-1 text-xl font-mono font-bold text-primary">
${selectedRequest.total_amount.toFixed(2)}
</p>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Date of Purchase</label>
<p className="mt-1 font-medium">{formatDate(selectedRequest.date_of_purchase)}</p>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm col-span-2">
<label className="text-sm font-medium text-base-content/70">Payment Method</label>
<p className="mt-1 font-medium">{selectedRequest.payment_method}</p>
</div>
</div>
{selectedRequest.additional_info && (
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Additional Information</label>
<p className="mt-2 whitespace-pre-wrap">{selectedRequest.additional_info}</p>
</div>
)}
{selectedRequest.audit_notes && selectedRequest.audit_notes.filter(note => !note.is_private).length > 0 && (
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm border-l-4 border-primary">
<div className="flex items-center gap-2 mb-3">
<Icon icon="heroicons:chat-bubble-left-right" className="h-5 w-5 text-primary" />
<label className="text-base font-medium">Public Notes</label>
</div>
<div className="space-y-3">
{selectedRequest.audit_notes
.filter(note => !note.is_private)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.map((note, index) => (
<div key={index} className="card bg-base-100 p-4 hover:bg-base-200 transition-colors duration-200">
<p className="whitespace-pre-wrap text-base">{note.note}</p>
<div className="flex justify-between items-center mt-3 text-sm text-base-content/70">
<span className="flex items-center gap-1">
<Icon icon="heroicons:clock" className="h-4 w-4" />
{formatDate(note.timestamp)}
</span>
</div>
</div>
))}
</div>
</div>
)}
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70 mb-2">Receipts</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{(selectedRequest.receipts || []).map((receiptId, index) => (
<motion.button
key={receiptId || index}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="btn btn-outline btn-sm normal-case gap-2 hover:shadow-md transition-all duration-300"
onClick={() => handlePreviewFile(selectedRequest, receiptId)}
>
<Icon icon="heroicons:document" className="h-4 w-4" />
Receipt #{index + 1}
</motion.button>
))}
</div>
</div>
<div className="divider before:bg-base-300 after:bg-base-300"></div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Submitted At</label>
<p className="mt-1">{formatDate(selectedRequest.created)}</p>
</div>
<div className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
<label className="text-sm font-medium text-base-content/70">Last Updated</label>
<p className="mt-1">{formatDate(selectedRequest.updated)}</p>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* File Preview Modal */}
<AnimatePresence>
{showPreview && selectedReceipt && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="modal modal-open"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="modal-box max-w-7xl bg-base-100/95 backdrop-blur-md"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Receipt Details
</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
className="btn btn-ghost btn-sm btn-circle"
onClick={() => {
setShowPreview(false);
setSelectedReceipt(null);
}}
>
<Icon icon="heroicons:x-mark" className="h-5 w-5" />
</motion.button>
</div>
<div className="grid grid-cols-5 gap-6">
{/* Receipt Details */}
<div className="col-span-2 space-y-4">
<div>
<label className="text-sm font-medium">Location</label>
<p className="mt-1">{selectedReceipt.location_name}</p>
<p className="text-sm text-base-content/70">{selectedReceipt.location_address}</p>
</div>
<div>
<label className="text-sm font-medium">Date</label>
<p className="mt-1">{formatDate(selectedReceipt.date)}</p>
</div>
{selectedReceipt.notes && (
<div>
<label className="text-sm font-medium">Notes</label>
<p className="mt-1">{selectedReceipt.notes}</p>
</div>
)}
<div>
<label className="text-sm font-medium">Itemized Expenses</label>
<div className="mt-2 space-y-2">
{selectedReceipt.itemized_expenses.map((item, index) => (
<div key={index} className="card bg-base-200 p-3">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs font-medium">Description</label>
<p>{item.description}</p>
</div>
<div>
<label className="text-xs font-medium">Category</label>
<p>{item.category}</p>
</div>
<div>
<label className="text-xs font-medium">Amount</label>
<p>${item.amount.toFixed(2)}</p>
</div>
</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Tax</label>
<p className="mt-1">${selectedReceipt.tax.toFixed(2)}</p>
</div>
<div>
<label className="text-sm font-medium">Total</label>
<p className="mt-1">${(selectedReceipt.itemized_expenses.reduce((sum, item) => sum + item.amount, 0) + selectedReceipt.tax).toFixed(2)}</p>
</div>
</div>
</div>
{/* File Preview */}
<div className="col-span-3 border-l border-base-300 pl-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Receipt Image</h3>
<motion.a
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-sm btn-outline gap-2 hover:shadow-md transition-all duration-300"
>
<Icon icon="heroicons:arrow-top-right-on-square" className="h-4 w-4" />
View Full Size
</motion.a>
</div>
<div className="bg-base-200/50 backdrop-blur-sm rounded-lg p-4 shadow-sm">
{previewUrl ? (
<FilePreview
url={previewUrl}
filename={previewFilename}
isModal={false}
/>
) : (
<div className="flex flex-col items-center justify-center p-8 text-center space-y-4">
<div className="bg-warning/20 p-4 rounded-full">
<Icon icon="heroicons:exclamation-triangle" className="h-12 w-12 text-warning" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Receipt Image Not Available</h3>
<p className="text-base-content/70 max-w-md">
The receipt image could not be loaded. This might be due to permission issues or the file may not exist.
</p>
</div>
</div>
)}
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</>
);
}

View file

@ -1,50 +0,0 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
fullWidth = false,
className = '',
disabled = false,
...props
}) => {
// Base classes
const baseClasses = 'font-medium rounded-md focus:outline-none transition-colors';
// Size classes
const sizeClasses = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-2.5 text-base',
};
// Variant classes
const variantClasses = {
primary: `bg-blue-600 text-white hover:bg-blue-700 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`,
secondary: `bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`,
danger: `bg-red-600 text-white hover:bg-red-700 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`,
};
// Width classes
const widthClasses = fullWidth ? 'w-full' : '';
// Combine all classes
const buttonClasses = `${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]} ${widthClasses} ${className}`;
return (
<button
className={buttonClasses}
disabled={disabled}
{...props}
>
{children}
</button>
);
};

View file

@ -1,126 +0,0 @@
import React from 'react';
import { Icon } from "@iconify/react";
export type AlertType = 'info' | 'success' | 'warning' | 'error';
interface CustomAlertProps {
type: AlertType;
title: string;
message: string;
icon?: string;
actionLabel?: string;
onAction?: () => void;
onClose?: () => void;
className?: string;
}
const CustomAlert: React.FC<CustomAlertProps> = ({
type,
title,
message,
icon,
actionLabel,
onAction,
onClose,
className = '',
}) => {
// Default icons based on alert type
const defaultIcons = {
info: 'heroicons:information-circle',
success: 'heroicons:check-circle',
warning: 'heroicons:exclamation-triangle',
error: 'heroicons:document-text',
};
// Colors based on alert type
const colors = {
info: {
bg: 'bg-info/10',
border: 'border-info',
iconBg: 'bg-info/20',
iconColor: 'text-info',
actionBg: 'bg-info',
actionHover: 'hover:bg-info-focus',
actionRing: 'focus:ring-info',
},
success: {
bg: 'bg-success/10',
border: 'border-success',
iconBg: 'bg-success/20',
iconColor: 'text-success',
actionBg: 'bg-success',
actionHover: 'hover:bg-success-focus',
actionRing: 'focus:ring-success',
},
warning: {
bg: 'bg-warning/10',
border: 'border-warning',
iconBg: 'bg-warning/20',
iconColor: 'text-warning',
actionBg: 'bg-warning',
actionHover: 'hover:bg-warning-focus',
actionRing: 'focus:ring-warning',
},
error: {
bg: 'bg-error/10',
border: 'border-error',
iconBg: 'bg-error/20',
iconColor: 'text-error',
actionBg: 'bg-error',
actionHover: 'hover:bg-error-focus',
actionRing: 'focus:ring-error',
},
};
const color = colors[type];
const selectedIcon = icon || defaultIcons[type];
return (
<div className={`${color.bg} border-l-4 ${color.border} p-4 rounded-lg shadow-md ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 mt-0.5">
<div className={`p-1.5 ${color.iconBg} rounded-full`}>
<Icon
icon={selectedIcon}
className={`h-5 w-5 ${color.iconColor}`}
/>
</div>
</div>
<div>
<h3 className="font-semibold text-white mb-1">
{title}
</h3>
<p className="text-sm text-base-content/80">
{message}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
{actionLabel && onAction && (
<button
onClick={onAction}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${color.actionBg} ${color.actionHover} focus:outline-none focus:ring-2 focus:ring-offset-2 ${color.actionRing} transition-colors duration-200`}
>
{actionLabel}
</button>
)}
{onClose && (
<button
onClick={onClose}
className="p-1 rounded-full hover:bg-base-300/20 transition-colors duration-200"
aria-label="Close"
>
<Icon
icon="heroicons:x-mark"
className="h-5 w-5 text-base-content/60"
/>
</button>
)}
</div>
</div>
</div>
);
};
export default CustomAlert;

View file

@ -1,67 +0,0 @@
import { useState, useEffect } from "react";
import type { ReactNode } from "react";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import type { User } from "../../../schemas/pocketbase/schema";
import FirstTimeLoginPopup from "./FirstTimeLoginPopup";
interface DashboardWrapperProps {
children: ReactNode;
logtoApiEndpoint?: string;
}
const DashboardWrapper = ({ children, logtoApiEndpoint }: DashboardWrapperProps) => {
const [showOnboarding, setShowOnboarding] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkUserStatus = async () => {
try {
const auth = Authentication.getInstance();
if (!auth.isAuthenticated()) {
// Not logged in, so don't show onboarding
setIsLoading(false);
return;
}
const userData = auth.getCurrentUser() as User | null;
if (userData) {
// If signed_up is explicitly false, show onboarding
setShowOnboarding(userData.signed_up === false);
}
} catch (error) {
console.error("Error checking user status:", error);
} finally {
setIsLoading(false);
}
};
checkUserStatus();
}, []);
const handleOnboardingComplete = () => {
setShowOnboarding(false);
};
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-screen">
<span className="loading loading-spinner loading-lg text-primary"></span>
</div>
);
}
return (
<>
{showOnboarding && (
<FirstTimeLoginPopup
logtoApiEndpoint={logtoApiEndpoint}
onComplete={handleOnboardingComplete}
/>
)}
{children}
</>
);
};
export default DashboardWrapper;

File diff suppressed because it is too large Load diff

View file

@ -1,55 +0,0 @@
import { useState, useEffect } from "react";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import FirstTimeLoginPopup from "./FirstTimeLoginPopup";
interface FirstTimeLoginManagerProps {
logtoApiEndpoint?: string;
}
const FirstTimeLoginManager = ({ logtoApiEndpoint }: FirstTimeLoginManagerProps) => {
const [showOnboarding, setShowOnboarding] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkUserStatus = async () => {
try {
const auth = Authentication.getInstance();
if (!auth.isAuthenticated()) {
// Not logged in, so don't show onboarding
setIsLoading(false);
return;
}
// Using the new method to check if user has signed up
const isSignedUp = auth.isUserSignedUp();
console.log("User signed up status:", isSignedUp);
// If not signed up, show onboarding
setShowOnboarding(!isSignedUp);
} catch (error) {
console.error("Error checking user status:", error);
} finally {
setIsLoading(false);
}
};
checkUserStatus();
}, []);
const handleOnboardingComplete = () => {
setShowOnboarding(false);
};
if (isLoading || !showOnboarding) {
return null;
}
return (
<FirstTimeLoginPopup
logtoApiEndpoint={logtoApiEndpoint}
onComplete={handleOnboardingComplete}
/>
);
};
export default FirstTimeLoginManager;

View file

@ -1,701 +0,0 @@
import { useState, useEffect } from "react";
import { Icon } from "@iconify/react";
import { Update } from "../../../scripts/pocketbase/Update";
import { Authentication } from "../../../scripts/pocketbase/Authentication";
import type { User } from "../../../schemas/pocketbase/schema";
import CustomAlert from "./CustomAlert";
import { motion, AnimatePresence } from "framer-motion";
interface FirstTimeLoginPopupProps {
logtoApiEndpoint?: string;
onComplete?: () => void;
}
const ucsdMajors = [
"Aerospace Engineering",
"Aerospace Engineering Aerothermodynamics",
"Aerospace Engineering Astrodynamics and Space Applications",
"Aerospace Engineering Flight Dynamics and Controls",
"Anthropology",
"Art History/Criticism",
"Astronomy & Astrophysics",
"Biochemistry",
"Biochemistry and Cell Biology",
"Biology with Specialization in Bioinformatics",
"Bioengineering",
"Business Economics",
"Business Psychology",
"Chemical Engineering",
"Chemistry",
"Chinese Studies",
"Cinematic Arts",
"Classical Studies",
"Cognitive Science",
"Cognitive Science Clinical Aspects of Cognition",
"Cognitive Science Design and Interaction",
"Cognitive Science Language and Culture",
"Cognitive Science Machine Learning and Neural Computation",
"Cognitive Science Neuroscience",
"Cognitive and Behavioral Neuroscience",
"Communication",
"Computer Engineering",
"Computer Science",
"Computer Science Bioinformatics",
"Critical Gender Studies",
"Dance",
"Data Science",
"Ecology, Behavior and Evolution",
"Economics",
"Economics and Mathematics Joint Major",
"Economics-Public Policy",
"Education Sciences",
"Electrical Engineering",
"Electrical Engineering and Society",
"Engineering Physics",
"Environmental Chemistry",
"Environmental Systems (Earth Sciences)",
"Environmental Systems (Ecology, Behavior & Evolution)",
"Environmental Systems (Environmental Chemistry)",
"Environmental Systems (Environmental Policy)",
"Ethnic Studies",
"General Biology",
"General Physics",
"General Physics/Secondary Education",
"Geosciences",
"German Studies",
"Global Health",
"Global South Studies",
"History",
"Human Biology",
"Human Developmental Sciences",
"Human Developmental Sciences Equity and Diversity",
"Human Developmental Sciences Healthy Aging",
"Interdisciplinary Computing and the Arts",
"International Studies Anthropology",
"International Studies Economics",
"International Studies Economics (Joint BA/MIA)",
"International Studies History",
"International Studies International Business",
"International Studies International Business (Joint BA/MIA)",
"International Studies Linguistics",
"International Studies Literature",
"International Studies Philosophy",
"International Studies Political Science",
"International Studies Political Science (Joint BA/MIA)",
"International Studies Sociology",
"Italian Studies",
"Japanese Studies",
"Jewish Studies",
"Latin American Studies",
"Latin American Studies Mexico",
"Latin American Studies Migration and Border Studies",
"Linguistics",
"Linguistics Cognition and Language",
"Linguistics Language and Society",
"Linguistics Speech and Language Sciences",
"Linguistics: Language Studies",
"Literary Arts",
"Literatures in English",
"Marine Biology",
"Mathematical Biology",
"Mathematics",
"Mathematics Applied Science",
"Mathematics Computer Science",
"Mathematics Secondary Education",
"Mathematics (Applied)",
"Mechanical Engineering",
"Mechanical Engineering Controls and Robotics",
"Mechanical Engineering Fluid Mechanics and Thermal Systems",
"Mechanical Engineering Materials Science and Engineering",
"Mechanical Engineering Mechanics of Materials",
"Mechanical Engineering Renewable Energy and Environmental Flows",
"Media",
"Media Industries and Communication",
"Microbiology",
"Molecular Synthesis",
"Molecular and Cell Biology",
"Music",
"Music Humanities",
"NanoEngineering",
"Neurobiology / Physiology and Neuroscience",
"Oceanic and Atmospheric Sciences",
"Pharmacological Chemistry",
"Philosophy",
"Physics",
"Physics Astrophysics",
"Physics Biophysics",
"Physics Computational Physics",
"Physics Earth Sciences",
"Physics Materials Physics",
"Political Science",
"Political Science American Politics",
"Political Science Comparative Politics",
"Political Science Data Analytics",
"Political Science International Affairs",
"Political Science International Relations",
"Political Science Political Theory",
"Political Science Public Law",
"Political Science Public Policy",
"Political Science Race, Ethnicity, and Politics",
"Probability and Statistics",
"Psychology",
"Psychology Clinical Psychology",
"Psychology Cognitive Psychology",
"Psychology Developmental Psychology",
"Psychology Human Health",
"Psychology Sensation and Perception",
"Psychology Social Psychology",
"Public Health",
"Public Health Biostatistics",
"Public Health Climate and Environmental Sciences",
"Public Health Community Health Sciences",
"Public Health Epidemiology",
"Public Health Health Policy and Management Sciences",
"Public Health Medicine Sciences",
"Real Estate and Development",
"Russian, East European & Eurasian Studies",
"Sociology",
"Sociology American Studies",
"Sociology Culture and Communication",
"Sociology Economy and Society",
"Sociology International Studies",
"Sociology Law and Society",
"Sociology Science and Medicine",
"Sociology Social Inequality",
"Spanish Literature",
"Speculative Design",
"Structural Engineering",
"Structural Engineering Aerospace Structures",
"Structural Engineering Civil Structures",
"Structural Engineering Geotechnical Engineering",
"Structural Engineering Structural Health Monitoring/Non-Destructive Evaluation",
"Studio",
"Study of Religion",
"Theatre",
"Undeclared Humanities/Arts",
"Undeclared Physical Sciences",
"Undeclared Social Sciences",
"Urban Studies and Planning",
"World Literature and Culture",
"Other"
].sort(); // Ensure alphabetical order
// Animation variants
const overlayVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.3 } }
};
const popupVariants = {
hidden: { opacity: 0, scale: 0.9, y: 20 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: {
type: "spring",
damping: 25,
stiffness: 300,
duration: 0.4
}
},
exit: {
opacity: 0,
scale: 0.95,
y: -10,
transition: {
duration: 0.2
}
}
};
const formItemVariants = {
hidden: { opacity: 0, y: 10 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: i * 0.1,
duration: 0.3
}
})
};
const FirstTimeLoginPopup = ({ logtoApiEndpoint, onComplete }: FirstTimeLoginPopupProps) => {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [successMessage, setSuccessMessage] = useState("");
// Form state
const [formData, setFormData] = useState({
name: "",
username: "",
pid: "",
member_id: "", // Optional
graduation_year: new Date().getFullYear() + 4, // Default to 4 years from now
major: ""
});
// Validation state
const [isValid, setIsValid] = useState({
name: false,
username: false,
pid: false,
graduation_year: true,
major: false
});
// Get current user data
useEffect(() => {
const fetchUserData = async () => {
try {
const auth = Authentication.getInstance();
const userData = auth.getCurrentUser() as User | null;
if (userData) {
setFormData(prev => ({
...prev,
name: userData.name || "",
username: userData.username || "",
pid: userData.pid || "",
member_id: userData.member_id || "",
graduation_year: userData.graduation_year || new Date().getFullYear() + 4,
major: userData.major || ""
}));
// Update validation state based on existing data
setIsValid({
name: !!userData.name,
username: !!userData.username,
pid: !!userData.pid,
graduation_year: true,
major: !!userData.major
});
}
} catch (error) {
console.error("Error fetching user data:", error);
setErrorMessage("Failed to load your profile information. Please try again.");
} finally {
setIsLoading(false);
}
};
fetchUserData();
}, []);
// Validate form
useEffect(() => {
setIsValid({
name: formData.name.trim().length > 0,
username: /^[a-z0-9_]{3,20}$/.test(formData.username),
pid: /^[A-Za-z]\d{8}$/.test(formData.pid),
graduation_year:
!isNaN(parseInt(formData.graduation_year.toString())) &&
parseInt(formData.graduation_year.toString()) >= new Date().getFullYear(),
major: formData.major !== "" // Check if a major is selected
});
}, [formData]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name === "graduation_year" ? parseInt(value) : value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage("");
setSuccessMessage("");
const allRequiredValid = isValid.name && isValid.username && isValid.pid && isValid.graduation_year && isValid.major;
if (!allRequiredValid) {
setErrorMessage("Please fill in all required fields with valid information.");
return;
}
setIsSaving(true);
try {
// Update PocketBase user
const auth = Authentication.getInstance();
const userId = auth.getUserId();
if (!userId) {
throw new Error("No user ID found. Please log in again.");
}
const updateInstance = Update.getInstance();
await updateInstance.updateFields("users", userId, {
name: formData.name,
username: formData.username,
pid: formData.pid,
member_id: formData.member_id || undefined,
graduation_year: formData.graduation_year,
major: formData.major,
signed_up: true // Set signed_up to true after completing onboarding
});
console.log("Saving first-time user data with signed_up=true");
// Update Logto user if endpoint is provided
if (logtoApiEndpoint) {
const accessToken = localStorage.getItem("access_token");
if (accessToken) {
const response = await fetch("/api/update-logto-user", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: formData.name,
custom_data: {
username: formData.username,
pid: formData.pid,
member_id: formData.member_id || "",
graduation_year: formData.graduation_year,
major: formData.major,
signed_up: true
}
}),
});
if (!response.ok) {
throw new Error("Failed to update Logto user data");
}
}
}
console.log("Successfully updated PocketBase user with signed_up=true");
if (logtoApiEndpoint) {
console.log("Successfully updated Logto user profile with signed_up=true");
}
setSuccessMessage("Profile information saved successfully!");
// Call onComplete callback if provided
if (onComplete) {
setTimeout(() => {
onComplete();
}, 1500); // Show success message briefly before completing
}
} catch (error) {
console.error("Error saving user data:", error);
// Check if the error might be related to username uniqueness
const errorMsg = error instanceof Error ? error.message : "Unknown error";
if (errorMsg.toLowerCase().includes("username") || errorMsg.toLowerCase().includes("unique")) {
setErrorMessage("Failed to save your profile information. The username you chose might already be taken. Please try a different username.");
} else {
setErrorMessage("Failed to save your profile information. Please try again.");
}
} finally {
setIsSaving(false);
}
};
// Check if form can be submitted (all required fields valid)
const canSubmit = isValid.name && isValid.username && isValid.pid && isValid.graduation_year && isValid.major;
return (
<AnimatePresence>
<motion.div
className="fixed inset-0 z-50 overflow-y-auto bg-black bg-opacity-50 flex items-center justify-center p-4"
initial="hidden"
animate="visible"
exit="hidden"
variants={overlayVariants}
>
<motion.div
className="bg-base-100 shadow-xl rounded-xl max-w-2xl w-full"
variants={popupVariants}
initial="hidden"
animate="visible"
exit="exit"
>
<div className="p-6">
<div className="mb-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">Complete Your Profile</h2>
<div className="badge badge-primary p-3">
<Icon icon="heroicons:user" className="h-5 w-5" />
</div>
</div>
<p className="opacity-70 mt-2">
Welcome to IEEE UCSD! Please complete your profile to continue.
</p>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<CustomAlert
type="info"
title="Profile Setup Required"
message="You need to complete this information before you can access the dashboard. All fields marked with * are required."
className="mt-4"
/>
</motion.div>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<motion.span
className="loading loading-spinner loading-lg text-primary"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
</div>
) : (
<form onSubmit={handleSubmit}>
<div className="space-y-6">
{/* Name Field */}
<motion.div
className="form-control"
custom={0}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">Full Name <span className="text-error">*</span></span>
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="Enter your full name"
className={`input input-bordered w-full ${!isValid.name && formData.name ? 'input-error' : ''}`}
required
/>
{!isValid.name && formData.name && (
<label className="label">
<span className="label-text-alt text-error">Please enter your full name</span>
</label>
)}
</motion.div>
{/* Username Field */}
<motion.div
className="form-control"
custom={1}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">Username <span className="text-error">*</span></span>
</label>
<div className="relative">
<input
type="text"
name="username"
value={formData.username}
onChange={handleInputChange}
placeholder="your_username"
className={`input input-bordered w-full ${!isValid.username && formData.username ? 'input-error' : ''}`}
required
/>
</div>
{!isValid.username && formData.username && (
<label className="label">
<span className="label-text-alt text-error">Username must be 3-20 characters, lowercase letters, numbers, and underscores only</span>
</label>
)}
<label className="label">
<span className="label-text-alt opacity-70">Choose a unique username for your IEEEUCSD SSO account. This only impacts your SSO login</span>
</label>
</motion.div>
{/* PID Field */}
<motion.div
className="form-control"
custom={2}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">PID <span className="text-error">*</span></span>
</label>
<input
type="text"
name="pid"
value={formData.pid}
onChange={handleInputChange}
placeholder="A12345678"
className={`input input-bordered w-full ${!isValid.pid && formData.pid ? 'input-error' : ''}`}
required
/>
{!isValid.pid && formData.pid && (
<label className="label">
<span className="label-text-alt text-error">PID must be in format A12345678</span>
</label>
)}
</motion.div>
{/* Member ID Field (Optional) */}
<motion.div
className="form-control"
custom={3}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">IEEE Member ID <span className="text-opacity-50">(optional)</span></span>
</label>
<input
type="text"
name="member_id"
value={formData.member_id}
onChange={handleInputChange}
placeholder="Your IEEE member ID (if you have one)"
className="input input-bordered w-full"
/>
</motion.div>
{/* Graduation Year Field */}
<motion.div
className="form-control"
custom={4}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">Expected Graduation Year <span className="text-error">*</span></span>
</label>
<select
name="graduation_year"
value={formData.graduation_year}
onChange={handleInputChange}
className={`select select-bordered w-full ${!isValid.graduation_year ? 'select-error' : ''}`}
required
>
{Array.from({ length: 10 }, (_, i) => {
const year = new Date().getFullYear() + i;
return (
<option key={year} value={year}>
{year}
</option>
);
})}
</select>
{!isValid.graduation_year && (
<label className="label">
<span className="label-text-alt text-error">Please select a valid graduation year</span>
</label>
)}
</motion.div>
{/* Major Field */}
<motion.div
className="form-control"
custom={5}
variants={formItemVariants}
initial="hidden"
animate="visible"
>
<label className="label">
<span className="label-text font-medium">Major <span className="text-error">*</span></span>
</label>
<select
name="major"
value={formData.major}
onChange={handleInputChange}
className={`select select-bordered w-full ${!isValid.major && formData.major ? 'select-error' : ''}`}
required
>
<option value="" disabled>Select your major</option>
{ucsdMajors.map(major => (
<option key={major} value={major}>
{major}
</option>
))}
</select>
{!isValid.major && formData.major && (
<label className="label">
<span className="label-text-alt text-error">Please select your major</span>
</label>
)}
</motion.div>
{/* Error/Success Messages */}
<AnimatePresence>
{errorMessage && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<CustomAlert
type="error"
title="Error Saving Profile"
message={errorMessage}
icon="heroicons:exclamation-circle"
className="mt-4"
/>
</motion.div>
)}
{successMessage && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<CustomAlert
type="success"
title="Profile Saved"
message={successMessage}
icon="heroicons:check-circle"
className="mt-4"
/>
</motion.div>
)}
</AnimatePresence>
{/* Submit Button */}
<motion.div
className="form-control mt-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.3 }}
>
<button
type="submit"
className="btn btn-primary"
disabled={!canSubmit || isSaving}
>
{isSaving ? (
<>
<span className="loading loading-spinner loading-sm"></span>
Saving...
</>
) : (
"Complete Profile"
)}
</button>
<p className="text-xs text-center mt-3 opacity-70">
<span className="text-error">*</span> Required fields
</p>
</motion.div>
</div>
</form>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
};
export default FirstTimeLoginPopup;

View file

@ -1,115 +0,0 @@
import { useState, useEffect } from 'react';
import { getCurrentTheme, toggleTheme } from '../../../utils/themeUtils';
import { ThemeService } from '../../../scripts/database/ThemeService';
import { Update } from '../../../scripts/pocketbase/Update';
import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { Collections } from '../../../schemas/pocketbase/schema';
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>(getCurrentTheme());
const [isLoading, setIsLoading] = useState(false);
const auth = Authentication.getInstance();
const update = Update.getInstance();
useEffect(() => {
// Initialize theme from IndexedDB
const loadTheme = async () => {
try {
const themeService = ThemeService.getInstance();
const settings = await themeService.getThemeSettings();
setTheme(settings.theme);
} catch (error) {
console.error('Error loading theme:', error);
}
};
loadTheme();
// Add event listener for theme changes
const handleThemeChange = () => {
setTheme(getCurrentTheme());
};
window.addEventListener('themechange', handleThemeChange);
return () => {
window.removeEventListener('themechange', handleThemeChange);
};
}, []);
const handleToggle = async () => {
setIsLoading(true);
try {
// Toggle theme in IndexedDB
await toggleTheme();
const newTheme = getCurrentTheme();
setTheme(newTheme);
// Also update user preferences in PocketBase if user is authenticated
const user = auth.getCurrentUser();
if (user) {
try {
// Get current display preferences
let displayPreferences = { theme: newTheme, fontSize: 'medium' };
if (user.display_preferences && typeof user.display_preferences === 'string') {
try {
const userPrefs = JSON.parse(user.display_preferences);
displayPreferences = {
...userPrefs,
theme: newTheme
};
} catch (e) {
console.error('Error parsing display preferences:', e);
}
}
// Update user record
await update.updateFields(
Collections.USERS,
user.id,
{
display_preferences: JSON.stringify(displayPreferences)
}
);
} catch (error) {
console.error('Error updating user preferences:', error);
}
}
} catch (error) {
console.error('Error toggling theme:', error);
} finally {
setIsLoading(false);
}
};
return (
<div className="dropdown dropdown-end">
<button
onClick={handleToggle}
className={`btn btn-circle btn-sm ${isLoading ? 'loading' : ''}`}
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme (Light mode is experimental)`}
disabled={isLoading}
>
{!isLoading && (
theme === 'light' ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)
)}
</button>
<div className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 mt-2 text-xs">
<div className="p-2">
<p className="font-bold text-warning mb-1">Warning:</p>
<p>Light mode is experimental and not fully supported yet. Some UI elements may not display correctly.</p>
</div>
</div>
</div>
);
}

View file

@ -1,85 +0,0 @@
import React, { useEffect, useState } from 'react';
interface ToastProps {
message: string;
type?: 'success' | 'error' | 'info' | 'warning';
duration?: number;
onClose?: () => void;
}
export const Toast: React.FC<ToastProps> = ({
message,
type = 'info',
duration = 3000,
onClose,
}) => {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
if (onClose) onClose();
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
if (!isVisible) return null;
// Type-based styling
const typeStyles = {
success: 'bg-green-100 border-green-500 text-green-700 dark:bg-green-800 dark:text-green-100',
error: 'bg-red-100 border-red-500 text-red-700 dark:bg-red-800 dark:text-red-100',
warning: 'bg-yellow-100 border-yellow-500 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-100',
info: 'bg-blue-100 border-blue-500 text-blue-700 dark:bg-blue-800 dark:text-blue-100',
};
// Icons based on type
const icons = {
success: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd"></path>
</svg>
),
error: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd"></path>
</svg>
),
warning: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"></path>
</svg>
),
info: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zm-1 7a1 1 0 100 2h.01a1 1 0 100-2H10z" clipRule="evenodd"></path>
</svg>
),
};
return (
<div className="fixed top-4 right-4 z-50 animate-fade-in">
<div className={`flex items-center p-4 mb-4 border-l-4 rounded-md shadow-md ${typeStyles[type]}`} role="alert">
<div className="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 mr-3">
{icons[type]}
</div>
<div className="ml-3 text-sm font-medium">{message}</div>
<button
type="button"
className="ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex h-8 w-8 hover:bg-gray-200 dark:hover:bg-gray-700"
onClick={() => {
setIsVisible(false);
if (onClose) onClose();
}}
aria-label="Close"
>
<span className="sr-only">Close</span>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd"></path>
</svg>
</button>
</div>
</div>
);
};

View file

@ -1,43 +0,0 @@
import { Toaster } from 'react-hot-toast';
import { useState, useEffect } from 'react';
// Centralized toast provider to ensure consistent rendering
export default function ToastProvider() {
const [isMounted, setIsMounted] = useState(false);
// Only render the Toaster component on the client side
useEffect(() => {
setIsMounted(true);
}, []);
// Don't render anything during SSR
if (!isMounted) {
return null;
}
return (
<Toaster
position="top-center"
toastOptions={{
duration: 4000,
style: {
background: '#333',
color: '#fff',
borderRadius: '8px',
padding: '12px',
},
success: {
style: {
background: 'green',
},
},
error: {
style: {
background: 'red',
},
duration: 2000,
},
}}
/>
);
}

View file

@ -1,156 +0,0 @@
import React, { useState, useRef, useCallback } from 'react';
import { Icon } from '@iconify/react';
import { motion } from 'framer-motion';
import FilePreview from './FilePreview';
interface ZoomablePreviewProps {
url: string;
filename: string;
}
export default function ZoomablePreview({ url, filename }: ZoomablePreviewProps) {
const [zoom, setZoom] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const zoomLevels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3];
const currentZoomIndex = zoomLevels.findIndex(level => Math.abs(level - zoom) < 0.01);
const handleZoomIn = useCallback(() => {
const nextIndex = Math.min(currentZoomIndex + 1, zoomLevels.length - 1);
setZoom(zoomLevels[nextIndex]);
}, [currentZoomIndex]);
const handleZoomOut = useCallback(() => {
const prevIndex = Math.max(currentZoomIndex - 1, 0);
setZoom(zoomLevels[prevIndex]);
}, [currentZoomIndex]);
const handleZoomReset = useCallback(() => {
setZoom(1);
setPosition({ x: 0, y: 0 });
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (zoom > 1) {
setIsDragging(true);
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y
});
}
}, [zoom, position]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isDragging && zoom > 1) {
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y
});
}
}, [isDragging, dragStart, zoom]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -1 : 1;
const newZoomIndex = Math.max(0, Math.min(zoomLevels.length - 1, currentZoomIndex + delta));
setZoom(zoomLevels[newZoomIndex]);
// Reset position when zooming out to 100% or less
if (zoomLevels[newZoomIndex] <= 1) {
setPosition({ x: 0, y: 0 });
}
}, [currentZoomIndex]);
return (
<div className="relative h-full">
{/* Zoom Controls */}
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2 bg-base-100/90 backdrop-blur-sm rounded-lg p-2 shadow-lg">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={handleZoomIn}
disabled={currentZoomIndex >= zoomLevels.length - 1}
title="Zoom In"
>
<Icon icon="heroicons:plus" className="h-3 w-3" />
</motion.button>
<div className="text-xs text-center font-mono px-1">
{Math.round(zoom * 100)}%
</div>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={handleZoomOut}
disabled={currentZoomIndex <= 0}
title="Zoom Out"
>
<Icon icon="heroicons:minus" className="h-3 w-3" />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="btn btn-xs btn-ghost"
onClick={handleZoomReset}
disabled={zoom === 1 && position.x === 0 && position.y === 0}
title="Reset Zoom"
>
<Icon icon="heroicons:arrows-pointing-out" className="h-3 w-3" />
</motion.button>
</div>
{/* Zoom Indicator */}
{zoom !== 1 && (
<div className="absolute top-4 left-4 z-10 bg-primary/90 backdrop-blur-sm text-primary-content text-xs px-2 py-1 rounded">
{zoom > 1 ? 'Click and drag to pan' : ''}
</div>
)}
{/* Preview Container */}
<div
ref={containerRef}
className="relative h-full overflow-hidden rounded-lg cursor-move"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
style={{
cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
}}
>
<div
className="h-full transition-transform duration-100"
style={{
transform: `scale(${zoom}) translate(${position.x / zoom}px, ${position.y / zoom}px)`,
transformOrigin: 'center center'
}}
>
<div className="p-4 h-full">
<FilePreview
url={url}
filename={filename}
isModal={false}
/>
</div>
</div>
</div>
{/* Usage Hint */}
<div className="absolute bottom-4 left-4 z-10 text-xs text-base-content/50 bg-base-100/80 backdrop-blur-sm px-2 py-1 rounded">
Scroll to zoom Click and drag to pan
</div>
</div>
);
}

View file

@ -45,17 +45,9 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
if (!day) return [];
const dayStr = formatDate(day);
return events.filter((event) => {
let eventDate;
if (event.start.dateTime) {
// For events with specific times, convert to local timezone
const date = new Date(event.start.dateTime);
eventDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
.toISOString()
.split("T")[0];
} else {
// For all-day events, use the date directly
eventDate = event.start.date;
}
const eventDate = event.start.dateTime
? new Date(event.start.dateTime).toISOString().split("T")[0]
: event.start.date;
return eventDate === dayStr;
});
};
@ -76,26 +68,26 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
const loadGapiAndListEvents = async () => {
try {
// console.log("Starting to load events...");
console.log("Starting to load events...");
if (typeof window.gapi === "undefined") {
// console.log("Loading GAPI script...");
console.log("Loading GAPI script...");
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://apis.google.com/js/api.js";
document.body.appendChild(script);
script.onload = () => {
// console.log("GAPI script loaded");
console.log("GAPI script loaded");
window.gapi.load("client", resolve);
};
script.onerror = () => {
// console.error("Failed to load GAPI script");
console.error("Failed to load GAPI script");
reject(new Error("Failed to load the Google API script."));
};
});
}
// console.log("Initializing GAPI client...");
console.log("Initializing GAPI client...");
await window.gapi.client.init({
apiKey: CALENDAR_API_KEY,
discoveryDocs: [
@ -115,7 +107,7 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
0,
);
// console.log("Fetching events...");
console.log("Fetching events...");
const response = await window.gapi.client.calendar.events.list({
calendarId: calendarId,
timeZone: userTimeZone,
@ -125,13 +117,13 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
orderBy: "startTime",
});
// console.log("Response received:", response);
console.log("Response received:", response);
if (response.result.items) {
setEvents(response.result.items);
}
} catch (error) {
// console.error("Detailed Error: ", error);
console.error("Detailed Error: ", error);
setError(error.message || "Failed to load events");
} finally {
setLoading(false);

View file

@ -48,16 +48,16 @@ const EventList = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
const loadGapiAndListEvents = async () => {
try {
// console.log("Starting to load events...");
console.log("Starting to load events...");
if (typeof window.gapi === "undefined") {
// console.log("Loading GAPI script...");
console.log("Loading GAPI script...");
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://apis.google.com/js/api.js";
document.body.appendChild(script);
script.onload = () => {
// console.log("GAPI script loaded");
console.log("GAPI script loaded");
window.gapi.load("client", resolve);
};
script.onerror = () => {
@ -67,7 +67,7 @@ const EventList = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
});
}
// console.log("Initializing GAPI client...");
console.log("Initializing GAPI client...");
await window.gapi.client.init({
apiKey: apiKey,
discoveryDocs: [
@ -75,7 +75,7 @@ const EventList = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
],
});
// console.log("Fetching events...");
console.log("Fetching events...");
const response = await window.gapi.client.calendar.events.list({
calendarId: calendarId,
timeZone: userTimeZone,
@ -85,7 +85,7 @@ const EventList = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
orderBy: "startTime",
});
// console.log("Response received:", response);
console.log("Response received:", response);
if (response.result.items) {
setEvents(response.result.items);

View file

@ -5,19 +5,11 @@ import { LiaDotCircle } from "react-icons/lia";
---
<div class="w-full md:pt-[5vw] pt-[10vw] flex justify-center relative">
<div class="w-[45%] rounded-[2vw] aspect-[2/1] relative">
<div
id="event-skeleton"
class="skeleton absolute inset-0 rounded-[2vw] z-0"
>
</div>
<Image
id="event-image"
src={eventbg}
alt="Event Page Background"
class="w-full h-full rounded-[2vw] object-cover absolute top-0 left-0 z-1"
/>
</div>
<Image
src={eventbg}
alt="Event Page Background"
class="md:w-[45%] w-[80%] rounded-[2vw] aspect-[2/1] object-cover"
/>
<div
class="absolute -bottom-[6%] md:left-[20%] left-[10%] flex items-center md:text-[3vw] text-[6vw] py-[1.5%] px-[3%] text-white bg-ieee-black rounded-[2vw]"
>
@ -25,16 +17,3 @@ import { LiaDotCircle } from "react-icons/lia";
<p>EVENTS</p>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const image = document.getElementById("event-image");
const skeleton = document.getElementById("event-skeleton");
if (image && skeleton) {
image.onload = () => {
skeleton.style.display = "none";
};
}
});
</script>

View file

@ -4,63 +4,41 @@ import { GoArrowDownRight } from "react-icons/go";
import { FaGear } from "react-icons/fa6";
import { IoMdCalendar } from "react-icons/io";
import { RiRobot2Fill } from "react-icons/ri";
const { image, text, link, delay } = Astro.props;
const {image, text, link, delay} = Astro.props;
---
<div data-inview class={` animate-ease-in-out md:w-[15vw] w-[24vw] relative group in-view:animate-fade-up animate-delay-${delay} animate-duration-1000`}>
<div
data-inview
class={` animate-ease-in-out md:w-[15vw] w-[24vw] relative group in-view:animate-fade-up animate-delay-${delay} animate-duration-1000`}
>
<img
src={image}
alt="involvement background"
class="opacity-70 aspect-[230/425] object-cover rounded-[2vw] group-hover:opacity-50 duration-300"
/>
<Link
href={link}
target={text === "H.A.R.D. HACK" ? "_blank" : "_self"}
className="absolute top-0 md:w-[15vw] w-[25vw] pt-[5%] aspect-[230/425] flex flex-col justify-between"
>
<div class="w-full flex justify-end md:pr-[5%] pr-[2vw]">
<div
class="bg-white w-fit rounded-full aspect-square p-[0.5vw] text-ieee-black text-[4.5vw] md:text-[2vw]"
>
{
text === "PROJECTS" ? (
<RiRobot2Fill />
) : text === "EVENTS" ? (
<IoMdCalendar />
) : (
<FaGear />
)
}
</div>
</div>
<div
class="px-[3%] text-white w-full bg-gradient-to-t from-black via-black to-transparent rounded-b-[2vw] pt-[20vw] pb-[3vw] md:pt-[30%] md:pb-[5%]"
<img src={image} alt="involvement background" class="opacity-70 aspect-[230/425] object-cover rounded-[2vw] group-hover:opacity-50 duration-300"/>
<Link
href={link}
target={text==="H.A.R.D. HACK"? "_blank":"_self"}
className="absolute top-0 md:w-[15vw] w-[25vw] pt-[5%] aspect-[230/425] flex flex-col justify-between"
>
<div
class="text-[2vw] md:text-[1.1vw] duration-300 flex w-full px-[3%] justify-between items-end"
>
<p
class="pt-[3%] pb-[2%] px-[10%] border-[0.1vw] border-white rounded-full group-hover:text-ieee-yellow group-hover:border-ieee-yellow duration-300 font-light"
>
{text}
</p>
<GoArrowDownRight
className="text-[5vw] md:text-[3vw] leading-none group-hover:text-ieee-yellow"
/>
</div>
{
text === "H.A.R.D. HACK" && (
<p class="text-[1.8vw] md:text-[1vw] text-center pt-[10%] group-hover:text-ieee-yellow duration-300">
UC San Diegos largest hardware focused hackathon hold by IEEE UCSD,
HKN, and TNT
</p>
)
}
</div>
</Link>
<div class="w-full flex justify-end md:pr-[5%] pr-[2vw]">
<div class="bg-white w-fit rounded-full aspect-square p-[0.5vw] text-ieee-black text-[4.5vw] md:text-[2vw]">
{
text === "PROJECTS"? <RiRobot2Fill/>:
text === "EVENTS"? <IoMdCalendar/>:
<FaGear/>
}
</div>
</div>
<div class="px-[3%] text-white w-full bg-gradient-to-t from-black via-black to-transparent rounded-b-[2vw] pt-[20vw] pb-[3vw] md:pt-[30%] md:pb-[5%]">
<div class="text-[2vw] md:text-[1.1vw] duration-300 flex w-full px-[3%] justify-between items-end">
<p class="pt-[3%] pb-[2%] px-[10%] border-[0.1vw] border-white rounded-full group-hover:text-ieee-yellow group-hover:border-ieee-yellow duration-300 font-light">
{text}
</p>
<GoArrowDownRight className="text-[5vw] md:text-[3vw] leading-none group-hover:text-ieee-yellow"/>
</div>
{text === "H.A.R.D. HACK" &&
<p class="text-[1.8vw] md:text-[1vw] text-center pt-[10%] group-hover:text-ieee-yellow duration-300">
UC San Diegos largest
hardware focused hackathon
hold by IEEE UCSD, HKN, and TNT
</p>
}
</div>
</Link>
</div>

View file

@ -1,38 +1,23 @@
---
import Link from "next/link";
import { IoIosArrowDroprightCircle } from "react-icons/io";
const { title, text, link, number, delay } = Astro.props;
const { title, text, link, number, delay } = Astro.props;
---
<div
data-inview
class={`animate-ease-in-out relative text-white flex flex-col items-center w-[30vw] md:w-[17vw] bg-gradient-to-b from-ieee-blue-100/40 to-ieee-black md:h-[33vw] h-[40vw] border-[0.1vw] border-white/40 rounded-[2vw] md:pt-[5%] pt-[15%] pb-[3%] in-view:animate-fade-down duration-500 animate-delay-${delay}`}
>
<div
class="rounded-full aspect-square w-[7vw] md:w-[5.5vw] absolute bg-gradient-to-b from-ieee-blue-100 to-ieee-blue-300 -top-[10%] shadow-xl shadow-ieee-blue-300"
>
</div>
<p class="text-[2.7vw] md:text-[1.7vw] font-bold text-center">
{title}
</p>
<p
class={`text-[1.7vw] md:text-[1.2vw] w-4/5 ${title === "Professional Development" ? "md:mt-[2%] mt-[2%]" : "md:mt-[15%] mt-[5%]"}`}
>
{text}
</p>
<div data-inview class={`animate-ease-in-out relative text-white flex flex-col items-center w-[30vw] md:w-[17vw] bg-gradient-to-b from-ieee-blue-100/40 to-ieee-black md:h-[33vw] h-[40vw] border-[0.1vw] border-white/40 rounded-[2vw] md:pt-[5%] pt-[15%] pb-[3%] in-view:animate-fade-down duration-500 animate-delay-${delay}`}>
<div class="rounded-full aspect-square w-[7vw] md:w-[5.5vw] absolute bg-gradient-to-b from-ieee-blue-100 to-ieee-blue-300 -top-[10%] shadow-xl shadow-ieee-blue-300"/>
<p class="text-[2.7vw] md:text-[1.7vw] font-bold text-center">
{title}
</p>
<p class={`text-[1.7vw] md:text-[1.2vw] w-4/5 ${title === "Professional Development" ? "md:mt-[2%] mt-[2%]":"md:mt-[15%] mt-[5%]"}`}>
{text}
</p>
<Link
href={link}
className="flex items-center text-[2vw] md:text-[1.2vw] mb-[3%] absolute md:bottom-[22%] bottom-[25%] hover:text-ieee-yellow duration-300"
>
more details
<IoIosArrowDroprightCircle
className="ml-[0.5vw] text-[2vw] md:text-[1.4vw]"
/>
</Link>
<p
class="text-[7vw] md:text-[3.7vw] font-bold text-ieee-blue-300/50 absolute bottom-[5%]"
>
{number}
</p>
</div>
<Link href={link} className="flex items-center text-[2vw] md:text-[1.2vw] mb-[3%] absolute md:bottom-[22%] bottom-[25%] hover:text-ieee-yellow duration-300">
more details
<IoIosArrowDroprightCircle className="ml-[0.5vw] text-[2vw] md:text-[1.4vw]"/>
</Link>
<p class="text-[7vw] md:text-[3.7vw] font-bold text-ieee-blue-300/50 absolute bottom-[5%]">
{number}
</p>
</div>

View file

@ -7,9 +7,9 @@ import { RiInstagramFill } from "react-icons/ri";
import { MdEmail } from "react-icons/md";
---
<div class="w-full flex justify-between space-x-2 pt-[2%]">
<div class="w-full flex justify-between space-x-2">
<div
class="md:pt-[5%] pt-[6%] bg-gradient-to-t to-ieee-blue-100/30 p-[5vw] via-ieee-black from-ieee-black md:w-[53%] w-[60%] md:h-[45vw] h-[65vw] border-white/70 border-[0.1vw] rounded-[3vw]"
class="md:pt-[5%] pt-[6%] bg-gradient-to-t to-ieee-blue-100/30 p-[5vw] via-ieee-black from-ieee-black md:w-[53%] w-[60%] md:h-[40vw] h-[65vw] border-white/70 border-[0.1vw] rounded-[3vw]"
>
<p
data-inview
@ -60,12 +60,12 @@ import { MdEmail } from "react-icons/md";
</div>
</div>
<div
class="md:w-[46%] w-[40%] md:h-[45vw] h-[65vw] border-white/70 border-[0.1vw] rounded-[3vw] bg-gradient-to-b to-ieee-blue-100/60 from-ieee-black"
class="md:w-[46%] w-[40%] md:h-[40vw] h-[65vw] border-white/70 border-[0.1vw] rounded-[3vw] bg-gradient-to-b to-ieee-blue-100/60 from-ieee-black"
>
<Image
src={landingimg}
alt="circuit"
class="w-[95%] md:h-[45vw] h-[60vw] object-contain"
class="w-[95%] md:h-[40vw] h-[60vw] object-contain"
/>
</div>
</div>

View file

@ -3,32 +3,29 @@ import { LiaDotCircle } from "react-icons/lia";
import ProjectSection from "./ProjectSection.astro";
---
<div class="text-white md:mt-[15%] mt-[25%] mb-[30%] mx-[10%] relative">
<div class="flex items-center md:text-[2.7vw] text-[4.5vw] mb-[7%]">
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
<p>Annual Projects</p>
</div>
<div class="flex items-center md:text-[2.7vw] text-[4.5vw] mb-[7%]">
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
<p>
Annual Projects
</p>
</div>
<p
class="md:text-[1.2vw] text-[2vw] mt-[5%] mb-[3%] md:ml-[10%] ml-[5%] font-light"
>
Join in the fray of internationally-recognized competition through Robocup,
Signal Processing, Supercomputing, and Micromouse at IEEE @ UCSD!
Participate in an intensive collaborative environment that challenges hard
skills of hardware and software.
</p>
<div
class="flex items-center md:text-[1.2vw] text-[2vw] font-light mb-[7%] justify-between md:ml-[10%]"
>
<LiaDotCircle className="md:text-[2vw] text-[3vw] pt-[0.5%]" />
<p class="md:text-[2vw] text-[3vw] mr-[2vw]">Skills & Requirements</p>
<p class="w-3/5">
IEEE @ UCSDs annual projects are intended for students with intermediate
experience with hardware or software. Participation on teams assemble an
array of skills and talents of soft and hard skills.
<p class="md:text-[1.2vw] text-[2vw] mt-[5%] mb-[3%] md:ml-[10%] ml-[5%] font-light">
Join in the fray of internationally-recognized competition through Robocup, Signal Processing, Supercomputing, and Micromouse at IEEE @ UCSD! Participate in an intensive collaborative environment that challenges hard skills of hardware and software.
</p>
</div>
<ProjectSection />
</div>
<div class="flex items-center md:text-[1.2vw] text-[2vw] font-light mb-[7%] justify-between md:ml-[10%]">
<LiaDotCircle className="md:text-[2vw] text-[3vw] pt-[0.5%]"/>
<p class="md:text-[2vw] text-[3vw] mr-[2vw]">
Skills & Requirements
</p>
<p class="w-3/5">
IEEE @ UCSDs annual projects are intended for students with intermediate experience with hardware or software. Participation on teams assemble an array of skills and talents of soft and hard skills.
</p>
</div>
<ProjectSection />
</div>

View file

@ -6,172 +6,118 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
import { Image } from "astro:assets";
---
<div
class="flex flex-col md:flex-row md:gap-[1.5vw] md:h-[30vw] w-full max-w-[90vw] md:max-w-none mt-[10%] md:mt-0 mx-auto md:ml-0"
>
{
Object.entries(annualProjects).map(([title, project], index) => (
<a
href={project.url || "#"}
class={`project-card group relative flex-1 rounded-[1.5vw] overflow-hidden transition-all duration-500 ease-in-out md:hover:flex-[2] cursor-pointer mb-[5vw] md:mb-0 ${index === 0 ? "expanded" : ""}`}
data-project={index + 1}
target={title === "Supercomputing" ? "_blank" : "_self"}
>
<div class="skeleton absolute inset-0 rounded-[1.5vw] z-0" />
<Image
src={project.image}
alt={`${title} Project`}
width={668}
height={990}
class="opacity-70 w-full h-[50vw] md:h-full object-cover rounded-[1.5vw] aspect-[2/3] transition-transform duration-500 ease-in-out md:group-hover:scale-110"
/>
<div class="absolute flex items-end bottom-0 left-0 px-[5%] pb-[5%] md:pt-[17%] bg-gradient-to-b from-transparent to-black via-black rounded-b-[1.5vw] text-white z-10 w-full transition-transform duration-300 md:[.expanded_&]:pb-[5%]">
<div class="w-full">
<p class="py-[1.5%] px-[8%] w-fit border-[0.1vw] border-white rounded-full text-nowrap md:text-[1.2vw] text-[3vw] font-light mb-[5%]">
{title}
</p>
<p class="text-[3vw] md:text-[1.3vw] block md:hidden md:[.expanded_&]:block transition-all duration-300 overflow-hidden mb-[3vw]">
{project.description}
</p>
<div class="w-full flex justify-end md:invisible visible md:[.expanded_&]:visible h-auto md:h-0 md:[.expanded_&]:h-auto">
<div class="flex items-center md:text-[1.3vw] text-[3vw] md:[.expanded_&]:mt-[5%]">
more details
<IoIosArrowDroprightCircle className="ml-[0.5vw] text-[3vw] md:text-[1.4vw]" />
</div>
</div>
</div>
<GoArrowDownRight className="text-[3.2vw] [.expanded_&]:text-[0px] pt-[2%] hidden md:block" />
</div>
<div class="md:flex md:gap-[1.5vw] md:h-[30vw] md:w-auto w-[70vw] mt-[20%] md:mt-0 ml-[5%] md:ml-0">
{
Object.entries(annualProjects).map(([title, project], index) => (
<a
href={project.url || "#"}
class={`project-card group relative flex-1 rounded-[1.5vw] overflow-hidden transition-all duration-500 ease-in-out md:hover:flex-[2] cursor-pointer ${index === 0 ? "expanded" : ""}`}
data-project={index + 1}
target={title === "Supercomputing" ? "_blank" : "_self"}
>
<Image
src={project.image}
alt={`${title} Project`}
width={668}
height={990}
class="opacity-70 w-full md:h-full h-[30vw] object-cover rounded-[1.5vw] aspect-[2/3] transition-transform duration-500 ease-in-out md:group-hover:scale-110 my-[2vw] md:my-0"
/>
<div class="absolute flex items-end bottom-0 left-0 px-[5%] pb-[5%] md:pt-[17%] bg-gradient-to-b from-transparent to-black via-black rounded-b-[1.5vw] text-white z-10 w-full transition-transform duration-300 md:[.expanded_&]:pb-[5%]">
<div class="md:w-full w-[70vw]">
<p class="py-[1.5%] px-[8%] w-fit border-[0.1vw] border-white rounded-full text-nowrap md:text-[1.2vw] text-[2vw] font-light mb-[5%]">
{title}
</p>
<p class="text-[2vw] md:text-[1.3vw] md:hidden md:[.expanded_&]:contents transition-all duration-300 overflow-hidden">
{project.description}
</p>
<div class="w-full flex justify-end md:invisible visible md:[.expanded_&]:visible h-0 md:[.expanded_&]:h-auto">
<div class="flex items-center md:text-[1.3vw] text-[2vw] md:[.expanded_&]:mt-[5%]">
more details
<IoIosArrowDroprightCircle className="ml-[0.5vw] text-[1.4vw]" />
</div>
</div>
</div>
<GoArrowDownRight className="text-[3.2vw] [.expanded_&]:text-[0px] pt-[2%]" />
</div>
<div class="bg-white w-fit rounded-full aspect-square p-[0.5vw] text-ieee-black md:text-[2vw] text-[3vw] absolute top-[3%] right-[5%]">
<FaGear
data-inview
className="in-view:rotate-[500deg] duration-[3000ms] group-hover:rotate-[750deg]"
/>
</div>
</a>
))
}
<div class="bg-white w-fit rounded-full aspect-square p-[0.5vw] text-ieee-black md:text-[2vw] text-[3vw] absolute top-[3%] right-[5%]">
<FaGear data-inview className="in-view:rotate-[500deg] duration-[3000ms] group-hover:rotate-[750deg]" />
</div>
</a>
))
}
</div>
<style>
.project-card {
background-size: cover;
background-position: center;
}
.project-card img {
transition: transform 0.3s ease-in-out;
}
@media (min-width: 768px) {
.project-card.expanded {
flex: 2;
}
.project-card.expanded img {
transform: scale(1.1);
}
.project-card.expanded p {
opacity: 1;
}
}
@media (max-width: 767px) {
.project-card {
height: auto;
margin-bottom: 5vw;
background-size: cover;
background-position: center;
}
.project-card img {
transition: transform 0.3s ease-in-out;
}
@media (min-width: 768px) {
.project-card.expanded {
flex: 2;
}
.project-card.expanded img {
transform: scale(1.1);
}
.project-card.expanded p {
opacity: 1;
}
}
}
</style>
<script>
function initializeProjectCards() {
const projectCards = document.querySelectorAll(".project-card");
const STORAGE_KEY = "lastExpandedCardIndex";
const isMobile = window.innerWidth < 768;
function initializeProjectCards() {
const projectCards = document.querySelectorAll(".project-card");
const STORAGE_KEY = "lastExpandedCardIndex";
// Function to remove expanded class from all cards
function removeExpandedFromAll() {
projectCards.forEach((card) => {
card.classList.remove("expanded");
});
}
// Function to expand a card by index
function expandCard(index: number) {
if (window.innerWidth >= 768) {
removeExpandedFromAll();
projectCards[index]?.classList.add("expanded");
localStorage.setItem(STORAGE_KEY, index.toString());
}
}
// Get the last expanded card index from localStorage
// Prevents bug where the expanded card breaks upon page reload
const lastExpandedIndex = parseInt(
localStorage.getItem(STORAGE_KEY) || "0",
);
// Only apply expanded state on desktop
if (!isMobile) {
expandCard(lastExpandedIndex);
}
// Add hover listeners to each card
projectCards.forEach((card, index) => {
card.addEventListener("mouseenter", () => {
if (!isMobile) {
expandCard(index);
// Function to remove expanded class from all cards
function removeExpandedFromAll() {
projectCards.forEach((card) => {
card.classList.remove("expanded");
});
}
});
});
// Handle window resize
window.addEventListener("resize", () => {
const currentIndex = parseInt(localStorage.getItem(STORAGE_KEY) || "0");
const isMobileNow = window.innerWidth < 768;
if (!isMobileNow) {
expandCard(currentIndex);
} else {
// On mobile, remove expanded class from all cards
removeExpandedFromAll();
}
});
}
initializeProjectCards();
document.addEventListener("astro:page-load", initializeProjectCards);
// Image loading handler
function handleImageLoading() {
const projectImages = document.querySelectorAll(".project-card img");
projectImages.forEach((image) => {
// Ensure the image is fully loaded, even if it's already in cache
if ((image as HTMLImageElement).complete) {
(image as HTMLImageElement).style.opacity = "1";
const skeleton = image.previousElementSibling;
if (skeleton && skeleton.classList.contains("skeleton")) {
(skeleton as HTMLElement).style.display = "none";
// Function to expand a card by index
function expandCard(index: number) {
if (window.innerWidth >= 768) {
removeExpandedFromAll();
projectCards[index]?.classList.add("expanded");
localStorage.setItem(STORAGE_KEY, index.toString());
}
}
} else {
image.addEventListener("load", () => {
(image as HTMLImageElement).style.opacity = "1";
const skeleton = image.previousElementSibling;
if (skeleton && skeleton.classList.contains("skeleton")) {
(skeleton as HTMLElement).style.display = "none";
}
// Get the last expanded card index from localStorage
// Prevents bug where the expanded card breaks upon page reload
const lastExpandedIndex = parseInt(
localStorage.getItem(STORAGE_KEY) || "0"
);
expandCard(lastExpandedIndex);
// Add hover listeners to each card
projectCards.forEach((card, index) => {
card.addEventListener("mouseenter", () => {
expandCard(index);
});
});
}
});
}
// Ensure images are loaded after a short delay
setTimeout(handleImageLoading, 100);
handleImageLoading();
document.addEventListener("astro:page-load", handleImageLoading);
// Handle window resize
window.addEventListener("resize", () => {
const currentIndex = parseInt(
localStorage.getItem(STORAGE_KEY) || "0"
);
expandCard(currentIndex);
});
}
initializeProjectCards();
document.addEventListener("astro:page-load", initializeProjectCards);
</script>

View file

@ -7,53 +7,35 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
---
<div class="text-white flex flex-col items-center md:my-[7%] my-[12%] relative">
<div class="flex items-center md:text-[2.7vw] text-[4.5vw] mb-[4%]">
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
<p>Quarterly Project</p>
</div>
<div class="w-[70vw] aspect-[2.5/1] relative">
<div id="qp-skeleton" class="skeleton absolute inset-0 rounded-full z-0">
<Image
<div class="flex items-center md:text-[2.7vw] text-[4.5vw] mb-[4%]">
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
<p>Quarterly Project</p>
</div>
<Image
src={qp}
alt="qp showcase photo"
class="md:opacity-0 md:w-[70vw] w-[85vw] md:aspect-[2.5/1] aspect-[2.5/1.2] rounded-full relative in-view:animate-fade-down"
class="md:opacity-0 w-[70vw] w-[85vw] md:aspect-[2.5/1] aspect-[2.5/1.2] rounded-full relative in-view:animate-fade-down"
data-inview
/>
/>
<Link
data-inview
href="/quarterly"
className="in-view:animate-fade-left absolute top-[25%] md:right-[15%] right-[10%] w-fit px-[1%] py-[0.4%] bg-white rounded-full text-black flex items-center md:text-[1.3vw] text-[2vw] hover:bg-ieee-yellow duration-300 shadow-md"
>
more details
<IoIosArrowDroprightCircle
className="ml-[0.5vw] text-[1.7vw] mt-[1%]"
/>
</Link>
<div
data-inview class="in-view:animate-fade-right md:w-[45%] w-[70%] text-[1.8vw] md:text-[1vw] font-semibold bg-white/50 backdrop-blur text-black absolute md:-bottom-[6%] -bottom-[15%] md:left-[15%] left-[5%] px-[1.5%] py-[1%] rounded-[1.5vw]"
>
<p class="md:text-[1.4vw] text-[2.2vw] mb-[2%]">Quarterly Project</p>
<p>
Getting started on hardware development or want to make your own
project? Need something to put on your resume? IEEE's Quarterly
Projects aims to provide students with project experience in a span
of 10 weeks. Check out QP page for more detail!
</p>
</div>
</div>
<Link
data-inview
href="/projects/quarterly"
className="in-view:animate-fade-left absolute top-[25%] md:right-[15%] right-[10%] w-fit px-[1%] py-[0.4%] bg-white rounded-full text-black flex items-center md:text-[1.3vw] text-[2vw] hover:bg-ieee-yellow duration-300 shadow-md"
>
more details
<IoIosArrowDroprightCircle className="ml-[0.5vw] text-[1.7vw] mt-[1%]" />
</Link>
<div
data-inview
class="in-view:animate-fade-right md:w-[45%] w-[70%] text-[1.8vw] md:text-[1vw] font-semibold bg-white/50 backdrop-blur text-black absolute md:-bottom-[6%] -bottom-[15%] md:left-[15%] left-[5%] px-[1.5%] py-[1%] rounded-[1.5vw]"
>
<p class="md:text-[1.4vw] text-[2.2vw] mb-[2%]">Quarterly Project</p>
<p>
Getting started on hardware development or want to make your own project?
Need something to put on your resume? IEEE's Quarterly Projects aims to
provide students with project experience in a span of 10 weeks. Check out
QP page for more detail!
</p>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const image = document.getElementById("qp-image");
const skeleton = document.getElementById("qp-skeleton");
if (image) {
image.onload = () => {
if (skeleton) {
skeleton.style.display = "none";
}
};
}
});
</script>

View file

@ -1,34 +1,18 @@
---
const { title, link, image, col } = Astro.props;
const {title, link, image, col} = Astro.props;
import { GoArrowDownRight } from "react-icons/go";
import Link from "next/link";
---
<Link
href={link}
target="_blank"
className={`ease-in-out group relative col-span-${col} text-white`}
>
<img
src={image}
alt="past projects image"
class="md:h-[30vh] h-[15vh] object-cover w-full rounded-[1.5vw] opacity-80 group-hover:opacity-40 duration-300"
/>
<Link href={link} target="_blank" className={`ease-in-out group relative col-span-${col} text-white`}>
<img src={image} alt="past projects image" class="md:h-[30vh] h-[15vh] object-cover w-full rounded-[1.5vw] opacity-80 group-hover:opacity-40 duration-300" />
<div
class="absolute bottom-0 px-[3%] w-full bg-gradient-to-t from-black via-black to-transparent rounded-b-[1.5vw] pb-[1vw] pt-[5%]"
>
<div
class="text-[1.4vw] md:text-[1.1vw] flex w-full px-[2%] justify-between items-end"
>
<p
class="py-[0.3vw] px-[1vw] border-[0.1vw] text-nowrap border-white rounded-full group-hover:text-ieee-yellow group-hover:border-ieee-yellow duration-300 font-light"
>
{title}
</p>
<GoArrowDownRight
className="text-[3vw] leading-none group-hover:text-ieee-yellow duration-300 "
/>
<div class="absolute bottom-0 px-[3%] w-full bg-gradient-to-t from-black via-black to-transparent rounded-b-[1.5vw] pb-[1vw] pt-[5%]">
<div class="text-[1.4vw] md:text-[1.1vw] flex w-full px-[2%] justify-between items-end">
<p class="py-[0.3vw] px-[1vw] border-[0.1vw] text-nowrap border-white rounded-full group-hover:text-ieee-yellow group-hover:border-ieee-yellow duration-300 font-light">
{title}
</p>
<GoArrowDownRight className="text-[3vw] leading-none group-hover:text-ieee-yellow duration-300 "/>
</div>
</div>
</div>
</Link>
</Link>

View file

@ -7,11 +7,12 @@ import Subtitle from "../core/Subtitle.astro";
<Subtitle title="Timeline" />
<div
class="relative flex items-center justify-between w-full md:max-w-[75vw] max-w-[85vw] py-[10%] md:mt-[7%] mt-[10%]"
class="relative flex items-center justify-between w-full md:max-w-[65vw] max-w-[85vw] py-[10%] md:mt-[3%] mt-[10%]"
>
<div
class="absolute top-1/2 w-full h-[0.1vw] bg-gray-200 transform -translate-y-1/2"
/>
>
</div>
{timeline.map((event, index) => (
<div

View file

@ -25,7 +25,7 @@ import { LiaDotCircle } from "react-icons/lia";
<p class="md:text-[1.3vw] text-[2vw] font-semibold">Website</p>
</Link>
<Link href="https://discord.gg/XxfjqZSjca" target="_blank" className="flex flex-col items-center">
<Link href="https://www.instagram.com/ieee.ucsd" target="_blank" className="flex flex-col items-center">
<div class="border-[0.15vw] rounded-full shadow-glow hover:scale-110 duration-300 p-[1vw] md:text-[2.2vw] text-[3.5vw] text-ieee-black/80 border-ieee-blue-100 bg-gradient-radial from-white via-white to-white/40">
<FaDiscord />
</div>

View file

@ -12,7 +12,7 @@ import robocup from "../../images/robocup.webp";
class="md:w-3/5 w-[90%] rounded-[2vw] object-cover aspect-[8/5] opacity-40"
/>
<Link
href="https://docs.google.com/forms/d/e/1FAIpQLSex5VejEiClvgcfhSBQJ9IH5Q008j-HWC5Y9YAa56yIHgGBvw/viewform?usp=sf_link"
href="/"
target="_blank"
className="absolute aspect-[8/5] md:w-3/5 w-[90%] p-[5%] group -bottom-[3%]"
>
@ -24,7 +24,8 @@ import robocup from "../../images/robocup.webp";
</div>
<div class="md:text-[1vw] text-[1.5vw] md:pt-[3%]">
<p>
Join a community of novice to experienced engineers from a wide range of disciplines and backgrounds.
Join a community of beginner to experienced engineers from
different disciplines and backgrounds
</p>
<p>
Fill out this application form (due week 2 of each quarter if

View file

@ -1,25 +1,16 @@
---
import Title from "../core/Title.astro";
import { Image } from "astro:assets";
import model from "../../images/robot.png";
import roboLogo from "../../images/roboLogo.png";
import model from "../../images/model.webp";
---
<div
class="flex items-center md:h-[35vw] h-[45vw] relative w-full pl-[10%] md:mt-[3%] mt-[10%] mb-[10%]"
class="flex items-center md:h-[35vw] h-[45vw] relative w-full pl-[10%] md:mt-[3%] mt-[20%] mb-[15%]"
>
<div
class="flex items-center md:text-[3vw] text-[4.5vw] ml-[10%] md:pt-[5%] pt-[10%] text-white font-semibold"
>
<Image src={roboLogo} alt="Triton RoboCup Team Logo" class=" mr-[1vw] w-[5vw] rounded-full border-[0.2vw] border-ieee-yellow" />
<p>
Robocup
</p>
</div>
<Title title="Robocup" />
<Image
data-inview
src={model}
alt="Robocup robot model"
class="in-view:animate-fade-down absolute md:w-[40%] w-[50%] md:right-[15%] right-0 md:top-0"
class="absolute md:w-[50%] w-[60%] md:right-[10%] right-0 md:top-[5%]"
/>
</div>

View file

@ -26,7 +26,7 @@ const centerIndex = Math.floor(subteams.length); // center in the middle
return (
<div
class="carousel-item absolute transition-all duration-500 md:w-[22vw] w-[25vw]"
class="carousel-item absolute transition-all duration-500 md:w-[20vw] w-[25vw]"
style={{
transform: `translateX(${distance * 12}vw) scale(${
Math.abs(distance) === 0
@ -47,7 +47,7 @@ const centerIndex = Math.floor(subteams.length); // center in the middle
>
<div class="relative">
<div class="relative w-full h-full backdrop-blur-md overflow-hidden rounded-[2vw]">
<div class="px-[8%] flex flex-col justify-center items-center md:w-[22vw] w-[25vw] md:h-[24vw] h-[36vw] bg-gradient-to-b from-ieee-blue-100/25 to-ieee-black backdrop-blur rounded-[2vw] border-white/40 border-[0.1vw]">
<div class="px-[10%] flex flex-col justify-center items-center md:w-[20vw] w-[25vw] md:h-[22vw] h-[28vw] bg-gradient-to-b from-ieee-blue-100/25 to-ieee-black backdrop-blur rounded-[2vw] border-white/40 border-[0.1vw]">
<p class="md:text-[1.5vw] text-[2vw] mb-[10%] font-semibold pt-[10%]">
{subteam.title}
</p>

View file

@ -1,21 +1,21 @@
---
import { LiaDotCircle } from "react-icons/lia";
import Link from "next/link";
const {picture, name, description, link, linktext} = Astro.props;
const {picture, name, description, link} = Astro.props;
---
<div class = "text-white relative my-[3%]" >
<img src = {picture} alt = "signal" class = " w-full object-cover md:aspect-[1300/526] aspect-[2/1] opacity-25">
<img src = {picture} alt = "signal" class = "w-full object-cover md:aspect-[1300/526] aspect-[2/1] opacity-25">
<div class = "w-full flex justify-evenly absolute bottom-[20%] left-[4%]">
<div data-inview class = "in-view:animate-fade-right flex items-center md:text-[2.5vw] text-[4vw]">
<div class = "flex items-center md:text-[2.5vw] text-[4vw]">
<LiaDotCircle className=" mr-[1vw] md:text-[2.6vw]"/>
<p>
Competition
</p>
</div>
<div data-inview class = "md:text-[1.6vw] text-[2.2vw] tracking-wider in-view:animate-flip-up max-w-[60%]">
<div class = "md:text-[1.6vw] text-[2.2vw] tracking-wider">
<p class = "w-[35vw] md:mt-[25vw] mt-[35vw]">
{name}
</p>
@ -23,7 +23,7 @@ const {picture, name, description, link, linktext} = Astro.props;
{description}
</p>
<Link href = {link} target="_blank" className="md:text-[1.2vw] text-[2vw] font-light border-white/70 border-[0.1vw] py-[1.5%] px-[9%] rounded-[0.5vw] hover:text-ieee-yellow hover:border-ieee-yellow duration-300">
{linktext}
Link
</Link>
</div>

View file

@ -17,7 +17,7 @@ import { LiaDotCircle } from "react-icons/lia";
To stay up to date, join discord server
</p>
</div>
<Link href="https://discord.gg/ubr2suwc2f" target="_blank" className="mr-[20%] flex flex-col items-center">
<Link href="https://www.facebook.com/ieeeucsd" target="_blank" className="mr-[20%] flex flex-col items-center">
<div class="border-[0.15vw] rounded-full shadow-glow hover:scale-110 duration-300 p-[1vw] text-[2.2vw] text-ieee-black/80 border-ieee-blue-100 bg-gradient-radial from-white via-white to-white/40">
<FaDiscord />
</div>

View file

@ -1,142 +0,0 @@
sections:
# Base Menu (accessible to all except sponsors)
profile:
title: "Dashboard"
icon: "heroicons:home"
role: "none"
component: "ProfileSection"
class: "text-primary hover:text-primary-focus"
events:
title: "Events"
icon: "heroicons:calendar"
role: "none"
component: "EventsSection"
class: "text-secondary hover:text-secondary-focus"
leaderboard:
title: "Leaderboard"
icon: "heroicons:trophy"
role: "none"
component: "LeaderboardSection"
class: "text-warning hover:text-warning-focus"
reimbursement:
title: "Reimbursement"
icon: "heroicons:credit-card"
role: "none"
component: "ReimbursementSection"
class: "text-accent hover:text-accent-focus"
# Officer Menu
eventManagement:
title: "Event Management"
icon: "heroicons:cog-6-tooth"
role: "general"
component: "Officer_EventManagement"
class: "text-info hover:text-info-focus"
officerEmailManagement:
title: "IEEE Email Management"
icon: "heroicons:envelope"
role: "general"
component: "Officer_EmailManagement"
class: "text-info hover:text-info-focus"
reimbursementManagement:
title: "Reimbursement Management"
icon: "heroicons:credit-card"
role: "executive"
component: "Officer_ReimbursementManagement"
class: "text-info hover:text-info-focus"
eventRequestManagement:
title: "Event Request Management"
icon: "heroicons:document-text"
role: "executive"
component: "Officer_EventRequestManagement"
class: "text-info hover:text-info-focus"
officerManagement:
title: "Officer Management"
icon: "heroicons:user-group"
role: "executive"
component: "OfficerManagement"
class: "text-info hover:text-info-focus"
eventRequestForm:
title: "Event Request Form"
icon: "heroicons:document-text"
role: "general"
component: "Officer_EventRequestForm"
class: "text-info hover:text-info-focus"
# Sponsor Menu
sponsorAnalytics:
title: "Event Analytics"
icon: "heroicons:chart-bar"
role: "sponsor"
component: "SponsorAnalyticsSection"
class: "text-primary hover:text-primary-focus"
resumeDatabase:
title: "Resume Database"
icon: "heroicons:document-text"
role: "sponsor"
component: "ResumeDatabase"
class: "text-secondary hover:text-secondary-focus"
# Administrator Menu
adminDashboard:
title: "Admin Dashboard"
icon: "heroicons:shield-check"
role: "administrator"
component: "AdminDashboard"
class: "text-error hover:text-error-focus"
# Settings (accessible to all except sponsors)
settings:
title: "Settings"
icon: "heroicons:cog-6-tooth"
role: "none"
component: "SettingsSection"
class: "text-neutral hover:text-neutral-focus"
logout:
title: "Logout"
icon: "heroicons:arrow-left-on-rectangle"
role: "none"
class: "text-error hover:text-error-focus"
# Menu Categories
categories:
main:
title: "Main Menu"
sections: ["profile", "events", "leaderboard", "reimbursement"]
role: "none"
officer:
title: "Officer Menu"
sections: ["eventManagement", "officerEmailManagement", "eventRequestForm"]
role: "general"
executive:
title: "Executive Menu"
sections:
["reimbursementManagement", "eventRequestManagement", "officerManagement"]
role: "executive"
admin:
title: "Admin Menu"
sections: ["adminDashboard"]
role: "administrator"
sponsor:
title: "Sponsor Portal"
sections: ["sponsorAnalytics", "resumeDatabase"]
role: "sponsor"
account:
title: "Account"
sections: ["settings"]
role: "none"

View file

@ -1,5 +0,0 @@
api:
baseUrl: https://pocketbase.ieeeucsd.org
oauth2:
redirectPath: /oauth2-redirect
providerName: oidc

View file

@ -1,14 +0,0 @@
status:
- "submitted"
- "under_review"
- "approved"
- "rejected"
- "paid"
- "in_progress"
department:
- "internal"
- "external"
- "projects"
- "events"
- "other"

Some files were not shown because too many files have changed in this diff Show more