Compare commits
No commits in common. "main" and "auth" have entirely different histories.
166 changed files with 6937 additions and 28760 deletions
|
@ -1 +0,0 @@
|
||||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -5,8 +5,6 @@ dist/
|
||||||
.astro/
|
.astro/
|
||||||
.cursor
|
.cursor
|
||||||
|
|
||||||
final_review_gate.py
|
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
|
55
Dockerfile
55
Dockerfile
|
@ -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"]
|
|
|
@ -15,29 +15,9 @@ import icon from "astro-icon";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: "server",
|
|
||||||
integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()],
|
integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()],
|
||||||
|
|
||||||
adapter: node({
|
adapter: node({
|
||||||
mode: "standalone",
|
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,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
|
|
@ -1,4 +1,4 @@
|
||||||
[phases.setup]
|
[phases.setup]
|
||||||
nixPkgs = ["nodejs_20", "bun"]
|
nixPkgs = ["nodejs_18", "bun"]
|
||||||
aptPkgs = ["curl", "wget"]
|
aptPkgs = ["curl", "wget"]
|
||||||
|
|
||||||
|
|
32
package.json
32
package.json
|
@ -2,9 +2,6 @@
|
||||||
"name": "ieeeucsd-dev",
|
"name": "ieeeucsd-dev",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"engines": {
|
|
||||||
"node": ">=18.20.8"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"start": "node ./dist/server/entry.mjs",
|
"start": "node ./dist/server/entry.mjs",
|
||||||
|
@ -13,27 +10,23 @@
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.4",
|
"@astrojs/mdx": "4.0.3",
|
||||||
"@astrojs/mdx": "^4.2.3",
|
"@astrojs/node": "^9.0.0",
|
||||||
"@astrojs/node": "^9.1.3",
|
"@astrojs/react": "^4.2.0",
|
||||||
"@astrojs/react": "^4.2.3",
|
"@astrojs/tailwind": "5.1.4",
|
||||||
"@astrojs/tailwind": "^6.0.2",
|
|
||||||
"@heroui/react": "^2.7.5",
|
|
||||||
"@iconify-json/heroicons": "^1.2.2",
|
"@iconify-json/heroicons": "^1.2.2",
|
||||||
"@iconify-json/mdi": "^1.2.3",
|
|
||||||
"@iconify/react": "^5.2.0",
|
"@iconify/react": "^5.2.0",
|
||||||
"@types/highlight.js": "^10.1.0",
|
"@types/highlight.js": "^10.1.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/puppeteer": "^7.0.4",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@types/react-dom": "^19.1.1",
|
"astro": "5.1.1",
|
||||||
"astro": "^5.5.6",
|
|
||||||
"astro-expressive-code": "^0.40.2",
|
"astro-expressive-code": "^0.40.2",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
"framer-motion": "^12.6.2",
|
"framer-motion": "^12.4.4",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
@ -42,16 +35,13 @@
|
||||||
"next": "^15.1.2",
|
"next": "^15.1.2",
|
||||||
"pocketbase": "^0.25.1",
|
"pocketbase": "^0.25.1",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"puppeteer": "^24.10.1",
|
"react": "^19.0.0",
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"rehype-expressive-code": "^0.40.2",
|
"rehype-expressive-code": "^0.40.2",
|
||||||
"resend": "^4.5.1",
|
"tailwindcss": "^3.4.16"
|
||||||
"tailwindcss": "^3.4.16",
|
|
||||||
"typescript": "^5.8.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
|
|
|
@ -41,7 +41,7 @@ const { name, position, picture, email } = Astro.props;
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
data-inview
|
data-inview
|
||||||
class="opacity-0 in-view:animate-fade-right md:text-[1.8vw] text-[3.5vw] font-light md:leading-[2.5vw] leading-[5vw] md:w-[10vw]"
|
class="in-view:animate-fade-right md:text-[2vw] text-[3.5vw] font-light md:leading-[2.5vw] leading-[5vw] w-[10vw]"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -4,16 +4,14 @@ const { title, text } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="flex flex-col items-center text-white my-[10%]">
|
<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%]">
|
<div class="flex items-center text-[2.5vw] mb-[3%]">
|
||||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
|
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
|
||||||
<p
|
<p class="text-transparent bg-clip-text bg-gradient-to-b from-white via-white to-ieee-black">
|
||||||
class="text-transparent bg-clip-text bg-gradient-to-b from-white via-white to-ieee-black"
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="w-[70%] md:text-[1.4vw] text-[2vw] font-light">
|
<p class="w-[70%] text-[1.4vw] font-light ">
|
||||||
{text}
|
{text}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
|
@ -1,22 +1,16 @@
|
||||||
<script>
|
<script>
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver((entries) => {
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
entry.target.classList.add("in-view");
|
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 {
|
} else {
|
||||||
entry.target.classList.remove("in-view");
|
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 });
|
||||||
{ threshold: 0.2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
document
|
document.querySelectorAll("[data-inview]").forEach((el) => observer.observe(el));
|
||||||
.querySelectorAll("[data-inview]")
|
|
||||||
.forEach((el) => observer.observe(el));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -6,7 +6,7 @@ import pages from "../../data/pages.json";
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div
|
<div
|
||||||
class="flex justify-between items-center bg-ieee-black my-[1%] mx-[2.5%] py-[0.5%] px-[1%] rounded-full md:border-[0.1vw]"
|
class="flex justify-between items-center bg-black my-[1%] mx-[2.5%] py-[0.5%] px-[1%] md:rounded-full md:border-[0.1vw]"
|
||||||
>
|
>
|
||||||
<a href="/" class="hover:opacity-60 duration-300">
|
<a href="/" class="hover:opacity-60 duration-300">
|
||||||
<Image
|
<Image
|
||||||
|
@ -24,29 +24,7 @@ import pages from "../../data/pages.json";
|
||||||
<!-- Desktop Navigation -->
|
<!-- Desktop Navigation -->
|
||||||
<div class="hidden md:flex md:w-[55%] md:justify-between">
|
<div class="hidden md:flex md:w-[55%] md:justify-between">
|
||||||
{
|
{
|
||||||
pages.map((page) =>
|
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
|
<a
|
||||||
href={page.path}
|
href={page.path}
|
||||||
class={`uppercase rounded-full duration-300 px-[1.5vw] py-[0.2vw] text-[1.2vw] text-nowrap
|
class={`uppercase rounded-full duration-300 px-[1.5vw] py-[0.2vw] text-[1.2vw] text-nowrap
|
||||||
|
@ -58,8 +36,7 @@ import pages from "../../data/pages.json";
|
||||||
>
|
>
|
||||||
{page.name}
|
{page.name}
|
||||||
</a>
|
</a>
|
||||||
),
|
))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -101,66 +78,25 @@ import pages from "../../data/pages.json";
|
||||||
<!-- Mobile Menu -->
|
<!-- Mobile Menu -->
|
||||||
<div
|
<div
|
||||||
id="mobile-menu"
|
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
|
<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) =>
|
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
|
<a
|
||||||
href={page.path}
|
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
|
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"
|
page.name === "Online Store"
|
||||||
? "bg-[#f3c135] text-black border-[#f3c135] hover:bg-[#dba923] hover:border-[#dba923]"
|
? "bg-[#f3c135] text-black border-[#f3c135] hover:bg-[#dba923] hover:border-[#dba923]"
|
||||||
: "text-white hover:text-gray-300 border-white border-2"
|
: "text-white hover:text-gray-300 border-white border-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{page.name}
|
{page.name}
|
||||||
</a>
|
</a>
|
||||||
),
|
))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -170,111 +106,6 @@ import pages from "../../data/pages.json";
|
||||||
#mobile-menu.show {
|
#mobile-menu.show {
|
||||||
@apply translate-x-0;
|
@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>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -282,7 +113,6 @@ import pages from "../../data/pages.json";
|
||||||
const mobileMenu = document.getElementById("mobile-menu");
|
const mobileMenu = document.getElementById("mobile-menu");
|
||||||
const menuIcon = document.querySelector(".menu-icon");
|
const menuIcon = document.querySelector(".menu-icon");
|
||||||
const closeIcon = document.querySelector(".close-icon");
|
const closeIcon = document.querySelector(".close-icon");
|
||||||
const dropdownToggles = document.querySelectorAll(".mobile-dropdown-toggle");
|
|
||||||
|
|
||||||
function toggleMenu(show: boolean) {
|
function toggleMenu(show: boolean) {
|
||||||
if (show) {
|
if (show) {
|
||||||
|
@ -300,23 +130,9 @@ import pages from "../../data/pages.json";
|
||||||
closeIcon?.classList.add("hidden");
|
closeIcon?.classList.add("hidden");
|
||||||
document.body.style.overflow = "";
|
document.body.style.overflow = "";
|
||||||
|
|
||||||
// First wait for the navbar to slide out
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
mobileMenu?.classList.add("hidden");
|
mobileMenu?.classList.add("hidden");
|
||||||
|
}, 100);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -325,57 +141,7 @@ import pages from "../../data/pages.json";
|
||||||
toggleMenu(isMenuHidden);
|
toggleMenu(isMenuHidden);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle dropdown toggles
|
// Close menu when clicking outside
|
||||||
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
|
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
if (
|
if (
|
||||||
!mobileMenu?.contains(e.target as Node) &&
|
!mobileMenu?.contains(e.target as Node) &&
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
import { LiaDotCircle } from "react-icons/lia";
|
import { LiaDotCircle } from "react-icons/lia";
|
||||||
import { Image } from "astro:assets";
|
import { Image } from "astro:assets";
|
||||||
import jellyfish from "../../images/jellyfish.png";
|
import jellyfish from "../../images/jellyfish.webp";
|
||||||
import { FaDiscord } from "react-icons/fa";
|
import { FaDiscord } from "react-icons/fa";
|
||||||
import { RiInstagramFill } from "react-icons/ri";
|
import { RiInstagramFill } from "react-icons/ri";
|
||||||
import { MdEmail } from "react-icons/md";
|
import { MdEmail } from "react-icons/md";
|
||||||
|
@ -72,8 +72,8 @@ import Link from "next/link";
|
||||||
<Image
|
<Image
|
||||||
data-inview
|
data-inview
|
||||||
src={jellyfish}
|
src={jellyfish}
|
||||||
alt="Jellyfish mascot"
|
alt="cat placeholder"
|
||||||
class="absolute bottom-[2vw] md:w-[25vw] w-[35vw] right-[4vw] animate-wiggle animate-infinite"
|
class="absolute bottom-0 md:w-[25vw] w-[35vw] right-[4vw] animate-wiggle animate-infinite"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,7 +3,7 @@ const { title } = Astro.props;
|
||||||
import { LiaDotCircle } from "react-icons/lia";
|
import { LiaDotCircle } from "react-icons/lia";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="flex items-center md:text-[2.5vw] text-[4vw] mb-[5%]">
|
<div class="flex items-center text-[2.5vw] mb-[5%]">
|
||||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
|
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
|
||||||
<p>
|
<p>
|
||||||
{title}
|
{title}
|
||||||
|
|
|
@ -28,7 +28,8 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
<div
|
<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"
|
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"
|
<span
|
||||||
|
class="text-base-content font-medium text-sm sm:text-base"
|
||||||
>Coming Soon</span
|
>Coming Soon</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
@ -48,8 +49,12 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<option disabled selected>Pick an event</option>
|
<option disabled selected>Pick an event</option>
|
||||||
<option>Technical Workshop - Web Development</option>
|
<option
|
||||||
<option>Professional Development Workshop</option>
|
>Technical Workshop - Web Development</option
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
>Professional Development Workshop</option
|
||||||
|
>
|
||||||
<option>Social Event - Game Night</option>
|
<option>Social Event - Game Night</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
|
@ -89,8 +94,9 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
class="btn btn-circle btn-ghost btn-sm sm:btn-md"
|
class="btn btn-circle btn-ghost btn-sm sm:btn-md"
|
||||||
onclick="window.closeEventDetailsModal()"
|
onclick="window.closeEventDetailsModal()"
|
||||||
>
|
>
|
||||||
<iconify-icon icon="heroicons:x-mark" className="h-4 w-4 sm:h-6 sm:w-6"
|
<iconify-icon
|
||||||
></iconify-icon>
|
icon="heroicons:x-mark"
|
||||||
|
className="h-4 w-4 sm:h-6 sm:w-6"></iconify-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -124,7 +130,8 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
id="previewLoadingSpinner"
|
id="previewLoadingSpinner"
|
||||||
class="absolute inset-0 flex items-center justify-center bg-base-200 bg-opacity-50 hidden"
|
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>
|
<span class="loading loading-spinner loading-md sm:loading-lg"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="previewContent" class="w-full">
|
<div id="previewContent" class="w-full">
|
||||||
<FilePreview client:load isModal={true} />
|
<FilePreview client:load isModal={true} />
|
||||||
|
@ -184,14 +191,14 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
|
|
||||||
// Universal file preview function for events section
|
// Universal file preview function for events section
|
||||||
window.previewFileEvents = function (url: string, filename: string) {
|
window.previewFileEvents = function (url: string, filename: string) {
|
||||||
// console.log("previewFileEvents called with:", { url, filename });
|
console.log("previewFileEvents called with:", { url, filename });
|
||||||
// console.log("URL type:", typeof url, "URL length:", url?.length || 0);
|
console.log("URL type:", typeof url, "URL length:", url?.length || 0);
|
||||||
// console.log(
|
console.log(
|
||||||
// "Filename type:",
|
"Filename type:",
|
||||||
// typeof filename,
|
typeof filename,
|
||||||
// "Filename length:",
|
"Filename length:",
|
||||||
// filename?.length || 0
|
filename?.length || 0
|
||||||
// );
|
);
|
||||||
|
|
||||||
// Validate inputs
|
// Validate inputs
|
||||||
if (!url || typeof url !== "string") {
|
if (!url || typeof url !== "string") {
|
||||||
|
@ -203,7 +210,7 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
if (!filename || typeof filename !== "string") {
|
if (!filename || typeof filename !== "string") {
|
||||||
console.error(
|
console.error(
|
||||||
"Invalid filename provided to previewFileEvents:",
|
"Invalid filename provided to previewFileEvents:",
|
||||||
filename,
|
filename
|
||||||
);
|
);
|
||||||
toast.error("Cannot preview file: Invalid filename");
|
toast.error("Cannot preview file: Invalid filename");
|
||||||
return;
|
return;
|
||||||
|
@ -211,24 +218,27 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
|
|
||||||
// Ensure URL is properly formatted
|
// Ensure URL is properly formatted
|
||||||
if (!url.startsWith("http")) {
|
if (!url.startsWith("http")) {
|
||||||
console.warn("URL doesn't start with http, attempting to fix:", url);
|
console.warn(
|
||||||
|
"URL doesn't start with http, attempting to fix:",
|
||||||
|
url
|
||||||
|
);
|
||||||
if (url.startsWith("/")) {
|
if (url.startsWith("/")) {
|
||||||
url = `https://pocketbase.ieeeucsd.org${url}`;
|
url = `https://pocketbase.ieeeucsd.org${url}`;
|
||||||
} else {
|
} else {
|
||||||
url = `https://pocketbase.ieeeucsd.org/${url}`;
|
url = `https://pocketbase.ieeeucsd.org/${url}`;
|
||||||
}
|
}
|
||||||
// console.log("Fixed URL:", url);
|
console.log("Fixed URL:", url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById(
|
||||||
"filePreviewModal",
|
"filePreviewModal"
|
||||||
) as HTMLDialogElement;
|
) as HTMLDialogElement;
|
||||||
const previewFileName = document.getElementById("previewFileName");
|
const previewFileName = document.getElementById("previewFileName");
|
||||||
const previewContent = document.getElementById("previewContent");
|
const previewContent = document.getElementById("previewContent");
|
||||||
const loadingSpinner = document.getElementById("previewLoadingSpinner");
|
const loadingSpinner = document.getElementById("previewLoadingSpinner");
|
||||||
|
|
||||||
if (modal && previewFileName && previewContent) {
|
if (modal && previewFileName && previewContent) {
|
||||||
// console.log("Found all required elements");
|
console.log("Found all required elements");
|
||||||
|
|
||||||
// Show loading spinner
|
// Show loading spinner
|
||||||
if (loadingSpinner) {
|
if (loadingSpinner) {
|
||||||
|
@ -244,11 +254,11 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
// Test the URL with a fetch before dispatching the event
|
// Test the URL with a fetch before dispatching the event
|
||||||
fetch(url, { method: "HEAD" })
|
fetch(url, { method: "HEAD" })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
// console.log(
|
console.log(
|
||||||
// "URL test response:",
|
"URL test response:",
|
||||||
// response.status,
|
response.status,
|
||||||
// response.ok
|
response.ok
|
||||||
// );
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn("URL might not be accessible:", url);
|
console.warn("URL might not be accessible:", url);
|
||||||
toast(
|
toast(
|
||||||
|
@ -260,7 +270,7 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
background: "#FFC107",
|
background: "#FFC107",
|
||||||
color: "#000",
|
color: "#000",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -269,14 +279,14 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// Dispatch state change event to update the FilePreview component
|
// Dispatch state change event to update the FilePreview component
|
||||||
// console.log(
|
console.log(
|
||||||
// "Dispatching filePreviewStateChange event with:",
|
"Dispatching filePreviewStateChange event with:",
|
||||||
// { url, filename }
|
{ url, filename }
|
||||||
// );
|
);
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("filePreviewStateChange", {
|
new CustomEvent("filePreviewStateChange", {
|
||||||
detail: { url, filename },
|
detail: { url, filename },
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -294,9 +304,9 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
|
|
||||||
// Close file preview for events section
|
// Close file preview for events section
|
||||||
window.closeFilePreviewEvents = function () {
|
window.closeFilePreviewEvents = function () {
|
||||||
// console.log("closeFilePreviewEvents called");
|
console.log("closeFilePreviewEvents called");
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById(
|
||||||
"filePreviewModal",
|
"filePreviewModal"
|
||||||
) as HTMLDialogElement;
|
) as HTMLDialogElement;
|
||||||
const previewFileName = document.getElementById("previewFileName");
|
const previewFileName = document.getElementById("previewFileName");
|
||||||
const previewContent = document.getElementById("previewContent");
|
const previewContent = document.getElementById("previewContent");
|
||||||
|
@ -307,14 +317,14 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modal && previewFileName && previewContent) {
|
if (modal && previewFileName && previewContent) {
|
||||||
// console.log("Resetting preview and closing modal");
|
console.log("Resetting preview and closing modal");
|
||||||
|
|
||||||
// First reset the preview state by dispatching an event with empty values
|
// First reset the preview state by dispatching an event with empty values
|
||||||
// This ensures the FilePreview component clears its internal state
|
// This ensures the FilePreview component clears its internal state
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("filePreviewStateChange", {
|
new CustomEvent("filePreviewStateChange", {
|
||||||
detail: { url: "", filename: "" },
|
detail: { url: "", filename: "" },
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset the UI
|
// Reset the UI
|
||||||
|
@ -323,7 +333,7 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
// Close the modal
|
// Close the modal
|
||||||
modal.close();
|
modal.close();
|
||||||
|
|
||||||
// console.log("File preview modal closed and state reset");
|
console.log("File preview modal closed and state reset");
|
||||||
} else {
|
} else {
|
||||||
console.error("Could not find elements to close file preview");
|
console.error("Could not find elements to close file preview");
|
||||||
}
|
}
|
||||||
|
@ -334,7 +344,7 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
url: string;
|
url: string;
|
||||||
name: string;
|
name: string;
|
||||||
}) {
|
}) {
|
||||||
// console.log("showFilePreviewEvents called with:", file);
|
console.log("showFilePreviewEvents called with:", file);
|
||||||
if (!file || !file.url || !file.name) {
|
if (!file || !file.url || !file.name) {
|
||||||
console.error("Invalid file data:", file);
|
console.error("Invalid file data:", file);
|
||||||
toast.error("Could not preview file: missing file information");
|
toast.error("Could not preview file: missing file information");
|
||||||
|
@ -346,10 +356,10 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
// Update the openDetailsModal function to use the events-specific preview
|
// Update the openDetailsModal function to use the events-specific preview
|
||||||
window.openDetailsModal = function (event: any) {
|
window.openDetailsModal = function (event: any) {
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById(
|
||||||
"eventDetailsModal",
|
"eventDetailsModal"
|
||||||
) as HTMLDialogElement;
|
) as HTMLDialogElement;
|
||||||
const filesContent = document.getElementById(
|
const filesContent = document.getElementById(
|
||||||
"filesContent",
|
"filesContent"
|
||||||
) as HTMLDivElement;
|
) as HTMLDivElement;
|
||||||
|
|
||||||
// Check if event has ended
|
// Check if event has ended
|
||||||
|
@ -373,7 +383,11 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
if (filesContent) filesContent.classList.remove("hidden");
|
if (filesContent) filesContent.classList.remove("hidden");
|
||||||
|
|
||||||
// Populate files content
|
// Populate files content
|
||||||
if (event.files && Array.isArray(event.files) && event.files.length > 0) {
|
if (
|
||||||
|
event.files &&
|
||||||
|
Array.isArray(event.files) &&
|
||||||
|
event.files.length > 0
|
||||||
|
) {
|
||||||
const baseUrl = "https://pocketbase.ieeeucsd.org";
|
const baseUrl = "https://pocketbase.ieeeucsd.org";
|
||||||
const collectionId = "events";
|
const collectionId = "events";
|
||||||
const recordId = event.id;
|
const recordId = event.id;
|
||||||
|
@ -432,7 +446,7 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
// Add downloadAllFiles function
|
// Add downloadAllFiles function
|
||||||
window.downloadAllFiles = async function () {
|
window.downloadAllFiles = async function () {
|
||||||
const downloadBtn = document.getElementById(
|
const downloadBtn = document.getElementById(
|
||||||
"downloadAllBtn",
|
"downloadAllBtn"
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
if (!downloadBtn) return;
|
if (!downloadBtn) return;
|
||||||
const originalBtnContent = downloadBtn.innerHTML;
|
const originalBtnContent = downloadBtn.innerHTML;
|
||||||
|
@ -487,7 +501,7 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to download files:", error);
|
console.error("Failed to download files:", error);
|
||||||
toast.error(
|
toast.error(
|
||||||
error?.message || "Failed to download files. Please try again.",
|
error?.message || "Failed to download files. Please try again."
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
// Reset button state
|
// Reset button state
|
||||||
|
@ -499,7 +513,7 @@ import EventLoad from "./EventsSection/EventLoad";
|
||||||
// Close event details modal
|
// Close event details modal
|
||||||
window.closeEventDetailsModal = function () {
|
window.closeEventDetailsModal = function () {
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById(
|
||||||
"eventDetailsModal",
|
"eventDetailsModal"
|
||||||
) as HTMLDialogElement;
|
) as HTMLDialogElement;
|
||||||
const filesContent = document.getElementById("filesContent");
|
const filesContent = document.getElementById("filesContent");
|
||||||
|
|
||||||
|
|
|
@ -7,12 +7,11 @@ import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import type { Event, EventAttendee, LimitedUser } from "../../../schemas/pocketbase";
|
import type { Event, EventAttendee } from "../../../schemas/pocketbase";
|
||||||
|
|
||||||
// Extended Event interface with additional properties needed for this component
|
// Extended Event interface with additional properties needed for this component
|
||||||
interface ExtendedEvent extends Event {
|
interface ExtendedEvent extends Event {
|
||||||
description?: string; // This component uses 'description' but schema has 'event_description'
|
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.
|
// Note: Date conversion is now handled automatically by the Get and Update classes.
|
||||||
|
@ -157,12 +156,7 @@ const EventCheckIn = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store event code in local storage for offline check-in
|
// Store event code in local storage for offline check-in
|
||||||
try {
|
|
||||||
await dataSync.storeEventCode(eventCode);
|
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
|
// Show event details toast only for non-food events
|
||||||
// For food events, we'll show the toast after food selection
|
// For food events, we'll show the toast after food selection
|
||||||
|
@ -186,7 +180,7 @@ const EventCheckIn = () => {
|
||||||
<div>
|
<div>
|
||||||
<strong>Event with food found!</strong>
|
<strong>Event with food found!</strong>
|
||||||
<p className="text-sm mt-1">{event.event_name}</p>
|
<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>
|
<p className="text-xs mt-1">Please select your food preference</p>
|
||||||
</div>,
|
</div>,
|
||||||
{ duration: 5000 }
|
{ duration: 5000 }
|
||||||
);
|
);
|
||||||
|
@ -255,6 +249,8 @@ const EventCheckIn = () => {
|
||||||
// Create the attendee record in PocketBase
|
// Create the attendee record in PocketBase
|
||||||
const newAttendee = await update.create(Collections.EVENT_ATTENDEES, attendeeData);
|
const newAttendee = await update.create(Collections.EVENT_ATTENDEES, attendeeData);
|
||||||
|
|
||||||
|
console.log("Successfully created attendance record");
|
||||||
|
|
||||||
// Update user's total points
|
// Update user's total points
|
||||||
// First, get all the user's attendance records to calculate total points
|
// First, get all the user's attendance records to calculate total points
|
||||||
const userAttendance = await get.getList<EventAttendee>(
|
const userAttendance = await get.getList<EventAttendee>(
|
||||||
|
@ -270,61 +266,23 @@ const EventCheckIn = () => {
|
||||||
totalPoints += attendee.points_earned || 0;
|
totalPoints += attendee.points_earned || 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the LimitedUser record with the new points total
|
// Log the points update
|
||||||
try {
|
console.log(`Updating user points to: ${totalPoints}`);
|
||||||
// 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
|
// Update the user record with the new total points
|
||||||
if (limitedUserExists) {
|
await update.updateFields(Collections.USERS, userId, {
|
||||||
await update.updateFields(Collections.LIMITED_USERS, userId, {
|
points: totalPoints
|
||||||
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
|
// Ensure local data is in sync with backend
|
||||||
// First sync the new attendance record
|
// First sync the new attendance record
|
||||||
try {
|
|
||||||
await dataSync.syncCollection(Collections.EVENT_ATTENDEES);
|
await dataSync.syncCollection(Collections.EVENT_ATTENDEES);
|
||||||
|
|
||||||
// Then sync the updated user and LimitedUser data
|
// Then sync the updated user data to ensure points are correctly reflected locally
|
||||||
await dataSync.syncCollection(Collections.USERS);
|
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
|
// Clear event code from local storage
|
||||||
try {
|
|
||||||
await dataSync.clearEventCode();
|
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
|
// Log successful check-in
|
||||||
await logger.send(
|
await logger.send(
|
||||||
|
@ -472,12 +430,12 @@ const EventCheckIn = () => {
|
||||||
<div className="badge badge-primary mb-4">
|
<div className="badge badge-primary mb-4">
|
||||||
{currentCheckInEvent?.points_to_reward} points
|
{currentCheckInEvent?.points_to_reward} points
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-4">This event has food! Please let us know what you ate (or will eat):</p>
|
<p className="mb-4">This event has food! Please let us know what you'd like to eat:</p>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter the food you will or are eating"
|
placeholder="Enter your food preference"
|
||||||
className="input input-bordered w-full"
|
className="input input-bordered w-full"
|
||||||
value={foodInput}
|
value={foodInput}
|
||||||
onChange={(e) => setFoodInput(e.target.value)}
|
onChange={(e) => setFoodInput(e.target.value)}
|
||||||
|
|
|
@ -10,7 +10,6 @@ import type { Event, AttendeeEntry, EventAttendee } from "../../../schemas/pocke
|
||||||
// Extended Event interface with additional properties needed for this component
|
// Extended Event interface with additional properties needed for this component
|
||||||
interface ExtendedEvent extends Event {
|
interface ExtendedEvent extends Event {
|
||||||
description?: string; // This component uses 'description' but schema has 'event_description'
|
description?: string; // This component uses 'description' but schema has 'event_description'
|
||||||
event_type: string; // Add event_type field from schema
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -63,19 +62,6 @@ const EventLoad = () => {
|
||||||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
|
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
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
|
// Function to clear the events cache and force a fresh sync
|
||||||
const refreshEvents = async () => {
|
const refreshEvents = async () => {
|
||||||
|
@ -88,9 +74,9 @@ const EventLoad = () => {
|
||||||
|
|
||||||
// Clear events table
|
// Clear events table
|
||||||
if (db && db.events) {
|
if (db && db.events) {
|
||||||
// console.log("Clearing events cache...");
|
console.log("Clearing events cache...");
|
||||||
await db.events.clear();
|
await db.events.clear();
|
||||||
// console.log("Events cache cleared successfully");
|
console.log("Events cache cleared successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset sync timestamp for events by updating it to 0
|
// Reset sync timestamp for events by updating it to 0
|
||||||
|
@ -98,7 +84,7 @@ const EventLoad = () => {
|
||||||
const currentInfo = await dexieService.getLastSync(Collections.EVENTS);
|
const currentInfo = await dexieService.getLastSync(Collections.EVENTS);
|
||||||
// Then update it with a timestamp of 0 (forcing a fresh sync)
|
// Then update it with a timestamp of 0 (forcing a fresh sync)
|
||||||
await dexieService.updateLastSync(Collections.EVENTS);
|
await dexieService.updateLastSync(Collections.EVENTS);
|
||||||
// console.log("Events sync timestamp reset");
|
console.log("Events sync timestamp reset");
|
||||||
|
|
||||||
// Reload events
|
// Reload events
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
@ -160,7 +146,7 @@ const EventLoad = () => {
|
||||||
try {
|
try {
|
||||||
const get = Get.getInstance();
|
const get = Get.getInstance();
|
||||||
const attendees = await get.getList<EventAttendee>(
|
const attendees = await get.getList<EventAttendee>(
|
||||||
Collections.EVENT_ATTENDEES,
|
"event_attendees",
|
||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
`user="${currentUser.id}" && event="${event.id}"`
|
`user="${currentUser.id}" && event="${event.id}"`
|
||||||
|
@ -168,38 +154,12 @@ const EventLoad = () => {
|
||||||
|
|
||||||
const hasAttendedEvent = attendees.totalItems > 0;
|
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
|
// Update the card UI based on attendance status
|
||||||
const cardElement = document.getElementById(`event-card-${event.id}`);
|
const cardElement = document.getElementById(`event-card-${event.id}`);
|
||||||
if (cardElement) {
|
if (cardElement && hasAttendedEvent) {
|
||||||
const attendedBadge = document.getElementById(`attendance-badge-${event.id}`);
|
const attendedBadge = cardElement.querySelector('.attended-badge');
|
||||||
if (attendedBadge && hasAttendedEvent) {
|
if (attendedBadge) {
|
||||||
attendedBadge.classList.remove('badge-ghost');
|
(attendedBadge as HTMLElement).style.display = 'flex';
|
||||||
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) {
|
} catch (error) {
|
||||||
|
@ -216,87 +176,48 @@ const EventLoad = () => {
|
||||||
const endDate = new Date(event.end_date);
|
const endDate = new Date(event.end_date);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const isPastEvent = endDate < now;
|
const isPastEvent = endDate < now;
|
||||||
const isExpanded = expandedDescriptions.has(event.id);
|
|
||||||
const description = event.event_description || "No description available";
|
|
||||||
|
|
||||||
return (
|
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 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">
|
<div className="card-body p-3 sm:p-4">
|
||||||
{/* Event Header */}
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="card-title text-base sm:text-lg font-semibold line-clamp-2">{event.event_name}</h3>
|
<div className="flex-1">
|
||||||
<div className="badge badge-primary badge-sm flex-shrink-0">{event.points_to_reward} pts</div>
|
<h3 className="card-title text-base sm:text-lg font-semibold mb-1 line-clamp-2">{event.event_name}</h3>
|
||||||
</div>
|
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-base-content/70">
|
||||||
|
<div className="badge badge-primary badge-sm">{event.points_to_reward} pts</div>
|
||||||
{/* Event Description */}
|
<div className="text-xs sm:text-sm opacity-75">
|
||||||
<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", {
|
{startDate.toLocaleDateString("en-US", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
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", {
|
{startDate.toLocaleTimeString("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})}
|
})}
|
||||||
</span>
|
|
||||||
</div>
|
</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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
<div className="text-xs sm:text-sm text-base-content/70 my-2 line-clamp-2">
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-auto">
|
{event.event_description || "No description available"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-auto pt-2">
|
||||||
{event.files && event.files.length > 0 && (
|
{event.files && event.files.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => window.openDetailsModal(event as ExtendedEvent)}
|
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"
|
className="btn btn-ghost btn-sm text-xs sm:text-sm gap-1 h-8 min-h-0 px-2"
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" />
|
<Icon icon="heroicons:document-duplicate" className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
Files ({event.files.length})
|
Files ({event.files.length})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isPastEvent && (
|
{isPastEvent && (
|
||||||
<div id={`attendance-badge-${event.id}`} className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1 ml-auto`}>
|
<div className={`badge ${hasAttended ? 'badge-success' : 'badge-ghost'} text-xs gap-1`}>
|
||||||
<Icon
|
<Icon
|
||||||
icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
|
icon={hasAttended ? "heroicons:check-circle" : "heroicons:x-circle"}
|
||||||
className="h-3 w-3"
|
className="h-3 w-3"
|
||||||
|
@ -304,6 +225,10 @@ const EventLoad = () => {
|
||||||
{hasAttended ? 'Attended' : 'Not Attended'}
|
{hasAttended ? 'Attended' : 'Not Attended'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="text-xs sm:text-sm opacity-75 ml-auto">
|
||||||
|
{event.location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -320,22 +245,17 @@ const EventLoad = () => {
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
const auth = Authentication.getInstance();
|
const auth = Authentication.getInstance();
|
||||||
|
|
||||||
// console.log("Starting to load events...");
|
console.log("Starting to load events...");
|
||||||
|
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
if (!auth.isAuthenticated()) {
|
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");
|
console.error("User not authenticated, cannot load events");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force sync to ensure we have the latest data
|
// Force sync to ensure we have the latest data
|
||||||
// console.log("Syncing events collection...");
|
console.log("Syncing events collection...");
|
||||||
let syncSuccess = false;
|
let syncSuccess = false;
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
|
@ -343,13 +263,13 @@ const EventLoad = () => {
|
||||||
while (!syncSuccess && retryCount < maxRetries) {
|
while (!syncSuccess && retryCount < maxRetries) {
|
||||||
try {
|
try {
|
||||||
if (retryCount > 0) {
|
if (retryCount > 0) {
|
||||||
// console.log(`Retry attempt ${retryCount} of ${maxRetries}...`);
|
console.log(`Retry attempt ${retryCount} of ${maxRetries}...`);
|
||||||
// Add a small delay between retries
|
// Add a small delay between retries
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
await dataSync.syncCollection(Collections.EVENTS, "published = true", "-start_date");
|
await dataSync.syncCollection(Collections.EVENTS, "published = true", "-start_date");
|
||||||
// console.log("Events collection synced successfully");
|
console.log("Events collection synced successfully");
|
||||||
syncSuccess = true;
|
syncSuccess = true;
|
||||||
} catch (syncError) {
|
} catch (syncError) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
|
@ -362,7 +282,7 @@ const EventLoad = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get events from IndexedDB
|
// Get events from IndexedDB
|
||||||
// console.log("Fetching events from IndexedDB...");
|
console.log("Fetching events from IndexedDB...");
|
||||||
const allEvents = await dataSync.getData<Event>(
|
const allEvents = await dataSync.getData<Event>(
|
||||||
Collections.EVENTS,
|
Collections.EVENTS,
|
||||||
false, // Don't force sync again
|
false, // Don't force sync again
|
||||||
|
@ -370,27 +290,27 @@ const EventLoad = () => {
|
||||||
"-start_date"
|
"-start_date"
|
||||||
);
|
);
|
||||||
|
|
||||||
// console.log(`Retrieved ${allEvents.length} events from IndexedDB`);
|
console.log(`Retrieved ${allEvents.length} events from IndexedDB`);
|
||||||
|
|
||||||
// Filter out invalid events
|
// Filter out invalid events
|
||||||
const validEvents = allEvents.filter(event => isValidEvent(event));
|
const validEvents = allEvents.filter(event => isValidEvent(event));
|
||||||
// console.log(`Filtered out ${allEvents.length - validEvents.length} invalid events`);
|
console.log(`Filtered out ${allEvents.length - validEvents.length} invalid events`);
|
||||||
|
|
||||||
// If no valid events found in IndexedDB, try fetching directly from PocketBase as fallback
|
// If no valid events found in IndexedDB, try fetching directly from PocketBase as fallback
|
||||||
let eventsToProcess = validEvents;
|
let eventsToProcess = validEvents;
|
||||||
if (allEvents.length === 0) {
|
if (allEvents.length === 0) {
|
||||||
// console.log("No events found in IndexedDB, trying direct PocketBase fetch...");
|
console.log("No events found in IndexedDB, trying direct PocketBase fetch...");
|
||||||
try {
|
try {
|
||||||
const pbEvents = await get.getAll<Event>(
|
const pbEvents = await get.getAll<Event>(
|
||||||
Collections.EVENTS,
|
Collections.EVENTS,
|
||||||
"published = true",
|
"published = true",
|
||||||
"-start_date"
|
"-start_date"
|
||||||
);
|
);
|
||||||
// console.log(`Retrieved ${pbEvents.length} events directly from PocketBase`);
|
console.log(`Retrieved ${pbEvents.length} events directly from PocketBase`);
|
||||||
|
|
||||||
// Filter out invalid events from PocketBase results
|
// Filter out invalid events from PocketBase results
|
||||||
const validPbEvents = pbEvents.filter(event => isValidEvent(event));
|
const validPbEvents = pbEvents.filter(event => isValidEvent(event));
|
||||||
// console.log(`Filtered out ${pbEvents.length - validPbEvents.length} invalid events from PocketBase`);
|
console.log(`Filtered out ${pbEvents.length - validPbEvents.length} invalid events from PocketBase`);
|
||||||
|
|
||||||
eventsToProcess = validPbEvents;
|
eventsToProcess = validPbEvents;
|
||||||
|
|
||||||
|
@ -399,7 +319,7 @@ const EventLoad = () => {
|
||||||
const dexieService = DexieService.getInstance();
|
const dexieService = DexieService.getInstance();
|
||||||
const db = dexieService.getDB();
|
const db = dexieService.getDB();
|
||||||
if (db && db.events) {
|
if (db && db.events) {
|
||||||
// console.log(`Storing ${validPbEvents.length} valid PocketBase events in IndexedDB...`);
|
console.log(`Storing ${validPbEvents.length} valid PocketBase events in IndexedDB...`);
|
||||||
await db.events.bulkPut(validPbEvents);
|
await db.events.bulkPut(validPbEvents);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -409,7 +329,7 @@ const EventLoad = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split events into upcoming, ongoing, and past based on start and end dates
|
// Split events into upcoming, ongoing, and past based on start and end dates
|
||||||
// console.log("Categorizing events...");
|
console.log("Categorizing events...");
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const { upcoming, ongoing, past } = eventsToProcess.reduce(
|
const { upcoming, ongoing, past } = eventsToProcess.reduce(
|
||||||
(acc, event) => {
|
(acc, event) => {
|
||||||
|
@ -462,7 +382,7 @@ const EventLoad = () => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// console.log(`Categorized events: ${upcoming.length} upcoming, ${ongoing.length} ongoing, ${past.length} past`);
|
console.log(`Categorized events: ${upcoming.length} upcoming, ${ongoing.length} ongoing, ${past.length} past`);
|
||||||
|
|
||||||
// Sort events
|
// Sort events
|
||||||
upcoming.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime());
|
upcoming.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime());
|
||||||
|
@ -489,16 +409,16 @@ const EventLoad = () => {
|
||||||
|
|
||||||
// Try to load from IndexedDB only as a last resort
|
// Try to load from IndexedDB only as a last resort
|
||||||
try {
|
try {
|
||||||
// console.log("Attempting to load events from IndexedDB only...");
|
console.log("Attempting to load events from IndexedDB only...");
|
||||||
const dexieService = DexieService.getInstance();
|
const dexieService = DexieService.getInstance();
|
||||||
const db = dexieService.getDB();
|
const db = dexieService.getDB();
|
||||||
if (db && db.events) {
|
if (db && db.events) {
|
||||||
const allCachedEvents = await db.events.filter(event => event.published === true).toArray();
|
const allCachedEvents = await db.events.filter(event => event.published === true).toArray();
|
||||||
// console.log(`Found ${allCachedEvents.length} cached events in IndexedDB`);
|
console.log(`Found ${allCachedEvents.length} cached events in IndexedDB`);
|
||||||
|
|
||||||
// Filter out invalid events
|
// Filter out invalid events
|
||||||
const cachedEvents = allCachedEvents.filter(event => isValidEvent(event));
|
const cachedEvents = allCachedEvents.filter(event => isValidEvent(event));
|
||||||
// console.log(`Filtered out ${allCachedEvents.length - cachedEvents.length} invalid cached events`);
|
console.log(`Filtered out ${allCachedEvents.length - cachedEvents.length} invalid cached events`);
|
||||||
|
|
||||||
if (cachedEvents.length > 0) {
|
if (cachedEvents.length > 0) {
|
||||||
// Process these events
|
// Process these events
|
||||||
|
@ -538,7 +458,7 @@ const EventLoad = () => {
|
||||||
ongoing: ongoing.slice(0, 50),
|
ongoing: ongoing.slice(0, 50),
|
||||||
past: past.slice(0, 50)
|
past: past.slice(0, 50)
|
||||||
});
|
});
|
||||||
// console.log("Successfully loaded events from cache");
|
console.log("Successfully loaded events from cache");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (cacheError) {
|
} catch (cacheError) {
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
File diff suppressed because it is too large
Load diff
|
@ -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>
|
|
|
@ -34,7 +34,13 @@ let upcomingEvents: Event[] = [];
|
||||||
// Fetch events
|
// Fetch events
|
||||||
try {
|
try {
|
||||||
if (auth.isAuthenticated()) {
|
if (auth.isAuthenticated()) {
|
||||||
eventResponse = await get.getList<Event>("events", 1, 5, "", "-start_date");
|
eventResponse = await get.getList<Event>(
|
||||||
|
"events",
|
||||||
|
1,
|
||||||
|
5,
|
||||||
|
"",
|
||||||
|
"-start_date"
|
||||||
|
);
|
||||||
upcomingEvents = eventResponse.items;
|
upcomingEvents = eventResponse.items;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -66,7 +72,9 @@ const currentPage = eventResponse.page;
|
||||||
class="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform"
|
class="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 class="stat p-4 md:p-6">
|
<div class="stat p-4 md:p-6">
|
||||||
<div class="stat-title text-sm md:text-base font-medium opacity-80">
|
<div
|
||||||
|
class="stat-title text-sm md:text-base font-medium opacity-80"
|
||||||
|
>
|
||||||
Total Events
|
Total Events
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -86,7 +94,9 @@ const currentPage = eventResponse.page;
|
||||||
class="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-secondary transition-all duration-300 hover:-translate-y-1 transform"
|
class="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 class="stat p-4 md:p-6">
|
<div class="stat p-4 md:p-6">
|
||||||
<div class="stat-title text-sm md:text-base font-medium opacity-80">
|
<div
|
||||||
|
class="stat-title text-sm md:text-base font-medium opacity-80"
|
||||||
|
>
|
||||||
Unique Attendees
|
Unique Attendees
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -106,7 +116,9 @@ const currentPage = eventResponse.page;
|
||||||
class="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-accent transition-all duration-300 hover:-translate-y-1 transform sm:col-span-2 md:col-span-1"
|
class="stats shadow-lg bg-base-100 rounded-2xl border border-base-200 hover:border-accent transition-all duration-300 hover:-translate-y-1 transform sm:col-span-2 md:col-span-1"
|
||||||
>
|
>
|
||||||
<div class="stat p-4 md:p-6">
|
<div class="stat p-4 md:p-6">
|
||||||
<div class="stat-title text-sm md:text-base font-medium opacity-80">
|
<div
|
||||||
|
class="stat-title text-sm md:text-base font-medium opacity-80"
|
||||||
|
>
|
||||||
Recurring Attendees
|
Recurring Attendees
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -143,14 +155,20 @@ const currentPage = eventResponse.page;
|
||||||
class="btn btn-ghost btn-sm md:btn-md gap-2"
|
class="btn btn-ghost btn-sm md:btn-md gap-2"
|
||||||
onclick="window.refreshEvents()"
|
onclick="window.refreshEvents()"
|
||||||
>
|
>
|
||||||
<Icon name="heroicons:arrow-path" class="h-4 w-4 md:h-5 md:w-5" />
|
<Icon
|
||||||
|
name="heroicons:arrow-path"
|
||||||
|
class="h-4 w-4 md:h-5 md:w-5"
|
||||||
|
/>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-sm md:btn-md gap-2"
|
class="btn btn-primary btn-sm md:btn-md gap-2"
|
||||||
onclick="window.openEditModal()"
|
onclick="window.openEditModal()"
|
||||||
>
|
>
|
||||||
<Icon name="heroicons:plus" class="h-4 w-4 md:h-5 md:w-5" />
|
<Icon
|
||||||
|
name="heroicons:plus"
|
||||||
|
class="h-4 w-4 md:h-5 md:w-5"
|
||||||
|
/>
|
||||||
Add New Event
|
Add New Event
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -164,7 +182,8 @@ const currentPage = eventResponse.page;
|
||||||
<div class="flex flex-wrap gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<div class="form-control w-full sm:w-auto">
|
<div class="form-control w-full sm:w-auto">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-sm md:text-base font-medium"
|
<span
|
||||||
|
class="label-text text-sm md:text-base font-medium"
|
||||||
>Time Filter</span
|
>Time Filter</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
@ -204,7 +223,8 @@ const currentPage = eventResponse.page;
|
||||||
<!-- Other filters with similar responsive adjustments -->
|
<!-- Other filters with similar responsive adjustments -->
|
||||||
<div class="form-control w-full sm:w-auto">
|
<div class="form-control w-full sm:w-auto">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-sm md:text-base font-medium"
|
<span
|
||||||
|
class="label-text text-sm md:text-base font-medium"
|
||||||
>Year</span
|
>Year</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
@ -234,7 +254,8 @@ const currentPage = eventResponse.page;
|
||||||
>
|
>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
<span class="label-text">All Years</span>
|
<span class="label-text">All Years</span
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox"
|
class="checkbox"
|
||||||
|
@ -252,7 +273,8 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
<div class="form-control w-full sm:w-auto">
|
<div class="form-control w-full sm:w-auto">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-sm md:text-base font-medium"
|
<span
|
||||||
|
class="label-text text-sm md:text-base font-medium"
|
||||||
>Quarter</span
|
>Quarter</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
@ -262,7 +284,9 @@ const currentPage = eventResponse.page;
|
||||||
class="btn btn-sm m-1 w-[180px] justify-between items-center"
|
class="btn btn-sm m-1 w-[180px] justify-between items-center"
|
||||||
>
|
>
|
||||||
<div class="flex-1 text-left truncate">
|
<div class="flex-1 text-left truncate">
|
||||||
<span id="quarterFilterLabel">All Quarters</span>
|
<span id="quarterFilterLabel"
|
||||||
|
>All Quarters</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
@ -283,7 +307,9 @@ const currentPage = eventResponse.page;
|
||||||
>
|
>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
<span class="label-text">All Quarters</span>
|
<span class="label-text"
|
||||||
|
>All Quarters</span
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox"
|
class="checkbox"
|
||||||
|
@ -295,25 +321,41 @@ const currentPage = eventResponse.page;
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
<span class="label-text">Fall</span>
|
<span class="label-text">Fall</span>
|
||||||
<input type="checkbox" class="checkbox" value="fall" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox"
|
||||||
|
value="fall"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
<span class="label-text">Winter</span>
|
<span class="label-text">Winter</span>
|
||||||
<input type="checkbox" class="checkbox" value="winter" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox"
|
||||||
|
value="winter"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
<span class="label-text">Spring</span>
|
<span class="label-text">Spring</span>
|
||||||
<input type="checkbox" class="checkbox" value="spring" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox"
|
||||||
|
value="spring"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
<span class="label-text">Summer</span>
|
<span class="label-text">Summer</span>
|
||||||
<input type="checkbox" class="checkbox" value="summer" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox"
|
||||||
|
value="summer"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -322,7 +364,8 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
<div class="form-control w-full sm:w-auto">
|
<div class="form-control w-full sm:w-auto">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-sm md:text-base font-medium"
|
<span
|
||||||
|
class="label-text text-sm md:text-base font-medium"
|
||||||
>Published</span
|
>Published</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
@ -388,7 +431,8 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
<div class="form-control w-full sm:w-auto">
|
<div class="form-control w-full sm:w-auto">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-sm md:text-base font-medium"
|
<span
|
||||||
|
class="label-text text-sm md:text-base font-medium"
|
||||||
>Has Files</span
|
>Has Files</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
@ -454,7 +498,8 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
<div class="form-control w-full sm:w-auto">
|
<div class="form-control w-full sm:w-auto">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-sm md:text-base font-medium"
|
<span
|
||||||
|
class="label-text text-sm md:text-base font-medium"
|
||||||
>Has Food</span
|
>Has Food</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
@ -524,7 +569,9 @@ const currentPage = eventResponse.page;
|
||||||
<div class="flex flex-col sm:flex-row gap-4 mb-4">
|
<div class="flex flex-col sm:flex-row gap-4 mb-4">
|
||||||
<div class="form-control flex-1">
|
<div class="form-control flex-1">
|
||||||
<div class="join w-full">
|
<div class="join w-full">
|
||||||
<div class="join-item bg-base-200 flex items-center px-3">
|
<div
|
||||||
|
class="join-item bg-base-200 flex items-center px-3"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-4 w-4 md:h-5 md:w-5 opacity-70"
|
class="h-4 w-4 md:h-5 md:w-5 opacity-70"
|
||||||
|
@ -571,22 +618,29 @@ const currentPage = eventResponse.page;
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div class="flex justify-center mt-4 md:mt-6" id="paginationContainer">
|
<div
|
||||||
<div class="join">
|
class="flex justify-center mt-4 md:mt-6"
|
||||||
<button class="join-item btn btn-xs md:btn-sm" id="firstPageBtn"
|
id="paginationContainer"
|
||||||
>«</button
|
|
||||||
>
|
>
|
||||||
<button class="join-item btn btn-xs md:btn-sm" id="prevPageBtn"
|
<div class="join">
|
||||||
>‹</button
|
<button
|
||||||
|
class="join-item btn btn-xs md:btn-sm"
|
||||||
|
id="firstPageBtn">«</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="join-item btn btn-xs md:btn-sm"
|
||||||
|
id="prevPageBtn">‹</button
|
||||||
>
|
>
|
||||||
<button class="join-item btn btn-xs md:btn-sm"
|
<button class="join-item btn btn-xs md:btn-sm"
|
||||||
>Page <span id="currentPageNumber">1</span></button
|
>Page <span id="currentPageNumber">1</span></button
|
||||||
>
|
>
|
||||||
<button class="join-item btn btn-xs md:btn-sm" id="nextPageBtn"
|
<button
|
||||||
>›</button
|
class="join-item btn btn-xs md:btn-sm"
|
||||||
|
id="nextPageBtn">›</button
|
||||||
>
|
>
|
||||||
<button class="join-item btn btn-xs md:btn-sm" id="lastPageBtn"
|
<button
|
||||||
>»</button
|
class="join-item btn btn-xs md:btn-sm"
|
||||||
|
id="lastPageBtn">»</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -681,7 +735,10 @@ const currentPage = eventResponse.page;
|
||||||
<div class="modal-box max-w-4xl">
|
<div class="modal-box max-w-4xl">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="font-bold text-lg" id="attendeesModalTitle"></h3>
|
<h3 class="font-bold text-lg" id="attendeesModalTitle"></h3>
|
||||||
<button class="btn btn-circle btn-ghost" onclick="attendeesModal.close()">
|
<button
|
||||||
|
class="btn btn-circle btn-ghost"
|
||||||
|
onclick="attendeesModal.close()"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-6 w-6"
|
class="h-6 w-6"
|
||||||
|
@ -783,7 +840,9 @@ const currentPage = eventResponse.page;
|
||||||
) {
|
) {
|
||||||
auth.setUpdating(true);
|
auth.setUpdating(true);
|
||||||
const response = await get.getAll<Event>("events");
|
const response = await get.getAll<Event>("events");
|
||||||
cachedEvents = response.map((event) => Get.convertUTCToLocal(event));
|
cachedEvents = response.map((event) =>
|
||||||
|
Get.convertUTCToLocal(event)
|
||||||
|
);
|
||||||
lastCacheUpdate = now;
|
lastCacheUpdate = now;
|
||||||
|
|
||||||
// Initialize year filter options from cache
|
// Initialize year filter options from cache
|
||||||
|
@ -793,7 +852,8 @@ const currentPage = eventResponse.page;
|
||||||
years.add(year);
|
years.add(year);
|
||||||
});
|
});
|
||||||
|
|
||||||
const yearCheckboxes = document.getElementById("yearCheckboxes");
|
const yearCheckboxes =
|
||||||
|
document.getElementById("yearCheckboxes");
|
||||||
if (yearCheckboxes) {
|
if (yearCheckboxes) {
|
||||||
const sortedYears = Array.from(years).sort((a, b) => b - a);
|
const sortedYears = Array.from(years).sort((a, b) => b - a);
|
||||||
yearCheckboxes.innerHTML = sortedYears
|
yearCheckboxes.innerHTML = sortedYears
|
||||||
|
@ -803,16 +863,18 @@ const currentPage = eventResponse.page;
|
||||||
<span class="label-text">${year}</span>
|
<span class="label-text">${year}</span>
|
||||||
<input type="checkbox" class="checkbox" value="${year}" />
|
<input type="checkbox" class="checkbox" value="${year}" />
|
||||||
</label>
|
</label>
|
||||||
`,
|
`
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
// Add event listeners to checkboxes
|
// Add event listeners to checkboxes
|
||||||
const allYearsCheckbox = document.querySelector(
|
const allYearsCheckbox = document.querySelector(
|
||||||
'input[type="checkbox"][value="all"]',
|
'input[type="checkbox"][value="all"]'
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const yearInputs = Array.from(
|
const yearInputs = Array.from(
|
||||||
yearCheckboxes.querySelectorAll('input[type="checkbox"]'),
|
yearCheckboxes.querySelectorAll(
|
||||||
|
'input[type="checkbox"]'
|
||||||
|
)
|
||||||
) as HTMLInputElement[];
|
) as HTMLInputElement[];
|
||||||
|
|
||||||
if (allYearsCheckbox) {
|
if (allYearsCheckbox) {
|
||||||
|
@ -823,8 +885,9 @@ const currentPage = eventResponse.page;
|
||||||
input.checked = false;
|
input.checked = false;
|
||||||
});
|
});
|
||||||
filterState.year = ["all"];
|
filterState.year = ["all"];
|
||||||
document.getElementById("yearFilterLabel")!.textContent =
|
document.getElementById(
|
||||||
"All Years";
|
"yearFilterLabel"
|
||||||
|
)!.textContent = "All Years";
|
||||||
}
|
}
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
|
@ -840,12 +903,15 @@ const currentPage = eventResponse.page;
|
||||||
if (checkedYears.length === 0) {
|
if (checkedYears.length === 0) {
|
||||||
allYearsCheckbox.checked = true;
|
allYearsCheckbox.checked = true;
|
||||||
filterState.year = ["all"];
|
filterState.year = ["all"];
|
||||||
document.getElementById("yearFilterLabel")!.textContent =
|
document.getElementById(
|
||||||
"All Years";
|
"yearFilterLabel"
|
||||||
|
)!.textContent = "All Years";
|
||||||
} else {
|
} else {
|
||||||
allYearsCheckbox.checked = false;
|
allYearsCheckbox.checked = false;
|
||||||
filterState.year = checkedYears;
|
filterState.year = checkedYears;
|
||||||
document.getElementById("yearFilterLabel")!.textContent =
|
document.getElementById(
|
||||||
|
"yearFilterLabel"
|
||||||
|
)!.textContent =
|
||||||
checkedYears.length === 1
|
checkedYears.length === 1
|
||||||
? checkedYears[0]
|
? checkedYears[0]
|
||||||
: `${checkedYears.length} Years Selected`;
|
: `${checkedYears.length} Years Selected`;
|
||||||
|
@ -891,27 +957,27 @@ const currentPage = eventResponse.page;
|
||||||
let start: Date, end: Date;
|
let start: Date, end: Date;
|
||||||
|
|
||||||
// Determine quarter (0-based months: 0-11)
|
// Determine quarter (0-based months: 0-11)
|
||||||
// Fall: Sept-Dec (8-11)
|
// Q1: Sept-Dec (8-11)
|
||||||
// Winter: Jan-Mar (0-2)
|
// Q2: Jan-Mar (0-2)
|
||||||
// Spring: Apr-Jun (3-5)
|
// Q3: Mar-Jun (2-5)
|
||||||
// Summer: Jul-Sept (6-8)
|
// Q4: Jun-Sept (5-8)
|
||||||
|
|
||||||
if (month >= 8) {
|
if (month >= 8) {
|
||||||
// Fall: Sept-Dec
|
// Q1: Sept-Dec
|
||||||
start = new Date(year, 8, 1);
|
start = new Date(year, 8, 1);
|
||||||
end = new Date(year, 11, 31);
|
end = new Date(year, 11, 31);
|
||||||
} else if (month >= 0 && month < 3) {
|
} else if (month < 2) {
|
||||||
// Winter: Jan-Mar
|
// Q2: Jan-Mar
|
||||||
start = new Date(year, 0, 1);
|
start = new Date(year, 0, 1);
|
||||||
end = new Date(year, 2, 31);
|
end = new Date(year, 2, 31);
|
||||||
} else if (month >= 3 && month < 6) {
|
} else if (month < 5) {
|
||||||
// Spring: Apr-Jun
|
// Q3: Mar-Jun
|
||||||
start = new Date(year, 3, 1);
|
start = new Date(year, 2, 1);
|
||||||
end = new Date(year, 5, 30);
|
end = new Date(year, 5, 30);
|
||||||
} else {
|
} else {
|
||||||
// Summer: Jul-Sept
|
// Q4: Jun-Sept
|
||||||
start = new Date(year, 6, 1);
|
start = new Date(year, 5, 1);
|
||||||
end = new Date(year, 8, 30);
|
end = new Date(year, 8, 0); // End on Aug 31
|
||||||
}
|
}
|
||||||
|
|
||||||
return { start, end };
|
return { start, end };
|
||||||
|
@ -924,14 +990,14 @@ const currentPage = eventResponse.page;
|
||||||
if (month >= 8) {
|
if (month >= 8) {
|
||||||
// Sept-Dec
|
// Sept-Dec
|
||||||
return "Fall";
|
return "Fall";
|
||||||
} else if (month >= 0 && month < 3) {
|
} else if (month < 2) {
|
||||||
// Jan-Mar
|
// Jan-Mar
|
||||||
return "Winter";
|
return "Winter";
|
||||||
} else if (month >= 3 && month < 6) {
|
} else if (month < 5) {
|
||||||
// Apr-Jun
|
// Mar-Jun
|
||||||
return "Spring";
|
return "Spring";
|
||||||
} else {
|
} else {
|
||||||
// Jul-Sept
|
// Jun-Sept
|
||||||
return "Summer";
|
return "Summer";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -944,7 +1010,8 @@ const currentPage = eventResponse.page;
|
||||||
const eventEnd = new Date(event.end_date).toISOString();
|
const eventEnd = new Date(event.end_date).toISOString();
|
||||||
|
|
||||||
// Time filter
|
// Time filter
|
||||||
if (filterState.time === "upcoming" && eventStart <= now) return false;
|
if (filterState.time === "upcoming" && eventStart <= now)
|
||||||
|
return false;
|
||||||
if (filterState.time === "past" && eventEnd >= now) return false;
|
if (filterState.time === "past" && eventEnd >= now) return false;
|
||||||
if (
|
if (
|
||||||
filterState.time === "ongoing" &&
|
filterState.time === "ongoing" &&
|
||||||
|
@ -961,7 +1028,7 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
// Check if either the start year or end year matches any selected year
|
// Check if either the start year or end year matches any selected year
|
||||||
const yearMatches = filterState.year.some(
|
const yearMatches = filterState.year.some(
|
||||||
(year) => year === eventStartYear || year === eventEndYear,
|
(year) => year === eventStartYear || year === eventEndYear
|
||||||
);
|
);
|
||||||
if (!yearMatches) return false;
|
if (!yearMatches) return false;
|
||||||
}
|
}
|
||||||
|
@ -979,13 +1046,13 @@ const currentPage = eventResponse.page;
|
||||||
isInQuarter = month >= 8 && month <= 11; // Sept-Dec
|
isInQuarter = month >= 8 && month <= 11; // Sept-Dec
|
||||||
break;
|
break;
|
||||||
case "winter":
|
case "winter":
|
||||||
isInQuarter = month >= 0 && month < 3; // Jan-Mar (0-2)
|
isInQuarter = month >= 0 && month <= 2; // Jan-Mar
|
||||||
break;
|
break;
|
||||||
case "spring":
|
case "spring":
|
||||||
isInQuarter = month >= 3 && month < 6; // Apr-Jun (3-5)
|
isInQuarter = month >= 2 && month <= 5; // Mar-Jun
|
||||||
break;
|
break;
|
||||||
case "summer":
|
case "summer":
|
||||||
isInQuarter = month >= 6 && month < 9; // Jul-Sept (6-8)
|
isInQuarter = month >= 5 && month <= 8; // Jun-Sept
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (isInQuarter) {
|
if (isInQuarter) {
|
||||||
|
@ -998,7 +1065,8 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
// Published filter
|
// Published filter
|
||||||
if (filterState.published !== "all") {
|
if (filterState.published !== "all") {
|
||||||
if ((filterState.published === "yes") !== event.published) return false;
|
if ((filterState.published === "yes") !== event.published)
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Has Files filter
|
// Has Files filter
|
||||||
|
@ -1009,7 +1077,8 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
// Has Food filter
|
// Has Food filter
|
||||||
if (filterState.hasFood !== "all") {
|
if (filterState.hasFood !== "all") {
|
||||||
if ((filterState.hasFood === "yes") !== event.has_food) return false;
|
if ((filterState.hasFood === "yes") !== event.has_food)
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search query
|
// Search query
|
||||||
|
@ -1024,7 +1093,7 @@ const currentPage = eventResponse.page;
|
||||||
event.event_name.toLowerCase().includes(term) ||
|
event.event_name.toLowerCase().includes(term) ||
|
||||||
event.event_code.toLowerCase().includes(term) ||
|
event.event_code.toLowerCase().includes(term) ||
|
||||||
event.location.toLowerCase().includes(term) ||
|
event.location.toLowerCase().includes(term) ||
|
||||||
event.event_description.toLowerCase().includes(term),
|
event.event_description.toLowerCase().includes(term)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1036,7 +1105,9 @@ const currentPage = eventResponse.page;
|
||||||
// Fetch and display events using cached data
|
// Fetch and display events using cached data
|
||||||
async function fetchEvents() {
|
async function fetchEvents() {
|
||||||
const eventsList = document.getElementById("eventsList");
|
const eventsList = document.getElementById("eventsList");
|
||||||
const paginationContainer = document.getElementById("paginationContainer");
|
const paginationContainer = document.getElementById(
|
||||||
|
"paginationContainer"
|
||||||
|
);
|
||||||
if (!eventsList || !paginationContainer) return;
|
if (!eventsList || !paginationContainer) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -1062,7 +1133,8 @@ const currentPage = eventResponse.page;
|
||||||
// Sort events by start date (newest first)
|
// Sort events by start date (newest first)
|
||||||
filteredEvents.sort(
|
filteredEvents.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(b.start_date).getTime() - new Date(a.start_date).getTime(),
|
new Date(b.start_date).getTime() -
|
||||||
|
new Date(a.start_date).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate pagination
|
// Calculate pagination
|
||||||
|
@ -1074,18 +1146,19 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
// Update pagination UI
|
// Update pagination UI
|
||||||
const firstPageBtn = document.getElementById(
|
const firstPageBtn = document.getElementById(
|
||||||
"firstPageBtn",
|
"firstPageBtn"
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
const prevPageBtn = document.getElementById(
|
const prevPageBtn = document.getElementById(
|
||||||
"prevPageBtn",
|
"prevPageBtn"
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
const nextPageBtn = document.getElementById(
|
const nextPageBtn = document.getElementById(
|
||||||
"nextPageBtn",
|
"nextPageBtn"
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
const lastPageBtn = document.getElementById(
|
const lastPageBtn = document.getElementById(
|
||||||
"lastPageBtn",
|
"lastPageBtn"
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
const currentPageNumber = document.getElementById("currentPageNumber");
|
const currentPageNumber =
|
||||||
|
document.getElementById("currentPageNumber");
|
||||||
|
|
||||||
if (firstPageBtn) firstPageBtn.disabled = currentPage <= 1;
|
if (firstPageBtn) firstPageBtn.disabled = currentPage <= 1;
|
||||||
if (prevPageBtn) prevPageBtn.disabled = currentPage <= 1;
|
if (prevPageBtn) prevPageBtn.disabled = currentPage <= 1;
|
||||||
|
@ -1133,8 +1206,12 @@ const currentPage = eventResponse.page;
|
||||||
console.error("Error formatting date:", e);
|
console.error("Error formatting date:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const locationStr = event.location ? `${event.location}` : "";
|
const locationStr = event.location
|
||||||
const codeStr = event.event_code ? `${event.event_code}` : "";
|
? `${event.location}`
|
||||||
|
: "";
|
||||||
|
const codeStr = event.event_code
|
||||||
|
? `${event.event_code}`
|
||||||
|
: "";
|
||||||
const detailsStr = [locationStr, codeStr]
|
const detailsStr = [locationStr, codeStr]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" | code: ");
|
.join(" | code: ");
|
||||||
|
@ -1199,7 +1276,8 @@ const currentPage = eventResponse.page;
|
||||||
async function calculateQuarterlyStats() {
|
async function calculateQuarterlyStats() {
|
||||||
try {
|
try {
|
||||||
const { start: termStart, end: termEnd } = getCurrentTerm();
|
const { start: termStart, end: termEnd } = getCurrentTerm();
|
||||||
const { start: quarterStart, end: quarterEnd } = getCurrentQuarter();
|
const { start: quarterStart, end: quarterEnd } =
|
||||||
|
getCurrentQuarter();
|
||||||
|
|
||||||
// Update quarter name in UI
|
// Update quarter name in UI
|
||||||
const quarterNameEl = document.getElementById("quarterName");
|
const quarterNameEl = document.getElementById("quarterName");
|
||||||
|
@ -1240,17 +1318,19 @@ const currentPage = eventResponse.page;
|
||||||
});
|
});
|
||||||
|
|
||||||
quarterlyStats.recurringAttendees = Array.from(
|
quarterlyStats.recurringAttendees = Array.from(
|
||||||
quarterAttendees.values(),
|
quarterAttendees.values()
|
||||||
).filter((count) => count > 1).length;
|
).filter((count) => count > 1).length;
|
||||||
|
|
||||||
// Update the UI
|
// Update the UI
|
||||||
const totalEventsEl = document.getElementById("totalEvents");
|
const totalEventsEl = document.getElementById("totalEvents");
|
||||||
const uniqueAttendeesEl = document.getElementById("uniqueAttendees");
|
const uniqueAttendeesEl =
|
||||||
|
document.getElementById("uniqueAttendees");
|
||||||
const recurringAttendeesEl =
|
const recurringAttendeesEl =
|
||||||
document.getElementById("recurringAttendees");
|
document.getElementById("recurringAttendees");
|
||||||
|
|
||||||
if (totalEventsEl)
|
if (totalEventsEl)
|
||||||
totalEventsEl.textContent = quarterlyStats.totalEvents.toString();
|
totalEventsEl.textContent =
|
||||||
|
quarterlyStats.totalEvents.toString();
|
||||||
if (uniqueAttendeesEl)
|
if (uniqueAttendeesEl)
|
||||||
uniqueAttendeesEl.textContent =
|
uniqueAttendeesEl.textContent =
|
||||||
quarterlyStats.uniqueAttendees.toString();
|
quarterlyStats.uniqueAttendees.toString();
|
||||||
|
@ -1303,20 +1383,24 @@ const currentPage = eventResponse.page;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Year filter
|
// Year filter
|
||||||
document.getElementById("yearCheckboxes")?.addEventListener("change", (e) => {
|
document
|
||||||
|
.getElementById("yearCheckboxes")
|
||||||
|
?.addEventListener("change", (e) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
if (!target.matches('input[type="checkbox"]')) return;
|
if (!target.matches('input[type="checkbox"]')) return;
|
||||||
|
|
||||||
const allYearsCheckbox = target
|
const allYearsCheckbox = target
|
||||||
.closest(".dropdown-content")
|
.closest(".dropdown-content")
|
||||||
?.querySelector(
|
?.querySelector(
|
||||||
'input[type="checkbox"][value="all"]',
|
'input[type="checkbox"][value="all"]'
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
const yearInputs = Array.from(
|
const yearInputs = Array.from(
|
||||||
target
|
target
|
||||||
.closest(".dropdown-content")
|
.closest(".dropdown-content")
|
||||||
?.querySelectorAll('#yearCheckboxes input[type="checkbox"]') || [],
|
?.querySelectorAll(
|
||||||
|
'#yearCheckboxes input[type="checkbox"]'
|
||||||
|
) || []
|
||||||
) as HTMLInputElement[];
|
) as HTMLInputElement[];
|
||||||
|
|
||||||
if (target.value === "all" && target.checked) {
|
if (target.value === "all" && target.checked) {
|
||||||
|
@ -1324,7 +1408,8 @@ const currentPage = eventResponse.page;
|
||||||
input.checked = false;
|
input.checked = false;
|
||||||
});
|
});
|
||||||
filterState.year = ["all"];
|
filterState.year = ["all"];
|
||||||
document.getElementById("yearFilterLabel")!.textContent = "All Years";
|
document.getElementById("yearFilterLabel")!.textContent =
|
||||||
|
"All Years";
|
||||||
} else {
|
} else {
|
||||||
const checkedYears = yearInputs
|
const checkedYears = yearInputs
|
||||||
.filter((inp) => inp.checked)
|
.filter((inp) => inp.checked)
|
||||||
|
@ -1333,7 +1418,8 @@ const currentPage = eventResponse.page;
|
||||||
if (checkedYears.length === 0) {
|
if (checkedYears.length === 0) {
|
||||||
allYearsCheckbox.checked = true;
|
allYearsCheckbox.checked = true;
|
||||||
filterState.year = ["all"];
|
filterState.year = ["all"];
|
||||||
document.getElementById("yearFilterLabel")!.textContent = "All Years";
|
document.getElementById("yearFilterLabel")!.textContent =
|
||||||
|
"All Years";
|
||||||
} else {
|
} else {
|
||||||
allYearsCheckbox.checked = false;
|
allYearsCheckbox.checked = false;
|
||||||
filterState.year = checkedYears;
|
filterState.year = checkedYears;
|
||||||
|
@ -1353,7 +1439,9 @@ const currentPage = eventResponse.page;
|
||||||
?.addEventListener("change", (e) => {
|
?.addEventListener("change", (e) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
const yearInputs = Array.from(
|
const yearInputs = Array.from(
|
||||||
document.querySelectorAll('#yearCheckboxes input[type="checkbox"]'),
|
document.querySelectorAll(
|
||||||
|
'#yearCheckboxes input[type="checkbox"]'
|
||||||
|
)
|
||||||
) as HTMLInputElement[];
|
) as HTMLInputElement[];
|
||||||
|
|
||||||
if (target.checked) {
|
if (target.checked) {
|
||||||
|
@ -1361,7 +1449,8 @@ const currentPage = eventResponse.page;
|
||||||
input.checked = false;
|
input.checked = false;
|
||||||
});
|
});
|
||||||
filterState.year = ["all"];
|
filterState.year = ["all"];
|
||||||
document.getElementById("yearFilterLabel")!.textContent = "All Years";
|
document.getElementById("yearFilterLabel")!.textContent =
|
||||||
|
"All Years";
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
}
|
}
|
||||||
|
@ -1377,15 +1466,15 @@ const currentPage = eventResponse.page;
|
||||||
const allQuartersCheckbox = target
|
const allQuartersCheckbox = target
|
||||||
.closest("#quarterDropdownContent")
|
.closest("#quarterDropdownContent")
|
||||||
?.querySelector(
|
?.querySelector(
|
||||||
'input[type="checkbox"][value="all"]',
|
'input[type="checkbox"][value="all"]'
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
const quarterInputs = Array.from(
|
const quarterInputs = Array.from(
|
||||||
target
|
target
|
||||||
.closest("#quarterDropdownContent")
|
.closest("#quarterDropdownContent")
|
||||||
?.querySelectorAll('input[type="checkbox"]') || [],
|
?.querySelectorAll('input[type="checkbox"]') || []
|
||||||
).filter(
|
).filter(
|
||||||
(inp) => (inp as HTMLInputElement).value !== "all",
|
(inp) => (inp as HTMLInputElement).value !== "all"
|
||||||
) as HTMLInputElement[];
|
) as HTMLInputElement[];
|
||||||
|
|
||||||
if (target.value === "all" && target.checked) {
|
if (target.value === "all" && target.checked) {
|
||||||
|
@ -1427,8 +1516,8 @@ const currentPage = eventResponse.page;
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
filterState.published = target.value;
|
filterState.published = target.value;
|
||||||
document.getElementById("publishedFilterLabel")!.textContent =
|
document.getElementById("publishedFilterLabel")!.textContent =
|
||||||
target.parentElement?.querySelector(".label-text")?.textContent ||
|
target.parentElement?.querySelector(".label-text")
|
||||||
"All";
|
?.textContent || "All";
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
});
|
});
|
||||||
|
@ -1442,8 +1531,8 @@ const currentPage = eventResponse.page;
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
filterState.hasFiles = target.value;
|
filterState.hasFiles = target.value;
|
||||||
document.getElementById("hasFilesFilterLabel")!.textContent =
|
document.getElementById("hasFilesFilterLabel")!.textContent =
|
||||||
target.parentElement?.querySelector(".label-text")?.textContent ||
|
target.parentElement?.querySelector(".label-text")
|
||||||
"All";
|
?.textContent || "All";
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
});
|
});
|
||||||
|
@ -1457,8 +1546,8 @@ const currentPage = eventResponse.page;
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
filterState.hasFood = target.value;
|
filterState.hasFood = target.value;
|
||||||
document.getElementById("hasFoodFilterLabel")!.textContent =
|
document.getElementById("hasFoodFilterLabel")!.textContent =
|
||||||
target.parentElement?.querySelector(".label-text")?.textContent ||
|
target.parentElement?.querySelector(".label-text")
|
||||||
"All";
|
?.textContent || "All";
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
});
|
});
|
||||||
|
@ -1487,7 +1576,9 @@ const currentPage = eventResponse.page;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per page select
|
// Per page select
|
||||||
document.getElementById("perPageSelect")?.addEventListener("change", (e) => {
|
document
|
||||||
|
.getElementById("perPageSelect")
|
||||||
|
?.addEventListener("change", (e) => {
|
||||||
const target = e.target as HTMLSelectElement;
|
const target = e.target as HTMLSelectElement;
|
||||||
perPage = parseInt(target.value);
|
perPage = parseInt(target.value);
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
|
@ -1499,7 +1590,7 @@ const currentPage = eventResponse.page;
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
// Reset all filters to defaults
|
// Reset all filters to defaults
|
||||||
const timeFilterAll = document.querySelector(
|
const timeFilterAll = document.querySelector(
|
||||||
'input[name="timeFilter"][value="all"]',
|
'input[name="timeFilter"][value="all"]'
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
if (timeFilterAll) {
|
if (timeFilterAll) {
|
||||||
// Ensure the radio button is properly checked
|
// Ensure the radio button is properly checked
|
||||||
|
@ -1509,10 +1600,10 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
// Reset year filter
|
// Reset year filter
|
||||||
const yearAllCheckbox = document.querySelector(
|
const yearAllCheckbox = document.querySelector(
|
||||||
'input[type="checkbox"][value="all"]',
|
'input[type="checkbox"][value="all"]'
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const yearCheckboxes = document.querySelectorAll(
|
const yearCheckboxes = document.querySelectorAll(
|
||||||
'#yearCheckboxes input[type="checkbox"]',
|
'#yearCheckboxes input[type="checkbox"]'
|
||||||
);
|
);
|
||||||
if (yearAllCheckbox && yearCheckboxes) {
|
if (yearAllCheckbox && yearCheckboxes) {
|
||||||
yearAllCheckbox.checked = true;
|
yearAllCheckbox.checked = true;
|
||||||
|
@ -1520,15 +1611,16 @@ const currentPage = eventResponse.page;
|
||||||
(checkbox as HTMLInputElement).checked = false;
|
(checkbox as HTMLInputElement).checked = false;
|
||||||
});
|
});
|
||||||
filterState.year = ["all"];
|
filterState.year = ["all"];
|
||||||
document.getElementById("yearFilterLabel")!.textContent = "All Years";
|
document.getElementById("yearFilterLabel")!.textContent =
|
||||||
|
"All Years";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset quarter filter
|
// Reset quarter filter
|
||||||
const quarterAllCheckbox = document.querySelector(
|
const quarterAllCheckbox = document.querySelector(
|
||||||
'#quarterDropdownContent input[type="checkbox"][value="all"]',
|
'#quarterDropdownContent input[type="checkbox"][value="all"]'
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const quarterCheckboxes = document.querySelectorAll(
|
const quarterCheckboxes = document.querySelectorAll(
|
||||||
'#quarterDropdownContent input[type="checkbox"]:not([value="all"])',
|
'#quarterDropdownContent input[type="checkbox"]:not([value="all"])'
|
||||||
);
|
);
|
||||||
if (quarterAllCheckbox && quarterCheckboxes) {
|
if (quarterAllCheckbox && quarterCheckboxes) {
|
||||||
quarterAllCheckbox.checked = true;
|
quarterAllCheckbox.checked = true;
|
||||||
|
@ -1541,27 +1633,27 @@ const currentPage = eventResponse.page;
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishedFilter = document.getElementById(
|
const publishedFilter = document.getElementById(
|
||||||
"publishedFilter",
|
"publishedFilter"
|
||||||
) as HTMLSelectElement;
|
) as HTMLSelectElement;
|
||||||
if (publishedFilter) publishedFilter.value = "all";
|
if (publishedFilter) publishedFilter.value = "all";
|
||||||
|
|
||||||
const hasFilesFilter = document.getElementById(
|
const hasFilesFilter = document.getElementById(
|
||||||
"hasFilesFilter",
|
"hasFilesFilter"
|
||||||
) as HTMLSelectElement;
|
) as HTMLSelectElement;
|
||||||
if (hasFilesFilter) hasFilesFilter.value = "all";
|
if (hasFilesFilter) hasFilesFilter.value = "all";
|
||||||
|
|
||||||
const hasFoodFilter = document.getElementById(
|
const hasFoodFilter = document.getElementById(
|
||||||
"hasFoodFilter",
|
"hasFoodFilter"
|
||||||
) as HTMLSelectElement;
|
) as HTMLSelectElement;
|
||||||
if (hasFoodFilter) hasFoodFilter.value = "all";
|
if (hasFoodFilter) hasFoodFilter.value = "all";
|
||||||
|
|
||||||
const searchInput = document.getElementById(
|
const searchInput = document.getElementById(
|
||||||
"searchInput",
|
"searchInput"
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
if (searchInput) searchInput.value = "";
|
if (searchInput) searchInput.value = "";
|
||||||
|
|
||||||
const perPageSelect = document.getElementById(
|
const perPageSelect = document.getElementById(
|
||||||
"perPageSelect",
|
"perPageSelect"
|
||||||
) as HTMLSelectElement;
|
) as HTMLSelectElement;
|
||||||
if (perPageSelect) perPageSelect.value = "5";
|
if (perPageSelect) perPageSelect.value = "5";
|
||||||
|
|
||||||
|
@ -1591,10 +1683,12 @@ const currentPage = eventResponse.page;
|
||||||
// Update the previewFileInEditModal function
|
// Update the previewFileInEditModal function
|
||||||
window.previewFileInEditModal = async function (
|
window.previewFileInEditModal = async function (
|
||||||
url: string,
|
url: string,
|
||||||
filename: string,
|
filename: string
|
||||||
) {
|
) {
|
||||||
const editFormSection = document.getElementById("editFormSection");
|
const editFormSection = document.getElementById("editFormSection");
|
||||||
const previewSection = document.getElementById("editModalPreviewSection");
|
const previewSection = document.getElementById(
|
||||||
|
"editModalPreviewSection"
|
||||||
|
);
|
||||||
const editFilePreview = document.getElementById("editFilePreview");
|
const editFilePreview = document.getElementById("editFilePreview");
|
||||||
const previewFileName = document.getElementById("editPreviewFileName");
|
const previewFileName = document.getElementById("editPreviewFileName");
|
||||||
const loadingSpinner = document.getElementById("editLoadingSpinner");
|
const loadingSpinner = document.getElementById("editLoadingSpinner");
|
||||||
|
@ -1630,14 +1724,16 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
// Update the showFilePreview function
|
// Update the showFilePreview function
|
||||||
window.showFilePreview = function (file: { url: string; name: string }) {
|
window.showFilePreview = function (file: { url: string; name: string }) {
|
||||||
// console.log("showFilePreview called with:", file);
|
console.log("showFilePreview called with:", file);
|
||||||
window.previewFile(file.url, file.name);
|
window.previewFile(file.url, file.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add backToEditForm function
|
// Add backToEditForm function
|
||||||
window.backToEditForm = function () {
|
window.backToEditForm = function () {
|
||||||
const editFormSection = document.getElementById("editFormSection");
|
const editFormSection = document.getElementById("editFormSection");
|
||||||
const previewSection = document.getElementById("editModalPreviewSection");
|
const previewSection = document.getElementById(
|
||||||
|
"editModalPreviewSection"
|
||||||
|
);
|
||||||
const editFilePreview = document.getElementById("editFilePreview");
|
const editFilePreview = document.getElementById("editFilePreview");
|
||||||
const previewFileName = document.getElementById("editPreviewFileName");
|
const previewFileName = document.getElementById("editPreviewFileName");
|
||||||
|
|
||||||
|
@ -1660,15 +1756,17 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
// Universal file preview function
|
// Universal file preview function
|
||||||
window.previewFile = function (url: string, filename: string) {
|
window.previewFile = function (url: string, filename: string) {
|
||||||
// console.log("previewFile called with:", { url, filename });
|
console.log("previewFile called with:", { url, filename });
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById(
|
||||||
"filePreviewModal",
|
"filePreviewModal"
|
||||||
) as HTMLDialogElement;
|
) as HTMLDialogElement;
|
||||||
const filePreview = document.getElementById("officerFilePreview") as any;
|
const filePreview = document.getElementById(
|
||||||
|
"officerFilePreview"
|
||||||
|
) as any;
|
||||||
const previewFileName = document.getElementById("previewFileName");
|
const previewFileName = document.getElementById("previewFileName");
|
||||||
|
|
||||||
if (filePreview && modal && previewFileName) {
|
if (filePreview && modal && previewFileName) {
|
||||||
// console.log("Found all required elements");
|
console.log("Found all required elements");
|
||||||
// Update the filename display
|
// Update the filename display
|
||||||
previewFileName.textContent = filename;
|
previewFileName.textContent = filename;
|
||||||
|
|
||||||
|
@ -1676,7 +1774,7 @@ const currentPage = eventResponse.page;
|
||||||
modal.showModal();
|
modal.showModal();
|
||||||
|
|
||||||
// Update the preview component
|
// Update the preview component
|
||||||
// console.log("Dispatching updateFilePreview event");
|
console.log("Dispatching updateFilePreview event");
|
||||||
const event = new CustomEvent("updateFilePreview", {
|
const event = new CustomEvent("updateFilePreview", {
|
||||||
detail: { url, filename },
|
detail: { url, filename },
|
||||||
});
|
});
|
||||||
|
@ -1692,15 +1790,17 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
// Close file preview
|
// Close file preview
|
||||||
window.closeFilePreview = function () {
|
window.closeFilePreview = function () {
|
||||||
// console.log("closeFilePreview called");
|
console.log("closeFilePreview called");
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById(
|
||||||
"filePreviewModal",
|
"filePreviewModal"
|
||||||
) as HTMLDialogElement;
|
) as HTMLDialogElement;
|
||||||
const filePreview = document.getElementById("officerFilePreview") as any;
|
const filePreview = document.getElementById(
|
||||||
|
"officerFilePreview"
|
||||||
|
) as any;
|
||||||
const previewFileName = document.getElementById("previewFileName");
|
const previewFileName = document.getElementById("previewFileName");
|
||||||
|
|
||||||
if (modal && filePreview && previewFileName) {
|
if (modal && filePreview && previewFileName) {
|
||||||
// console.log("Resetting preview and closing modal");
|
console.log("Resetting preview and closing modal");
|
||||||
// Reset the preview
|
// Reset the preview
|
||||||
const event = new CustomEvent("updateFilePreview", {
|
const event = new CustomEvent("updateFilePreview", {
|
||||||
detail: { url: "", filename: "" },
|
detail: { url: "", filename: "" },
|
||||||
|
@ -1714,7 +1814,7 @@ const currentPage = eventResponse.page;
|
||||||
// Close event details modal
|
// Close event details modal
|
||||||
window.closeEventDetailsModal = function () {
|
window.closeEventDetailsModal = function () {
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById(
|
||||||
"eventDetailsModal",
|
"eventDetailsModal"
|
||||||
) as HTMLDialogElement;
|
) as HTMLDialogElement;
|
||||||
const filesContent = document.getElementById("filesContent");
|
const filesContent = document.getElementById("filesContent");
|
||||||
const attendeesContent = document.getElementById("attendeesContent");
|
const attendeesContent = document.getElementById("attendeesContent");
|
||||||
|
@ -1735,7 +1835,11 @@ const currentPage = eventResponse.page;
|
||||||
function updateFilePreviewButtons(files: string[], eventId: string) {
|
function updateFilePreviewButtons(files: string[], eventId: string) {
|
||||||
return files
|
return files
|
||||||
.map((filename) => {
|
.map((filename) => {
|
||||||
const fileUrl = fileManager.getFileUrl("events", eventId, filename);
|
const fileUrl = fileManager.getFileUrl(
|
||||||
|
"events",
|
||||||
|
eventId,
|
||||||
|
filename
|
||||||
|
);
|
||||||
const previewData = JSON.stringify({
|
const previewData = JSON.stringify({
|
||||||
url: fileUrl,
|
url: fileUrl,
|
||||||
name: filename,
|
name: filename,
|
||||||
|
@ -1780,10 +1884,12 @@ const currentPage = eventResponse.page;
|
||||||
if (newFiles && fileInput.files) {
|
if (newFiles && fileInput.files) {
|
||||||
// Get existing files if any
|
// Get existing files if any
|
||||||
const existingFiles = newFiles.querySelectorAll(".file-item");
|
const existingFiles = newFiles.querySelectorAll(".file-item");
|
||||||
const existingFilesArray = Array.from(existingFiles).map((item) => {
|
const existingFilesArray = Array.from(existingFiles).map(
|
||||||
|
(item) => {
|
||||||
const nameSpan = item.querySelector(".file-name");
|
const nameSpan = item.querySelector(".file-name");
|
||||||
return nameSpan ? nameSpan.textContent : "";
|
return nameSpan ? nameSpan.textContent : "";
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Store new files in the storage and update UI
|
// Store new files in the storage and update UI
|
||||||
Array.from(fileInput.files)
|
Array.from(fileInput.files)
|
||||||
|
@ -1830,11 +1936,12 @@ const currentPage = eventResponse.page;
|
||||||
const currentFiles = document.getElementById("currentFiles");
|
const currentFiles = document.getElementById("currentFiles");
|
||||||
if (currentFiles) {
|
if (currentFiles) {
|
||||||
const fileElement = currentFiles.querySelector(
|
const fileElement = currentFiles.querySelector(
|
||||||
`[data-filename="${filename}"]`,
|
`[data-filename="${filename}"]`
|
||||||
);
|
);
|
||||||
if (fileElement) {
|
if (fileElement) {
|
||||||
fileElement.classList.add("opacity-50");
|
fileElement.classList.add("opacity-50");
|
||||||
const deleteButton = fileElement.querySelector(".text-error");
|
const deleteButton =
|
||||||
|
fileElement.querySelector(".text-error");
|
||||||
if (deleteButton) {
|
if (deleteButton) {
|
||||||
deleteButton.innerHTML = `
|
deleteButton.innerHTML = `
|
||||||
<button type="button" class="btn btn-ghost btn-xs" onclick="window.undoDeleteFile('${eventId}', '${filename}')">
|
<button type="button" class="btn btn-ghost btn-xs" onclick="window.undoDeleteFile('${eventId}', '${filename}')">
|
||||||
|
@ -1850,7 +1957,7 @@ const currentPage = eventResponse.page;
|
||||||
`File "${filename}" marked for deletion. Save changes to confirm.`,
|
`File "${filename}" marked for deletion. Save changes to confirm.`,
|
||||||
{
|
{
|
||||||
icon: "🗑️",
|
icon: "🗑️",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to stage file deletion:", error);
|
console.error("Failed to stage file deletion:", error);
|
||||||
|
@ -1866,7 +1973,7 @@ const currentPage = eventResponse.page;
|
||||||
const currentFiles = document.getElementById("currentFiles");
|
const currentFiles = document.getElementById("currentFiles");
|
||||||
if (currentFiles) {
|
if (currentFiles) {
|
||||||
const fileElement = currentFiles.querySelector(
|
const fileElement = currentFiles.querySelector(
|
||||||
`[data-filename="${filename}"]`,
|
`[data-filename="${filename}"]`
|
||||||
);
|
);
|
||||||
if (fileElement) {
|
if (fileElement) {
|
||||||
fileElement.classList.remove("opacity-50");
|
fileElement.classList.remove("opacity-50");
|
||||||
|
@ -1895,7 +2002,7 @@ const currentPage = eventResponse.page;
|
||||||
// Universal file preview function for officer section
|
// Universal file preview function for officer section
|
||||||
window.previewFileOfficer = function (url: string, filename: string) {
|
window.previewFileOfficer = function (url: string, filename: string) {
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById(
|
||||||
"filePreviewModal",
|
"filePreviewModal"
|
||||||
) as HTMLDialogElement;
|
) as HTMLDialogElement;
|
||||||
|
|
||||||
if (!modal) {
|
if (!modal) {
|
||||||
|
@ -1907,7 +2014,7 @@ const currentPage = eventResponse.page;
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(FILE_PREVIEW_STATE_CHANGE, {
|
new CustomEvent(FILE_PREVIEW_STATE_CHANGE, {
|
||||||
detail: { url, filename },
|
detail: { url, filename },
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show modal after event dispatch
|
// Show modal after event dispatch
|
||||||
|
@ -1919,7 +2026,7 @@ const currentPage = eventResponse.page;
|
||||||
// Close file preview for officer section
|
// Close file preview for officer section
|
||||||
window.closeFilePreviewOfficer = function () {
|
window.closeFilePreviewOfficer = function () {
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById(
|
||||||
"filePreviewModal",
|
"filePreviewModal"
|
||||||
) as HTMLDialogElement;
|
) as HTMLDialogElement;
|
||||||
const previewContent = document.getElementById("previewContent");
|
const previewContent = document.getElementById("previewContent");
|
||||||
|
|
||||||
|
@ -1942,7 +2049,7 @@ const currentPage = eventResponse.page;
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(FILE_PREVIEW_STATE_CHANGE, {
|
new CustomEvent(FILE_PREVIEW_STATE_CHANGE, {
|
||||||
detail: { url: "", filename: "" },
|
detail: { url: "", filename: "" },
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Close modal after cleanup
|
// Close modal after cleanup
|
||||||
|
@ -1976,14 +2083,18 @@ const currentPage = eventResponse.page;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get fresh URL from FileManager to ensure we have the latest
|
// Get fresh URL from FileManager to ensure we have the latest
|
||||||
const freshUrl = fileManager.getFileUrl("events", eventId, file.name);
|
const freshUrl = fileManager.getFileUrl(
|
||||||
|
"events",
|
||||||
|
eventId,
|
||||||
|
file.name
|
||||||
|
);
|
||||||
|
|
||||||
// Show the preview with fresh URL
|
// Show the preview with fresh URL
|
||||||
window.previewFileOfficer(freshUrl, file.name);
|
window.previewFileOfficer(freshUrl, file.name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch fresh file data:", error);
|
console.error("Failed to fetch fresh file data:", error);
|
||||||
toast.error(
|
toast.error(
|
||||||
"Failed to load file preview. The file may have been deleted or modified.",
|
"Failed to load file preview. The file may have been deleted or modified."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1993,10 +2104,10 @@ const currentPage = eventResponse.page;
|
||||||
|
|
||||||
// Add openAttendeesModal function
|
// Add openAttendeesModal function
|
||||||
window.openAttendeesModal = function (event: Event) {
|
window.openAttendeesModal = function (event: Event) {
|
||||||
// console.log("Opening attendees modal for event:", event.id);
|
console.log("Opening attendees modal for event:", event.id);
|
||||||
|
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById(
|
||||||
"attendeesModal",
|
"attendeesModal"
|
||||||
) as HTMLDialogElement;
|
) as HTMLDialogElement;
|
||||||
const modalTitle = document.getElementById("attendeesModalTitle");
|
const modalTitle = document.getElementById("attendeesModalTitle");
|
||||||
|
|
||||||
|
@ -2015,7 +2126,7 @@ const currentPage = eventResponse.page;
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventName: event.event_name,
|
eventName: event.event_name,
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
|
@ -2025,7 +2136,7 @@ const currentPage = eventResponse.page;
|
||||||
// Add event listeners when the document loads
|
// Add event listeners when the document loads
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById(
|
||||||
"filePreviewModal",
|
"filePreviewModal"
|
||||||
) as HTMLDialogElement;
|
) as HTMLDialogElement;
|
||||||
if (modal) {
|
if (modal) {
|
||||||
// Handle modal close via backdrop
|
// Handle modal close via backdrop
|
||||||
|
|
|
@ -287,7 +287,7 @@ export default function Attendees() {
|
||||||
const fetchEventData = async () => {
|
const fetchEventData = async () => {
|
||||||
if (!eventId || !auth.isAuthenticated()) {
|
if (!eventId || !auth.isAuthenticated()) {
|
||||||
if (!auth.isAuthenticated()) {
|
if (!auth.isAuthenticated()) {
|
||||||
// console.log('User not authenticated');
|
console.log('User not authenticated');
|
||||||
setError('Authentication required');
|
setError('Authentication required');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||||
import { Update } from "../../../scripts/pocketbase/Update";
|
import { Update } from "../../../scripts/pocketbase/Update";
|
||||||
import { FileManager } from "../../../scripts/pocketbase/FileManager";
|
import { FileManager } from "../../../scripts/pocketbase/FileManager";
|
||||||
import { SendLog } from "../../../scripts/pocketbase/SendLog";
|
import { SendLog } from "../../../scripts/pocketbase/SendLog";
|
||||||
import { Realtime } from "../../../scripts/pocketbase/Realtime";
|
|
||||||
import FilePreview from "../universal/FilePreview";
|
import FilePreview from "../universal/FilePreview";
|
||||||
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase";
|
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase";
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
|
@ -133,28 +132,6 @@ const EventForm = memo(({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event Type */}
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label">
|
|
||||||
<span className="label-text">Event Type</span>
|
|
||||||
<span className="label-text-alt text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="editEventType"
|
|
||||||
className="select select-bordered"
|
|
||||||
value={event?.event_type || "other"}
|
|
||||||
onChange={(e) => handleChange('event_type', e.target.value)}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="social">Social</option>
|
|
||||||
<option value="technical">Technical</option>
|
|
||||||
<option value="outreach">Outreach</option>
|
|
||||||
<option value="professional">Professional</option>
|
|
||||||
<option value="workshop">Projects</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Points to Reward */}
|
{/* Points to Reward */}
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
|
@ -165,7 +142,7 @@ const EventForm = memo(({
|
||||||
type="number"
|
type="number"
|
||||||
name="editEventPoints"
|
name="editEventPoints"
|
||||||
className="input input-bordered"
|
className="input input-bordered"
|
||||||
value={event?.points_to_reward || ""}
|
value={event?.points_to_reward || 0}
|
||||||
onChange={(e) => handleChange('points_to_reward', Number(e.target.value))}
|
onChange={(e) => handleChange('points_to_reward', Number(e.target.value))}
|
||||||
min="0"
|
min="0"
|
||||||
required
|
required
|
||||||
|
@ -263,15 +240,7 @@ const EventForm = memo(({
|
||||||
// Show error for rejected files
|
// Show error for rejected files
|
||||||
if (rejectedFiles.length > 0) {
|
if (rejectedFiles.length > 0) {
|
||||||
const errorMessage = `The following files were not added:\n${rejectedFiles.map(f => `${f.name}: ${f.reason}`).join('\n')}`;
|
const errorMessage = `The following files were not added:\n${rejectedFiles.map(f => `${f.name}: ${f.reason}`).join('\n')}`;
|
||||||
// Use toast with custom styling to ensure visibility above modal
|
toast.error(errorMessage);
|
||||||
toast.error(errorMessage, {
|
|
||||||
duration: 5000,
|
|
||||||
style: {
|
|
||||||
zIndex: 9999, // Ensure it's above the modal
|
|
||||||
maxWidth: '500px',
|
|
||||||
whiteSpace: 'pre-line' // Preserve line breaks
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedFiles(newFiles);
|
setSelectedFiles(newFiles);
|
||||||
|
@ -324,31 +293,6 @@ const EventForm = memo(({
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:eye" className="h-4 w-4" />
|
<Icon icon="heroicons:eye" className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-xs"
|
|
||||||
onClick={async () => {
|
|
||||||
if (event?.id) {
|
|
||||||
try {
|
|
||||||
// Get file URL with token for protected files
|
|
||||||
const url = await fileManager.getFileUrlWithToken(
|
|
||||||
"events",
|
|
||||||
event.id,
|
|
||||||
filename,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
// Open file in new tab
|
|
||||||
window.open(url, '_blank');
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to open file:", error);
|
|
||||||
toast.error("Failed to open file. Please try again.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="heroicons:arrow-top-right-on-square" className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<div className="text-error">
|
<div className="text-error">
|
||||||
{filesToDelete.has(filename) ? (
|
{filesToDelete.has(filename) ? (
|
||||||
<button
|
<button
|
||||||
|
@ -457,7 +401,6 @@ interface EventChanges {
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
has_food?: boolean;
|
has_food?: boolean;
|
||||||
event_type?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileChanges {
|
interface FileChanges {
|
||||||
|
@ -544,8 +487,7 @@ class ChangeTracker {
|
||||||
'start_date',
|
'start_date',
|
||||||
'end_date',
|
'end_date',
|
||||||
'published',
|
'published',
|
||||||
'has_food',
|
'has_food'
|
||||||
'event_type'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
|
@ -608,12 +550,11 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
event_code: "",
|
event_code: "",
|
||||||
location: "",
|
location: "",
|
||||||
files: [],
|
files: [],
|
||||||
points_to_reward: null as unknown as number,
|
points_to_reward: 0,
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
published: false,
|
published: false,
|
||||||
has_food: false,
|
has_food: false
|
||||||
event_type: "other"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [previewUrl, setPreviewUrl] = useState("");
|
const [previewUrl, setPreviewUrl] = useState("");
|
||||||
|
@ -630,8 +571,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
auth: Authentication.getInstance(),
|
auth: Authentication.getInstance(),
|
||||||
update: Update.getInstance(),
|
update: Update.getInstance(),
|
||||||
fileManager: FileManager.getInstance(),
|
fileManager: FileManager.getInstance(),
|
||||||
sendLog: SendLog.getInstance(),
|
sendLog: SendLog.getInstance()
|
||||||
realtime: Realtime.getInstance()
|
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
// Handle field changes
|
// Handle field changes
|
||||||
|
@ -650,35 +590,17 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
const initializeEventData = useCallback(async (eventId: string) => {
|
const initializeEventData = useCallback(async (eventId: string) => {
|
||||||
try {
|
try {
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
// Show loading state
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
// Clear cache to ensure fresh data
|
// Clear cache to ensure fresh data
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
await dataSync.clearCache();
|
await dataSync.clearCache();
|
||||||
|
|
||||||
// Fetch fresh event data with expanded relations if needed
|
// Fetch fresh event data
|
||||||
const eventData = await services.get.getOne<Event>(
|
const eventData = await services.get.getOne<Event>(Collections.EVENTS, eventId);
|
||||||
Collections.EVENTS,
|
|
||||||
eventId,
|
|
||||||
{
|
|
||||||
disableAutoCancellation: true,
|
|
||||||
// Add any fields to expand if needed
|
|
||||||
// expand: ['related_field1', 'related_field2']
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!eventData) {
|
if (!eventData) {
|
||||||
throw new Error("Event not found");
|
throw new Error("Event not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log successful data fetch
|
|
||||||
await services.sendLog.send(
|
|
||||||
"view",
|
|
||||||
"event",
|
|
||||||
`Loaded event data: ${eventData.event_name} (${eventId})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ensure dates are properly formatted for datetime-local input
|
// Ensure dates are properly formatted for datetime-local input
|
||||||
if (eventData.start_date) {
|
if (eventData.start_date) {
|
||||||
// Convert to Date object first to ensure proper formatting
|
// Convert to Date object first to ensure proper formatting
|
||||||
|
@ -702,44 +624,15 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
event_code: eventData.event_code || '',
|
event_code: eventData.event_code || '',
|
||||||
location: eventData.location || '',
|
location: eventData.location || '',
|
||||||
files: eventData.files || [],
|
files: eventData.files || [],
|
||||||
points_to_reward: eventData.points_to_reward || null as unknown as number,
|
points_to_reward: eventData.points_to_reward || 0,
|
||||||
start_date: eventData.start_date || '',
|
start_date: eventData.start_date || '',
|
||||||
end_date: eventData.end_date || '',
|
end_date: eventData.end_date || '',
|
||||||
published: eventData.published || false,
|
published: eventData.published || false,
|
||||||
has_food: eventData.has_food || false,
|
has_food: eventData.has_food || false
|
||||||
event_type: eventData.event_type || 'other'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up realtime subscription for this event
|
console.log("Event data loaded successfully:", eventData);
|
||||||
const realtime = services.realtime;
|
|
||||||
|
|
||||||
// Define the RealtimeEvent type for proper typing
|
|
||||||
interface RealtimeEvent<T> {
|
|
||||||
action: "create" | "update" | "delete";
|
|
||||||
record: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionId = realtime.subscribeToRecord<RealtimeEvent<Event>>(
|
|
||||||
Collections.EVENTS,
|
|
||||||
eventId,
|
|
||||||
(data) => {
|
|
||||||
if (data.action === "update") {
|
|
||||||
// Auto-refresh data when event is updated elsewhere
|
|
||||||
initializeEventData(eventId);
|
|
||||||
toast.success("Event data has been updated");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store subscription ID for cleanup
|
|
||||||
(window as any).eventSubscriptionId = subscriptionId;
|
|
||||||
|
|
||||||
// console.log("Event data loaded successfully:", eventData);
|
|
||||||
} else {
|
} else {
|
||||||
// Creating a new event
|
|
||||||
const now = new Date();
|
|
||||||
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
|
|
||||||
|
|
||||||
setEvent({
|
setEvent({
|
||||||
id: '',
|
id: '',
|
||||||
created: '',
|
created: '',
|
||||||
|
@ -749,12 +642,11 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
event_code: '',
|
event_code: '',
|
||||||
location: '',
|
location: '',
|
||||||
files: [],
|
files: [],
|
||||||
points_to_reward: null as unknown as number,
|
points_to_reward: 0,
|
||||||
start_date: Get.formatLocalDate(now, false),
|
start_date: '',
|
||||||
end_date: Get.formatLocalDate(oneHourLater, false),
|
end_date: '',
|
||||||
published: false,
|
published: false,
|
||||||
has_food: false,
|
has_food: false
|
||||||
event_type: "other"
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setSelectedFiles(new Map());
|
setSelectedFiles(new Map());
|
||||||
|
@ -764,10 +656,8 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to initialize event data:", error);
|
console.error("Failed to initialize event data:", error);
|
||||||
toast.error("Failed to load event data. Please try again.");
|
toast.error("Failed to load event data. Please try again.");
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
}
|
||||||
}, [services]);
|
}, [services.get]);
|
||||||
|
|
||||||
// Expose initializeEventData to window
|
// Expose initializeEventData to window
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -803,17 +693,11 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleModalClose = useCallback(() => {
|
const handleModalClose = useCallback(() => {
|
||||||
if (hasUnsavedChanges && !isSubmitting) {
|
if (hasUnsavedChanges) {
|
||||||
const confirmed = window.confirm('You have unsaved changes. Are you sure you want to close?');
|
const confirmed = window.confirm('You have unsaved changes. Are you sure you want to close?');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up realtime subscription if it exists
|
|
||||||
if ((window as any).eventSubscriptionId) {
|
|
||||||
services.realtime.unsubscribe((window as any).eventSubscriptionId);
|
|
||||||
delete (window as any).eventSubscriptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvent({
|
setEvent({
|
||||||
id: "",
|
id: "",
|
||||||
created: "",
|
created: "",
|
||||||
|
@ -823,12 +707,11 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
event_code: "",
|
event_code: "",
|
||||||
location: "",
|
location: "",
|
||||||
files: [],
|
files: [],
|
||||||
points_to_reward: null as unknown as number,
|
points_to_reward: 0,
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
published: false,
|
published: false,
|
||||||
has_food: false,
|
has_food: false
|
||||||
event_type: "other"
|
|
||||||
});
|
});
|
||||||
setSelectedFiles(new Map());
|
setSelectedFiles(new Map());
|
||||||
setFilesToDelete(new Set());
|
setFilesToDelete(new Set());
|
||||||
|
@ -836,55 +719,9 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
setPreviewUrl("");
|
setPreviewUrl("");
|
||||||
setPreviewFilename("");
|
setPreviewFilename("");
|
||||||
|
|
||||||
// Clear file input element to reset filename display
|
|
||||||
const fileInput = document.querySelector('input[name="editEventFiles"]') as HTMLInputElement;
|
|
||||||
if (fileInput) {
|
|
||||||
fileInput.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
|
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
|
||||||
if (modal) modal.close();
|
if (modal) modal.close();
|
||||||
}, [hasUnsavedChanges, isSubmitting, services.realtime]);
|
}, [hasUnsavedChanges]);
|
||||||
|
|
||||||
// Function to close modal after saving (without confirmation)
|
|
||||||
const closeModalAfterSave = useCallback(() => {
|
|
||||||
// Clean up realtime subscription if it exists
|
|
||||||
if ((window as any).eventSubscriptionId) {
|
|
||||||
services.realtime.unsubscribe((window as any).eventSubscriptionId);
|
|
||||||
delete (window as any).eventSubscriptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvent({
|
|
||||||
id: "",
|
|
||||||
created: "",
|
|
||||||
updated: "",
|
|
||||||
event_name: "",
|
|
||||||
event_description: "",
|
|
||||||
event_code: "",
|
|
||||||
location: "",
|
|
||||||
files: [],
|
|
||||||
points_to_reward: null as unknown as number,
|
|
||||||
start_date: "",
|
|
||||||
end_date: "",
|
|
||||||
published: false,
|
|
||||||
has_food: false,
|
|
||||||
event_type: "other"
|
|
||||||
});
|
|
||||||
setSelectedFiles(new Map());
|
|
||||||
setFilesToDelete(new Set());
|
|
||||||
setShowPreview(false);
|
|
||||||
setPreviewUrl("");
|
|
||||||
setPreviewFilename("");
|
|
||||||
|
|
||||||
// Reset the file input element to clear the filename display
|
|
||||||
const fileInput = document.querySelector('input[name="editEventFiles"]') as HTMLInputElement;
|
|
||||||
if (fileInput) {
|
|
||||||
fileInput.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.getElementById("editEventModal") as HTMLDialogElement;
|
|
||||||
if (modal) modal.close();
|
|
||||||
}, [services.realtime]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -914,12 +751,11 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
event_code: formData.get("editEventCode") as string,
|
event_code: formData.get("editEventCode") as string,
|
||||||
location: formData.get("editEventLocation") as string,
|
location: formData.get("editEventLocation") as string,
|
||||||
files: event.files || [],
|
files: event.files || [],
|
||||||
points_to_reward: formData.get("editEventPoints") ? parseInt(formData.get("editEventPoints") as string) : null as unknown as number,
|
points_to_reward: parseInt(formData.get("editEventPoints") as string) || 0,
|
||||||
start_date: formData.get("editEventStartDate") as string,
|
start_date: formData.get("editEventStartDate") as string,
|
||||||
end_date: formData.get("editEventEndDate") as string,
|
end_date: formData.get("editEventEndDate") as string,
|
||||||
published: formData.get("editEventPublished") === "on",
|
published: formData.get("editEventPublished") === "on",
|
||||||
has_food: formData.get("editEventHasFood") === "on",
|
has_food: formData.get("editEventHasFood") === "on"
|
||||||
event_type: formData.get("editEventType") as string || "other"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log the update attempt
|
// Log the update attempt
|
||||||
|
@ -948,7 +784,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
|
|
||||||
// 1. Remove files marked for deletion
|
// 1. Remove files marked for deletion
|
||||||
if (filesToDelete.size > 0) {
|
if (filesToDelete.size > 0) {
|
||||||
// console.log(`Removing ${filesToDelete.size} files from event ${event.id}`);
|
console.log(`Removing ${filesToDelete.size} files from event ${event.id}`);
|
||||||
currentFiles = currentFiles.filter(file => !filesToDelete.has(file));
|
currentFiles = currentFiles.filter(file => !filesToDelete.has(file));
|
||||||
|
|
||||||
// Update the files field first to remove deleted files
|
// Update the files field first to remove deleted files
|
||||||
|
@ -961,7 +797,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
|
|
||||||
// 2. Add new files one by one to preserve existing ones
|
// 2. Add new files one by one to preserve existing ones
|
||||||
if (selectedFiles.size > 0) {
|
if (selectedFiles.size > 0) {
|
||||||
// console.log(`Adding ${selectedFiles.size} new files to event ${event.id}`);
|
console.log(`Adding ${selectedFiles.size} new files to event ${event.id}`);
|
||||||
|
|
||||||
// Convert Map to array of File objects
|
// Convert Map to array of File objects
|
||||||
const newFiles = Array.from(selectedFiles.values());
|
const newFiles = Array.from(selectedFiles.values());
|
||||||
|
@ -998,11 +834,8 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
// Call the onEventSaved callback if provided
|
// Call the onEventSaved callback if provided
|
||||||
if (onEventSaved) onEventSaved();
|
if (onEventSaved) onEventSaved();
|
||||||
|
|
||||||
// Reset unsaved changes flag before closing
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
|
|
||||||
// Close the modal
|
// Close the modal
|
||||||
closeModalAfterSave();
|
handleModalClose();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// We're creating a new event
|
// We're creating a new event
|
||||||
|
@ -1016,7 +849,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
|
|
||||||
// Then upload files if any
|
// Then upload files if any
|
||||||
if (selectedFiles.size > 0 && newEvent?.id) {
|
if (selectedFiles.size > 0 && newEvent?.id) {
|
||||||
// console.log(`Adding ${selectedFiles.size} files to new event ${newEvent.id}`);
|
console.log(`Adding ${selectedFiles.size} files to new event ${newEvent.id}`);
|
||||||
|
|
||||||
// Convert Map to array of File objects
|
// Convert Map to array of File objects
|
||||||
const newFiles = Array.from(selectedFiles.values());
|
const newFiles = Array.from(selectedFiles.values());
|
||||||
|
@ -1042,11 +875,8 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
// Call the onEventSaved callback if provided
|
// Call the onEventSaved callback if provided
|
||||||
if (onEventSaved) onEventSaved();
|
if (onEventSaved) onEventSaved();
|
||||||
|
|
||||||
// Reset unsaved changes flag before closing
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
|
|
||||||
// Close the modal
|
// Close the modal
|
||||||
closeModalAfterSave();
|
handleModalClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh events list if available
|
// Refresh events list if available
|
||||||
|
@ -1059,7 +889,7 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
window.hideLoading?.();
|
window.hideLoading?.();
|
||||||
}
|
}
|
||||||
}, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting, closeModalAfterSave]);
|
}, [event, selectedFiles, filesToDelete, services, onEventSaved, isSubmitting, handleModalClose]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -5,7 +5,7 @@ import EventRequestForm from "./Officer_EventRequestForm/EventRequestForm";
|
||||||
import UserEventRequests from "./Officer_EventRequestForm/UserEventRequests";
|
import UserEventRequests from "./Officer_EventRequestForm/UserEventRequests";
|
||||||
import { Collections } from "../../schemas/pocketbase/schema";
|
import { Collections } from "../../schemas/pocketbase/schema";
|
||||||
import { DataSyncService } from "../../scripts/database/DataSyncService";
|
import { DataSyncService } from "../../scripts/database/DataSyncService";
|
||||||
import { EventRequestFormPreviewModalWrapper } from "./Officer_EventRequestForm/EventRequestFormPreview";
|
import { EventRequestFormPreviewModal } from "./Officer_EventRequestForm/EventRequestFormPreview";
|
||||||
|
|
||||||
// Import the EventRequest type from UserEventRequests to ensure consistency
|
// Import the EventRequest type from UserEventRequests to ensure consistency
|
||||||
import type { EventRequest as UserEventRequest } from "./Officer_EventRequestForm/UserEventRequests";
|
import type { EventRequest as UserEventRequest } from "./Officer_EventRequestForm/UserEventRequests";
|
||||||
|
@ -31,7 +31,7 @@ if (auth.isAuthenticated()) {
|
||||||
userEventRequests = await get.getAll<EventRequest>(
|
userEventRequests = await get.getAll<EventRequest>(
|
||||||
Collections.EVENT_REQUESTS,
|
Collections.EVENT_REQUESTS,
|
||||||
`requested_user="${userId}"`,
|
`requested_user="${userId}"`,
|
||||||
"-created",
|
"-created"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -46,8 +46,8 @@ if (auth.isAuthenticated()) {
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Event Request Form</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Event Request Form</h1>
|
||||||
<p class="text-gray-300 mb-4">
|
<p class="text-gray-300 mb-4">
|
||||||
Submit your event request at least 6 weeks before your event. After
|
Submit your event request at least 6 weeks before your event. After
|
||||||
submitting, please notify PR and/or Coordinators in the #-events Slack
|
submitting, please notify PR and/or Coordinators in the #-events
|
||||||
channel.
|
Slack channel.
|
||||||
</p>
|
</p>
|
||||||
<div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300">
|
<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>
|
<p class="font-medium mb-2">This form includes sections for:</p>
|
||||||
|
@ -109,79 +109,134 @@ if (auth.isAuthenticated()) {
|
||||||
|
|
||||||
{
|
{
|
||||||
!error && (
|
!error && (
|
||||||
<UserEventRequests client:load eventRequests={userEventRequests} />
|
<UserEventRequests
|
||||||
|
client:load
|
||||||
|
eventRequests={userEventRequests}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The modal will be rendered through the global function and event system -->
|
|
||||||
<EventRequestFormPreviewModalWrapper client:load />
|
|
||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
/* Ensure the modal container is always visible */
|
/* Ensure the modal container is always visible */
|
||||||
#event-request-preview-modal-overlay {
|
#event-request-preview-modal-container {
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
top: 0 !important;
|
top: 0 !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
right: 0 !important;
|
|
||||||
bottom: 0 !important;
|
|
||||||
width: 100vw !important;
|
width: 100vw !important;
|
||||||
height: 100vh !important;
|
height: 100vh !important;
|
||||||
max-width: 100vw !important;
|
z-index: 99999 !important;
|
||||||
max-height: 100vh !important;
|
|
||||||
z-index: 999999 !important;
|
|
||||||
overflow: auto !important;
|
overflow: auto !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style for the modal backdrop */
|
||||||
|
#event-request-preview-modal-container > div > div:first-child {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
z-index: 99999 !important;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||||
|
backdrop-filter: blur(8px) !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
overflow: auto !important;
|
||||||
backdrop-filter: blur(4px) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for the modal content */
|
/* Style for the modal content */
|
||||||
#event-request-preview-modal-overlay > div {
|
#event-request-preview-modal-container > div > div > div {
|
||||||
z-index: 1000000 !important;
|
z-index: 100000 !important;
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
max-width: min(90vw, 1024px) !important;
|
max-width: 90vw !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-height: 90vh !important;
|
max-height: 90vh !important;
|
||||||
overflow: auto !important;
|
overflow: auto !important;
|
||||||
margin: 0 !important;
|
margin: 2rem !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>
|
</style>
|
||||||
|
|
||||||
|
<!-- Add the modal component -->
|
||||||
|
<EventRequestFormPreviewModal client:load />
|
||||||
|
|
||||||
|
<div class="dashboard-section hidden" id="eventRequestFormSection">
|
||||||
|
<!-- ... existing code ... -->
|
||||||
|
</div>
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
// Define the global function immediately to ensure it's available
|
// Define the global function immediately to ensure it's available
|
||||||
window.showEventRequestFormPreview = function (formData) {
|
window.showEventRequestFormPreview = function (formData) {
|
||||||
console.log("showEventRequestFormPreview called with formData:", formData);
|
console.log(
|
||||||
|
"Global showEventRequestFormPreview called with data",
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove any elements that might be obstructing the view
|
||||||
|
const removeObstructions = () => {
|
||||||
|
// Find any elements with high z-index that might be obstructing
|
||||||
|
document.querySelectorAll('[style*="z-index"]').forEach((el) => {
|
||||||
|
if (
|
||||||
|
el.id !== "event-request-preview-modal-container" &&
|
||||||
|
!el.closest("#event-request-preview-modal-container")
|
||||||
|
) {
|
||||||
|
// Store original z-index to restore later
|
||||||
|
if (!el.dataset.originalZIndex) {
|
||||||
|
el.dataset.originalZIndex = el.style.zIndex;
|
||||||
|
}
|
||||||
|
// Temporarily lower z-index
|
||||||
|
el.style.zIndex = "0";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Create a custom event to trigger the preview
|
// Create a custom event to trigger the preview
|
||||||
const event = new CustomEvent("showEventRequestPreviewModal", {
|
const event = new CustomEvent("showEventRequestPreviewModal", {
|
||||||
detail: { formData },
|
detail: { formData },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Dispatching event with detail:", event.detail);
|
// Remove obstructions before showing modal
|
||||||
|
removeObstructions();
|
||||||
|
|
||||||
// Dispatch event to show modal
|
// Dispatch event to show modal
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
|
console.log("showEventRequestPreviewModal event dispatched");
|
||||||
|
|
||||||
// Prevent body scrolling when modal is open
|
// Ensure modal container is visible
|
||||||
document.body.style.overflow = "hidden";
|
setTimeout(() => {
|
||||||
|
const modalContainer = document.getElementById(
|
||||||
|
"event-request-preview-modal-container"
|
||||||
|
);
|
||||||
|
if (modalContainer) {
|
||||||
|
modalContainer.style.zIndex = "99999";
|
||||||
|
modalContainer.style.position = "fixed";
|
||||||
|
modalContainer.style.top = "0";
|
||||||
|
modalContainer.style.left = "0";
|
||||||
|
modalContainer.style.width = "100vw";
|
||||||
|
modalContainer.style.height = "100vh";
|
||||||
|
modalContainer.style.overflow = "auto";
|
||||||
|
modalContainer.style.margin = "0";
|
||||||
|
modalContainer.style.padding = "0";
|
||||||
|
|
||||||
// Add event listener to restore scrolling when modal is closed
|
// Force body to allow scrolling
|
||||||
const handleModalClose = () => {
|
document.body.style.overflow = "auto";
|
||||||
document.body.style.overflow = "";
|
|
||||||
document.removeEventListener("modalClosed", handleModalClose);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("modalClosed", handleModalClose);
|
// Ensure the modal content is properly sized
|
||||||
|
const modalContent =
|
||||||
|
modalContainer.querySelector("div > div > div");
|
||||||
|
if (modalContent) {
|
||||||
|
modalContent.style.maxWidth = "90vw";
|
||||||
|
modalContent.style.width = "100%";
|
||||||
|
modalContent.style.maxHeight = "90vh";
|
||||||
|
modalContent.style.overflow = "auto";
|
||||||
|
modalContent.style.margin = "2rem";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -206,26 +261,30 @@ if (auth.isAuthenticated()) {
|
||||||
await dataSync.syncCollection(
|
await dataSync.syncCollection(
|
||||||
Collections.EVENT_REQUESTS,
|
Collections.EVENT_REQUESTS,
|
||||||
`requested_user="${userId}"`,
|
`requested_user="${userId}"`,
|
||||||
"-created",
|
"-created"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"Initial data sync complete for user event requests"
|
||||||
);
|
);
|
||||||
// console.log("Initial data sync complete for user event requests");
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// console.error("Error during initial data sync:", err);
|
console.error("Error during initial data sync:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formTab = document.getElementById("form-tab");
|
const formTab = document.getElementById("form-tab");
|
||||||
const submissionsTab = document.getElementById("submissions-tab");
|
const submissionsTab = document.getElementById("submissions-tab");
|
||||||
const formContent = document.getElementById("form-content");
|
const formContent = document.getElementById("form-content");
|
||||||
const submissionsContent = document.getElementById("submissions-content");
|
const submissionsContent = document.getElementById(
|
||||||
|
"submissions-content"
|
||||||
|
);
|
||||||
|
|
||||||
// Function to switch tabs
|
// Function to switch tabs
|
||||||
const switchTab = (
|
const switchTab = (
|
||||||
activeTab: HTMLElement,
|
activeTab: HTMLElement,
|
||||||
activeContent: HTMLElement,
|
activeContent: HTMLElement,
|
||||||
inactiveTab: HTMLElement,
|
inactiveTab: HTMLElement,
|
||||||
inactiveContent: HTMLElement,
|
inactiveContent: HTMLElement
|
||||||
) => {
|
) => {
|
||||||
// Update tab classes
|
// Update tab classes
|
||||||
activeTab.classList.add("tab-active");
|
activeTab.classList.add("tab-active");
|
||||||
|
@ -246,14 +305,24 @@ if (auth.isAuthenticated()) {
|
||||||
formTab?.addEventListener("click", (e) => {
|
formTab?.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (formContent && submissionsContent && submissionsTab) {
|
if (formContent && submissionsContent && submissionsTab) {
|
||||||
switchTab(formTab, formContent, submissionsTab, submissionsContent);
|
switchTab(
|
||||||
|
formTab,
|
||||||
|
formContent,
|
||||||
|
submissionsTab,
|
||||||
|
submissionsContent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
submissionsTab?.addEventListener("click", (e) => {
|
submissionsTab?.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (formContent && submissionsContent && formTab) {
|
if (formContent && submissionsContent && formTab) {
|
||||||
switchTab(submissionsTab, submissionsContent, formTab, formContent);
|
switchTab(
|
||||||
|
submissionsTab,
|
||||||
|
submissionsContent,
|
||||||
|
formTab,
|
||||||
|
formContent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -3,476 +3,204 @@ import { motion } from 'framer-motion';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import type { EventRequestFormData } from './EventRequestForm';
|
import type { EventRequestFormData } from './EventRequestForm';
|
||||||
import InvoiceBuilder from './InvoiceBuilder';
|
import InvoiceBuilder from './InvoiceBuilder';
|
||||||
import type { InvoiceData, InvoiceItem } from './InvoiceBuilder';
|
import type { InvoiceData } 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Animation variants
|
||||||
const itemVariants = {
|
const itemVariants = {
|
||||||
hidden: { opacity: 0, y: 10 },
|
hidden: { opacity: 0, y: 20 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
transition: {
|
transition: {
|
||||||
type: "spring",
|
type: "spring",
|
||||||
stiffness: 500,
|
stiffness: 300,
|
||||||
damping: 25,
|
damping: 24
|
||||||
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 {
|
interface ASFundingSectionProps {
|
||||||
formData: EventRequestFormData;
|
formData: EventRequestFormData;
|
||||||
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => {
|
const ASFundingSection: React.FC<ASFundingSectionProps> = ({ formData, onDataChange }) => {
|
||||||
// Check initial budget status
|
const [invoiceFile, setInvoiceFile] = useState<File | null>(formData.invoice);
|
||||||
React.useEffect(() => {
|
|
||||||
if (formData.invoiceData?.total) {
|
|
||||||
checkBudgetLimit(formData.invoiceData.total);
|
|
||||||
}
|
|
||||||
}, [formData.expected_attendance]);
|
|
||||||
const [invoiceFiles, setInvoiceFiles] = useState<File[]>(formData.invoice_files || []);
|
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
|
// Handle single invoice file upload (for backward compatibility)
|
||||||
const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInvoiceFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
const newFiles = Array.from(e.target.files) as File[];
|
const file = e.target.files[0];
|
||||||
// Combine existing files with new files instead of replacing
|
setInvoiceFile(file);
|
||||||
const combinedFiles = [...invoiceFiles, ...newFiles];
|
onDataChange({ invoice: file });
|
||||||
setInvoiceFiles(combinedFiles);
|
|
||||||
onDataChange({ invoice_files: combinedFiles });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle removing individual files
|
// Handle multiple invoice files upload
|
||||||
const handleRemoveFile = (indexToRemove: number) => {
|
const handleMultipleInvoiceFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const updatedFiles = invoiceFiles.filter((_, index) => index !== indexToRemove);
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
const files = Array.from(e.target.files) as File[];
|
||||||
|
|
||||||
|
// Check file sizes
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB limit per file
|
||||||
|
const oversizedFiles = files.filter(file => file.size > MAX_FILE_SIZE);
|
||||||
|
|
||||||
|
if (oversizedFiles.length > 0) {
|
||||||
|
toast.error(`Some files exceed the 10MB size limit: ${oversizedFiles.map(f => f.name).join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state with new files
|
||||||
|
const updatedFiles = [...invoiceFiles, ...files];
|
||||||
setInvoiceFiles(updatedFiles);
|
setInvoiceFiles(updatedFiles);
|
||||||
onDataChange({ invoice_files: updatedFiles });
|
onDataChange({ invoice_files: updatedFiles });
|
||||||
|
|
||||||
|
// Also set the first file as the main invoice file for backward compatibility
|
||||||
|
if (files.length > 0 && !formData.invoice) {
|
||||||
|
setInvoiceFile(files[0]);
|
||||||
|
onDataChange({ invoice: files[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Added ${files.length} file${files.length > 1 ? 's' : ''} successfully`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle clearing all files
|
// Remove an invoice file
|
||||||
const handleClearAllFiles = () => {
|
const handleRemoveInvoiceFile = (index: number) => {
|
||||||
setInvoiceFiles([]);
|
const updatedFiles = [...invoiceFiles];
|
||||||
onDataChange({ invoice_files: [] });
|
const removedFileName = updatedFiles[index].name;
|
||||||
|
updatedFiles.splice(index, 1);
|
||||||
|
setInvoiceFiles(updatedFiles);
|
||||||
|
onDataChange({ invoice_files: updatedFiles });
|
||||||
|
|
||||||
|
// Update the main invoice file if needed
|
||||||
|
if (invoiceFile && invoiceFile.name === removedFileName) {
|
||||||
|
const newMainFile = updatedFiles.length > 0 ? updatedFiles[0] : null;
|
||||||
|
setInvoiceFile(newMainFile);
|
||||||
|
onDataChange({ invoice: newMainFile });
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Removed ${removedFileName}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle JSON input change
|
// Handle invoice data change
|
||||||
const handleJsonInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInvoiceDataChange = (invoiceData: InvoiceData) => {
|
||||||
setJsonInput(e.target.value);
|
// Update the invoiceData in the form
|
||||||
setJsonError('');
|
onDataChange({ invoiceData });
|
||||||
};
|
|
||||||
|
|
||||||
// Show JSON example
|
// For backward compatibility, create a properly formatted JSON string
|
||||||
const showJsonExample = () => {
|
const jsonFormat = {
|
||||||
const example = {
|
items: invoiceData.items.map(item => ({
|
||||||
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,
|
item: item.description,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit_price: item.unitPrice,
|
unit_price: item.unitPrice
|
||||||
amount: item.amount
|
|
||||||
})),
|
})),
|
||||||
subtotal: data.subtotal,
|
tax: invoiceData.taxAmount,
|
||||||
tax: data.taxAmount,
|
tip: invoiceData.tipAmount,
|
||||||
tip: data.tipAmount,
|
total: invoiceData.total,
|
||||||
total: data.total
|
vendor: invoiceData.vendor
|
||||||
}, 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
|
// For backward compatibility, still update the itemized_invoice field
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
// but with a more structured format that's easier to parse if needed
|
||||||
e.preventDefault();
|
const itemizedText = JSON.stringify(jsonFormat, null, 2);
|
||||||
setIsDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
// Update the itemized_invoice field for backward compatibility
|
||||||
e.preventDefault();
|
onDataChange({ itemized_invoice: itemizedText });
|
||||||
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 (
|
return (
|
||||||
<motion.div
|
<div className="space-y-6">
|
||||||
className="space-y-8"
|
<h2 className="text-2xl font-bold mb-4 text-primary">AS Funding Information</h2>
|
||||||
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}>
|
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||||
<CustomAlert
|
<div className="flex items-start gap-2">
|
||||||
type="info"
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-warning flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
title="AS Funding Information"
|
<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" />
|
||||||
message="AS funding can cover food and other expenses for your event. Please itemize all expenses in the invoice builder below."
|
</svg>
|
||||||
className="mb-6"
|
<p className="text-sm">
|
||||||
icon="heroicons:information-circle"
|
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.
|
||||||
/>
|
</p>
|
||||||
</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>
|
||||||
<div className="max-h-32 overflow-y-auto text-left w-full space-y-1">
|
</div>
|
||||||
|
|
||||||
|
{/* Invoice Builder Instructions */}
|
||||||
|
<motion.div variants={itemVariants} className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||||
|
<h3 className="font-bold text-lg mb-2">How to Use the Invoice Builder</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-sm">
|
||||||
|
<li>Enter the vendor/restaurant name in the field provided.</li>
|
||||||
|
<li>Add each item from your invoice by filling in the description, quantity, and unit price, then click "Add Item".</li>
|
||||||
|
<li>The subtotal, tax, and tip will be calculated automatically based on the tax rate and tip percentage.</li>
|
||||||
|
<li>You can adjust the tax rate (default is 7.75% for San Diego) and tip percentage as needed.</li>
|
||||||
|
<li>Remove items by clicking the "X" button next to each item.</li>
|
||||||
|
<li>Upload your actual invoice file (receipt, screenshot, etc.) using the file upload below.</li>
|
||||||
|
</ol>
|
||||||
|
<p className="text-sm mt-3 text-warning">Note: The invoice builder helps you itemize your expenses for AS funding. You must still upload the actual invoice file.</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Invoice Builder */}
|
||||||
|
<InvoiceBuilder
|
||||||
|
invoiceData={formData.invoiceData}
|
||||||
|
onChange={handleInvoiceDataChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Invoice file upload */}
|
||||||
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-medium">
|
||||||
|
Upload your invoice files (receipts, screenshots, etc.)
|
||||||
|
</span>
|
||||||
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
|
||||||
|
onChange={handleMultipleInvoiceFilesChange}
|
||||||
|
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
|
||||||
|
multiple
|
||||||
|
required={invoiceFiles.length === 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{invoiceFiles.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm font-medium mb-2">Uploaded files:</p>
|
||||||
|
<div className="space-y-2">
|
||||||
{invoiceFiles.map((file, index) => (
|
{invoiceFiles.map((file, index) => (
|
||||||
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
|
<div key={index} className="flex items-center justify-between bg-base-300/30 p-2 rounded">
|
||||||
<span className="text-sm truncate flex-1">{file.name}</span>
|
<span className="text-sm truncate max-w-[80%]">{file.name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
className="btn btn-ghost btn-xs"
|
||||||
e.stopPropagation();
|
onClick={() => handleRemoveInvoiceFile(index)}
|
||||||
handleRemoveFile(index);
|
|
||||||
}}
|
|
||||||
className="btn btn-xs btn-error ml-2"
|
|
||||||
title="Remove file"
|
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:x-mark" className="w-3 h-3" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
</motion.div>
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
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)
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* JSON/Builder Toggle */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
className="bg-base-200/50 p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300"
|
className="alert alert-warning"
|
||||||
whileHover={{ y: -2 }}
|
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div>
|
||||||
<h3 className="text-xl font-bold text-primary">Invoice Details</h3>
|
<h3 className="font-bold">Important Note</h3>
|
||||||
|
<div className="text-sm">
|
||||||
<div className="flex mb-4 border rounded-lg overflow-hidden">
|
AS Funding requests must be submitted at least 6 weeks before your event. Please check the Funding Guide or the Google Calendar for the funding request deadlines.
|
||||||
<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>
|
||||||
</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>
|
</motion.div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -2,20 +2,8 @@ import React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import type { EventRequestFormData } from './EventRequestForm';
|
import type { EventRequestFormData } from './EventRequestForm';
|
||||||
import type { EventRequest } from '../../../schemas/pocketbase';
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Animation variants
|
||||||
const itemVariants = {
|
const itemVariants = {
|
||||||
hidden: { opacity: 0, y: 20 },
|
hidden: { opacity: 0, y: 20 },
|
||||||
visible: {
|
visible: {
|
||||||
|
@ -29,15 +17,6 @@ const itemVariants = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 {
|
interface EventDetailsSectionProps {
|
||||||
formData: EventRequestFormData;
|
formData: EventRequestFormData;
|
||||||
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
||||||
|
@ -45,232 +24,101 @@ interface EventDetailsSectionProps {
|
||||||
|
|
||||||
const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onDataChange }) => {
|
const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onDataChange }) => {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="space-y-6">
|
||||||
className="space-y-8"
|
<h2 className="text-2xl font-bold mb-4 text-primary">Event Details</h2>
|
||||||
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}>
|
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||||
<CustomAlert
|
<p className="text-sm">
|
||||||
type="info"
|
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.
|
||||||
title="Coordinator Notification"
|
</p>
|
||||||
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."
|
</div>
|
||||||
className="mb-6"
|
|
||||||
icon="heroicons:information-circle"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Event Name */}
|
{/* Event Name */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Event Name</span>
|
<span className="label-text font-medium text-lg">Event Name</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<motion.input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
|
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => onDataChange({ name: e.target.value })}
|
onChange={(e) => onDataChange({ name: e.target.value })}
|
||||||
placeholder="Enter event name"
|
placeholder="Enter event name"
|
||||||
required
|
required
|
||||||
whileHover="hover"
|
|
||||||
variants={inputHoverVariants}
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Event Description */}
|
{/* Event Description */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Event Description</span>
|
<span className="label-text font-medium text-lg">Event Description</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<motion.textarea
|
<textarea
|
||||||
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px] mt-2"
|
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px] mt-2"
|
||||||
value={formData.event_description}
|
value={formData.event_description}
|
||||||
onChange={(e) => onDataChange({ event_description: e.target.value })}
|
onChange={(e) => onDataChange({ event_description: e.target.value })}
|
||||||
placeholder="Provide a detailed description of your event"
|
placeholder="Provide a detailed description of your event"
|
||||||
rows={4}
|
rows={4}
|
||||||
required
|
required
|
||||||
whileHover="hover"
|
|
||||||
variants={inputHoverVariants}
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Date and Time Section */}
|
|
||||||
<motion.div
|
|
||||||
variants={itemVariants}
|
|
||||||
className="grid grid-cols-1 gap-6"
|
|
||||||
>
|
|
||||||
{/* Event Start Date */}
|
{/* Event Start Date */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Event Start Date & Time</span>
|
<span className="label-text font-medium">Event Start Date & Time</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<motion.input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
value={formData.start_date_time}
|
value={formData.start_date_time}
|
||||||
onChange={(e) => {
|
onChange={(e) => onDataChange({ start_date_time: e.target.value })}
|
||||||
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
|
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>
|
</motion.div>
|
||||||
|
|
||||||
{/* Event End Time */}
|
{/* Event End Date */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Event End Time</span>
|
<span className="label-text font-medium">Event End Date & Time</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-col gap-2">
|
<input
|
||||||
<motion.input
|
type="datetime-local"
|
||||||
type="time"
|
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
value={formData.end_date_time ? (() => {
|
value={formData.end_date_time}
|
||||||
try {
|
onChange={(e) => onDataChange({ end_date_time: e.target.value })}
|
||||||
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
|
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>
|
</motion.div>
|
||||||
|
|
||||||
{/* Event Location */}
|
{/* Event Location */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Event Location</span>
|
<span className="label-text font-medium">Event Location</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<motion.input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
value={formData.location}
|
value={formData.location}
|
||||||
onChange={(e) => onDataChange({ location: e.target.value })}
|
onChange={(e) => onDataChange({ location: e.target.value })}
|
||||||
placeholder="Enter event location"
|
placeholder="Enter event location"
|
||||||
required
|
required
|
||||||
whileHover="hover"
|
|
||||||
variants={inputHoverVariants}
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Room Booking */}
|
{/* Room Booking */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Room Booking Status</span>
|
<span className="label-text font-medium">Do you/will you have a room booking for this event?</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-6 mt-2">
|
<div className="flex gap-4">
|
||||||
<motion.label
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
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
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
className="radio radio-primary"
|
className="radio radio-primary"
|
||||||
|
@ -278,42 +126,21 @@ const EventDetailsSection: React.FC<EventDetailsSectionProps> = ({ formData, onD
|
||||||
onChange={() => onDataChange({ will_or_have_room_booking: true })}
|
onChange={() => onDataChange({ will_or_have_room_booking: true })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">Yes, I have a room booking</span>
|
<span>Yes</span>
|
||||||
</motion.label>
|
</label>
|
||||||
<motion.label
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
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
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
className="radio radio-primary"
|
className="radio radio-primary"
|
||||||
checked={formData.will_or_have_room_booking === false}
|
checked={formData.will_or_have_room_booking === false}
|
||||||
onChange={() => {
|
onChange={() => onDataChange({ will_or_have_room_booking: false })}
|
||||||
onDataChange({ will_or_have_room_booking: false });
|
|
||||||
}}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">No, I don't need a booking</span>
|
<span>No</span>
|
||||||
</motion.label>
|
</label>
|
||||||
</div>
|
</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>
|
</motion.div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -7,14 +7,13 @@ import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
import { EventRequestStatus } from '../../../schemas/pocketbase';
|
import { EventRequestStatus } from '../../../schemas/pocketbase';
|
||||||
import { EmailClient } from '../../../scripts/email/EmailClient';
|
|
||||||
|
|
||||||
// Form sections
|
// Form sections
|
||||||
import PRSection from './PRSection';
|
import PRSection from './PRSection';
|
||||||
import EventDetailsSection from './EventDetailsSection';
|
import EventDetailsSection from './EventDetailsSection';
|
||||||
import TAPFormSection from './TAPFormSection';
|
import TAPFormSection from './TAPFormSection';
|
||||||
import ASFundingSection from './ASFundingSection';
|
import ASFundingSection from './ASFundingSection';
|
||||||
import { EventRequestFormPreview } from './EventRequestFormPreview';
|
import EventRequestFormPreview from './EventRequestFormPreview';
|
||||||
import type { InvoiceData, InvoiceItem } from './InvoiceBuilder';
|
import type { InvoiceData, InvoiceItem } from './InvoiceBuilder';
|
||||||
|
|
||||||
// Animation variants
|
// Animation variants
|
||||||
|
@ -70,25 +69,23 @@ export interface EventRequestFormData {
|
||||||
flyer_advertising_start_date: string;
|
flyer_advertising_start_date: string;
|
||||||
flyer_additional_requests: string;
|
flyer_additional_requests: string;
|
||||||
required_logos: string[];
|
required_logos: string[];
|
||||||
other_logos: File[]; // Form uses File objects, schema uses strings - MULTIPLE FILES
|
other_logos: File[]; // Form uses File objects, schema uses strings
|
||||||
advertising_format: string;
|
advertising_format: string;
|
||||||
will_or_have_room_booking: boolean;
|
will_or_have_room_booking: boolean;
|
||||||
expected_attendance: number;
|
expected_attendance: number;
|
||||||
room_booking_files: File[]; // CHANGED: Multiple room booking files instead of single
|
room_booking: File | null;
|
||||||
invoice: File | null;
|
invoice: File | null;
|
||||||
invoice_files: File[]; // MULTIPLE FILES
|
invoice_files: File[];
|
||||||
invoiceData: InvoiceData;
|
invoiceData: InvoiceData;
|
||||||
needs_graphics?: boolean | null;
|
needs_graphics?: boolean | null;
|
||||||
needs_as_funding?: boolean | null;
|
needs_as_funding?: boolean | null;
|
||||||
formReviewed?: boolean; // Track if the form has been reviewed
|
formReviewed?: boolean; // Track if the form has been reviewed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add CustomAlert import
|
|
||||||
import CustomAlert from '../universal/CustomAlert';
|
|
||||||
|
|
||||||
const EventRequestForm: React.FC = () => {
|
const EventRequestForm: React.FC = () => {
|
||||||
const [currentStep, setCurrentStep] = useState<number>(1);
|
const [currentStep, setCurrentStep] = useState<number>(1);
|
||||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Initialize form data
|
// Initialize form data
|
||||||
const [formData, setFormData] = useState<EventRequestFormData>({
|
const [formData, setFormData] = useState<EventRequestFormData>({
|
||||||
|
@ -108,7 +105,7 @@ const EventRequestForm: React.FC = () => {
|
||||||
advertising_format: '',
|
advertising_format: '',
|
||||||
will_or_have_room_booking: false,
|
will_or_have_room_booking: false,
|
||||||
expected_attendance: 0,
|
expected_attendance: 0,
|
||||||
room_booking_files: [],
|
room_booking: null,
|
||||||
as_funding_required: false,
|
as_funding_required: false,
|
||||||
food_drinks_being_served: false,
|
food_drinks_being_served: false,
|
||||||
itemized_invoice: '',
|
itemized_invoice: '',
|
||||||
|
@ -119,7 +116,9 @@ const EventRequestForm: React.FC = () => {
|
||||||
invoiceData: {
|
invoiceData: {
|
||||||
items: [],
|
items: [],
|
||||||
subtotal: 0,
|
subtotal: 0,
|
||||||
|
taxRate: 7.75, // Default tax rate for San Diego
|
||||||
taxAmount: 0,
|
taxAmount: 0,
|
||||||
|
tipPercentage: 15, // Default tip percentage
|
||||||
tipAmount: 0,
|
tipAmount: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
vendor: ''
|
vendor: ''
|
||||||
|
@ -134,10 +133,9 @@ const EventRequestForm: React.FC = () => {
|
||||||
const dataToStore = {
|
const dataToStore = {
|
||||||
...formDataToSave,
|
...formDataToSave,
|
||||||
other_logos: [],
|
other_logos: [],
|
||||||
room_booking_files: [],
|
room_booking: null,
|
||||||
invoice: null,
|
invoice: null,
|
||||||
invoice_files: [],
|
invoice_files: []
|
||||||
savedAt: Date.now() // Add timestamp for stale data detection
|
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
|
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
|
||||||
|
@ -154,69 +152,22 @@ const EventRequestForm: React.FC = () => {
|
||||||
if (savedData) {
|
if (savedData) {
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(savedData);
|
const parsedData = JSON.parse(savedData);
|
||||||
|
|
||||||
// Check if the saved data is stale (older than 24 hours)
|
|
||||||
const now = Date.now();
|
|
||||||
const savedTime = parsedData.savedAt || 0;
|
|
||||||
const staleThreshold = 24 * 60 * 60 * 1000; // 24 hours
|
|
||||||
|
|
||||||
if (now - savedTime > staleThreshold) {
|
|
||||||
// Clear stale data
|
|
||||||
localStorage.removeItem('eventRequestFormData');
|
|
||||||
console.log('Cleared stale form data from localStorage');
|
|
||||||
} else {
|
|
||||||
// Load the saved data
|
|
||||||
setFormData(prevData => ({
|
setFormData(prevData => ({
|
||||||
...prevData,
|
...prevData,
|
||||||
...parsedData
|
...parsedData
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing saved form data:', e);
|
console.error('Error parsing saved form data:', e);
|
||||||
// Clear corrupted data
|
|
||||||
localStorage.removeItem('eventRequestFormData');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle form section data changes
|
// Handle form section data changes
|
||||||
const handleSectionDataChange = (sectionData: Partial<EventRequestFormData>) => {
|
const handleSectionDataChange = (sectionData: Partial<EventRequestFormData>) => {
|
||||||
// Ensure both needs_graphics and flyers_needed are synchronized
|
setFormData(prevData => ({
|
||||||
if (sectionData.flyers_needed !== undefined && sectionData.needs_graphics === undefined) {
|
...prevData,
|
||||||
sectionData.needs_graphics = sectionData.flyers_needed ? true : false;
|
...sectionData
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure both needs_as_funding and as_funding_required are synchronized
|
|
||||||
if (sectionData.needs_as_funding !== undefined && sectionData.as_funding_required === undefined) {
|
|
||||||
sectionData.as_funding_required = sectionData.needs_as_funding ? true : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormData(prevData => {
|
|
||||||
const updatedData = { ...prevData, ...sectionData };
|
|
||||||
|
|
||||||
// Save to localStorage
|
|
||||||
try {
|
|
||||||
const dataToStore = {
|
|
||||||
...updatedData,
|
|
||||||
// Remove file objects before saving to localStorage
|
|
||||||
other_logos: [],
|
|
||||||
room_booking_files: [],
|
|
||||||
invoice: null,
|
|
||||||
invoice_files: [],
|
|
||||||
savedAt: Date.now() // Add timestamp for stale data detection
|
|
||||||
};
|
|
||||||
localStorage.setItem('eventRequestFormData', JSON.stringify(dataToStore));
|
|
||||||
|
|
||||||
// Also update the preview data
|
|
||||||
window.dispatchEvent(new CustomEvent('formDataUpdated', {
|
|
||||||
detail: { formData: updatedData }
|
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving form data to localStorage:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedData;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add this function before the handleSubmit function
|
// Add this function before the handleSubmit function
|
||||||
|
@ -238,7 +189,7 @@ const EventRequestForm: React.FC = () => {
|
||||||
advertising_format: '',
|
advertising_format: '',
|
||||||
will_or_have_room_booking: false,
|
will_or_have_room_booking: false,
|
||||||
expected_attendance: 0,
|
expected_attendance: 0,
|
||||||
room_booking_files: [],
|
room_booking: null,
|
||||||
as_funding_required: false,
|
as_funding_required: false,
|
||||||
food_drinks_being_served: false,
|
food_drinks_being_served: false,
|
||||||
itemized_invoice: '',
|
itemized_invoice: '',
|
||||||
|
@ -249,7 +200,9 @@ const EventRequestForm: React.FC = () => {
|
||||||
invoiceData: {
|
invoiceData: {
|
||||||
items: [],
|
items: [],
|
||||||
subtotal: 0,
|
subtotal: 0,
|
||||||
|
taxRate: 7.75, // Default tax rate for San Diego
|
||||||
taxAmount: 0,
|
taxAmount: 0,
|
||||||
|
tipPercentage: 15, // Default tip percentage
|
||||||
tipAmount: 0,
|
tipAmount: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
vendor: ''
|
vendor: ''
|
||||||
|
@ -272,6 +225,7 @@ const EventRequestForm: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = Authentication.getInstance();
|
const auth = Authentication.getInstance();
|
||||||
|
@ -280,20 +234,14 @@ const EventRequestForm: React.FC = () => {
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
|
|
||||||
if (!auth.isAuthenticated()) {
|
if (!auth.isAuthenticated()) {
|
||||||
// Don't show error toast on dashboard page for unauthenticated users
|
|
||||||
if (!window.location.pathname.includes('/dashboard')) {
|
|
||||||
toast.error('You must be logged in to submit an event request');
|
toast.error('You must be logged in to submit an event request');
|
||||||
}
|
|
||||||
throw new Error('You must be logged in to submit an event request');
|
throw new Error('You must be logged in to submit an event request');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the event request record
|
// Create the event request record
|
||||||
const userId = auth.getUserId();
|
const userId = auth.getUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Don't show error toast on dashboard page for unauthenticated users
|
|
||||||
if (auth.isAuthenticated() || !window.location.pathname.includes('/dashboard')) {
|
|
||||||
toast.error('User ID not found');
|
toast.error('User ID not found');
|
||||||
}
|
|
||||||
throw new Error('User ID not found');
|
throw new Error('User ID not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -302,196 +250,73 @@ const EventRequestForm: React.FC = () => {
|
||||||
requested_user: userId,
|
requested_user: userId,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
location: formData.location,
|
location: formData.location,
|
||||||
start_date_time: (() => {
|
start_date_time: new Date(formData.start_date_time).toISOString(),
|
||||||
try {
|
end_date_time: new Date(formData.end_date_time).toISOString(),
|
||||||
const startDate = new Date(formData.start_date_time);
|
|
||||||
if (isNaN(startDate.getTime())) {
|
|
||||||
throw new Error('Invalid start date');
|
|
||||||
}
|
|
||||||
return startDate.toISOString();
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error('Invalid start date format');
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
end_date_time: (() => {
|
|
||||||
try {
|
|
||||||
if (formData.end_date_time) {
|
|
||||||
const endDate = new Date(formData.end_date_time);
|
|
||||||
if (isNaN(endDate.getTime())) {
|
|
||||||
throw new Error('Invalid end date');
|
|
||||||
}
|
|
||||||
return endDate.toISOString();
|
|
||||||
} else {
|
|
||||||
// Fallback to start date if no end date (should not happen with validation)
|
|
||||||
const startDate = new Date(formData.start_date_time);
|
|
||||||
return startDate.toISOString();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to start date
|
|
||||||
const startDate = new Date(formData.start_date_time);
|
|
||||||
return startDate.toISOString();
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
event_description: formData.event_description,
|
event_description: formData.event_description,
|
||||||
flyers_needed: formData.flyers_needed,
|
flyers_needed: formData.flyers_needed,
|
||||||
photography_needed: formData.photography_needed,
|
|
||||||
as_funding_required: formData.needs_as_funding,
|
|
||||||
food_drinks_being_served: formData.food_drinks_being_served,
|
|
||||||
itemized_invoice: formData.itemized_invoice,
|
|
||||||
flyer_type: formData.flyer_type,
|
flyer_type: formData.flyer_type,
|
||||||
other_flyer_type: formData.other_flyer_type,
|
other_flyer_type: formData.other_flyer_type,
|
||||||
flyer_advertising_start_date: formData.flyer_advertising_start_date ? (() => {
|
flyer_advertising_start_date: formData.flyer_advertising_start_date ? new Date(formData.flyer_advertising_start_date).toISOString() : '',
|
||||||
try {
|
|
||||||
const advertDate = new Date(formData.flyer_advertising_start_date);
|
|
||||||
return isNaN(advertDate.getTime()) ? '' : advertDate.toISOString();
|
|
||||||
} catch (e) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
})() : '',
|
|
||||||
flyer_additional_requests: formData.flyer_additional_requests,
|
flyer_additional_requests: formData.flyer_additional_requests,
|
||||||
|
photography_needed: formData.photography_needed,
|
||||||
required_logos: formData.required_logos,
|
required_logos: formData.required_logos,
|
||||||
advertising_format: formData.advertising_format,
|
advertising_format: formData.advertising_format,
|
||||||
will_or_have_room_booking: formData.will_or_have_room_booking,
|
will_or_have_room_booking: formData.will_or_have_room_booking,
|
||||||
expected_attendance: formData.expected_attendance,
|
expected_attendance: formData.expected_attendance,
|
||||||
needs_graphics: formData.needs_graphics,
|
as_funding_required: formData.as_funding_required,
|
||||||
needs_as_funding: formData.needs_as_funding,
|
food_drinks_being_served: formData.food_drinks_being_served,
|
||||||
|
// Store the itemized_invoice as a string for backward compatibility
|
||||||
|
itemized_invoice: formData.itemized_invoice,
|
||||||
|
// Store the invoice data as a properly formatted JSON object
|
||||||
invoice_data: {
|
invoice_data: {
|
||||||
items: formData.invoiceData.items.map(item => ({
|
items: formData.invoiceData.items.map(item => ({
|
||||||
item: item.description,
|
item: item.description,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit_price: item.unitPrice
|
unit_price: item.unitPrice
|
||||||
})),
|
})),
|
||||||
taxAmount: formData.invoiceData.taxAmount,
|
tax: formData.invoiceData.taxAmount,
|
||||||
tipAmount: formData.invoiceData.tipAmount,
|
tip: formData.invoiceData.tipAmount,
|
||||||
total: formData.invoiceData.total,
|
total: formData.invoiceData.total,
|
||||||
vendor: formData.invoiceData.vendor
|
vendor: formData.invoiceData.vendor
|
||||||
},
|
},
|
||||||
|
// Set the initial status to "submitted"
|
||||||
|
status: EventRequestStatus.SUBMITTED,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the record using the Update service
|
// Create the record using the Update service
|
||||||
// This will send the data to the server
|
// This will send the data to the server
|
||||||
const record = await update.create('event_request', submissionData);
|
const record = await update.create('event_request', submissionData);
|
||||||
|
|
||||||
// Force sync the event requests collection to update IndexedDB with deletion detection
|
// Force sync the event requests collection to update IndexedDB
|
||||||
await dataSync.syncCollection(Collections.EVENT_REQUESTS, "", "-created", {}, true);
|
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
|
||||||
|
|
||||||
console.log('Event request record created:', record.id);
|
// Upload files if they exist
|
||||||
|
|
||||||
// Upload files if they exist - handle each file type separately
|
|
||||||
const fileUploadErrors: string[] = [];
|
|
||||||
|
|
||||||
// Upload other logos
|
|
||||||
if (formData.other_logos.length > 0) {
|
if (formData.other_logos.length > 0) {
|
||||||
try {
|
|
||||||
console.log('Uploading other logos:', formData.other_logos.length, 'files');
|
|
||||||
console.log('Other logos files:', formData.other_logos.map(f => ({ name: f.name, size: f.size, type: f.type })));
|
|
||||||
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
|
await fileManager.uploadFiles('event_request', record.id, 'other_logos', formData.other_logos);
|
||||||
console.log('Other logos uploaded successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to upload other logos:', error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
fileUploadErrors.push(`Failed to upload custom logo files: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload room booking files
|
if (formData.room_booking) {
|
||||||
if (formData.room_booking_files && formData.room_booking_files.length > 0) {
|
await fileManager.uploadFile('event_request', record.id, 'room_booking', formData.room_booking);
|
||||||
try {
|
|
||||||
console.log('Uploading room booking files:', formData.room_booking_files.length, 'files');
|
|
||||||
console.log('Room booking files:', formData.room_booking_files.map(f => ({ name: f.name, size: f.size, type: f.type })));
|
|
||||||
console.log('Using collection:', 'event_request', 'record ID:', record.id, 'field:', 'room_booking');
|
|
||||||
|
|
||||||
// Use the correct field name 'room_booking' instead of 'room_booking_files'
|
|
||||||
await fileManager.uploadFiles('event_request', record.id, 'room_booking', formData.room_booking_files);
|
|
||||||
console.log('Room booking files uploaded successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to upload room booking files:', error);
|
|
||||||
console.error('Error details:', {
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
collection: 'event_request',
|
|
||||||
recordId: record.id,
|
|
||||||
field: 'room_booking',
|
|
||||||
fileCount: formData.room_booking_files.length
|
|
||||||
});
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
fileUploadErrors.push(`Failed to upload room booking files: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload invoice files
|
// Upload multiple invoice files
|
||||||
if (formData.invoice_files && formData.invoice_files.length > 0) {
|
if (formData.invoice_files && formData.invoice_files.length > 0) {
|
||||||
try {
|
await fileManager.appendFiles('event_request', record.id, 'invoice_files', formData.invoice_files);
|
||||||
console.log('Uploading invoice files:', formData.invoice_files.length, 'files');
|
|
||||||
console.log('Invoice files:', formData.invoice_files.map(f => ({ name: f.name, size: f.size, type: f.type })));
|
|
||||||
console.log('Using collection:', 'event_request', 'record ID:', record.id, 'field:', 'invoice');
|
|
||||||
|
|
||||||
// Use the correct field name 'invoice' instead of 'invoice_files'
|
// For backward compatibility, also upload the first file as the main invoice
|
||||||
await fileManager.uploadFiles('event_request', record.id, 'invoice', formData.invoice_files);
|
if (formData.invoice || formData.invoice_files[0]) {
|
||||||
console.log('Invoice files uploaded successfully');
|
const mainInvoice = formData.invoice || formData.invoice_files[0];
|
||||||
} catch (error) {
|
await fileManager.uploadFile('event_request', record.id, 'invoice', mainInvoice);
|
||||||
console.error('Failed to upload invoice files:', error);
|
|
||||||
console.error('Error details:', {
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
collection: 'event_request',
|
|
||||||
recordId: record.id,
|
|
||||||
field: 'invoice',
|
|
||||||
fileCount: formData.invoice_files.length
|
|
||||||
});
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
fileUploadErrors.push(`Failed to upload invoice files: ${errorMessage}`);
|
|
||||||
}
|
}
|
||||||
} else if (formData.invoice) {
|
} else if (formData.invoice) {
|
||||||
try {
|
|
||||||
console.log('Uploading single invoice file:', { name: formData.invoice.name, size: formData.invoice.size, type: formData.invoice.type });
|
|
||||||
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
|
await fileManager.uploadFile('event_request', record.id, 'invoice', formData.invoice);
|
||||||
console.log('Invoice file uploaded successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to upload invoice file:', error);
|
|
||||||
console.error('Error details:', {
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
collection: 'event_request',
|
|
||||||
recordId: record.id,
|
|
||||||
field: 'invoice'
|
|
||||||
});
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
fileUploadErrors.push(`Failed to upload invoice file: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show file upload warnings if any occurred
|
|
||||||
if (fileUploadErrors.length > 0) {
|
|
||||||
console.warn('File upload errors:', fileUploadErrors);
|
|
||||||
// Show each file upload error as a separate toast for better UX
|
|
||||||
fileUploadErrors.forEach(error => {
|
|
||||||
toast.error(error, {
|
|
||||||
duration: 6000, // Longer duration for file upload errors
|
|
||||||
position: 'top-right'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Also show a summary toast
|
|
||||||
toast.error(`Event request submitted successfully, but ${fileUploadErrors.length} file upload(s) failed. Please check the errors above and re-upload the files manually.`, {
|
|
||||||
duration: 8000,
|
|
||||||
position: 'top-center'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Keep success toast for form submission since it's a user action
|
|
||||||
toast.success('Event request submitted successfully!');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear form data from localStorage
|
// Clear form data from localStorage
|
||||||
localStorage.removeItem('eventRequestFormData');
|
localStorage.removeItem('eventRequestFormData');
|
||||||
|
|
||||||
// Send email notification to coordinators (non-blocking)
|
// Keep success toast for form submission since it's a user action
|
||||||
try {
|
toast.success('Event request submitted successfully!');
|
||||||
await EmailClient.notifyEventRequestSubmission(record.id);
|
|
||||||
console.log('Event request notification email sent successfully');
|
|
||||||
} catch (emailError) {
|
|
||||||
console.error('Failed to send event request notification email:', emailError);
|
|
||||||
// Don't show error to user - email failure shouldn't disrupt the main flow
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
resetForm();
|
resetForm();
|
||||||
|
@ -504,6 +329,7 @@ const EventRequestForm: React.FC = () => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting event request:', error);
|
console.error('Error submitting event request:', error);
|
||||||
toast.error('Failed to submit event request. Please try again.');
|
toast.error('Failed to submit event request. Please try again.');
|
||||||
|
setError('Failed to submit event request. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
@ -550,111 +376,53 @@ const EventRequestForm: React.FC = () => {
|
||||||
|
|
||||||
// Validate Event Details Section
|
// Validate Event Details Section
|
||||||
const validateEventDetailsSection = () => {
|
const validateEventDetailsSection = () => {
|
||||||
let valid = true;
|
if (!formData.name) {
|
||||||
const errors: string[] = [];
|
toast.error('Please enter an event name');
|
||||||
|
|
||||||
if (!formData.name || formData.name.trim() === '') {
|
|
||||||
errors.push('Event name is required');
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.event_description || formData.event_description.trim() === '') {
|
|
||||||
errors.push('Event description is required');
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.start_date_time || formData.start_date_time.trim() === '') {
|
|
||||||
errors.push('Event start date and time is required');
|
|
||||||
valid = false;
|
|
||||||
} else {
|
|
||||||
// Validate start date format
|
|
||||||
try {
|
|
||||||
const startDate = new Date(formData.start_date_time);
|
|
||||||
if (isNaN(startDate.getTime())) {
|
|
||||||
errors.push('Invalid start date and time format');
|
|
||||||
valid = false;
|
|
||||||
} else {
|
|
||||||
// Check if start date is in the future
|
|
||||||
const now = new Date();
|
|
||||||
if (startDate <= now) {
|
|
||||||
errors.push('Event start date must be in the future');
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
errors.push('Invalid start date and time format');
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.end_date_time || formData.end_date_time.trim() === '') {
|
|
||||||
errors.push('Event end time is required');
|
|
||||||
valid = false;
|
|
||||||
} else if (formData.start_date_time) {
|
|
||||||
// Validate end date format and logic
|
|
||||||
try {
|
|
||||||
const startDate = new Date(formData.start_date_time);
|
|
||||||
const endDate = new Date(formData.end_date_time);
|
|
||||||
|
|
||||||
if (isNaN(endDate.getTime())) {
|
|
||||||
errors.push('Invalid end date and time format');
|
|
||||||
valid = false;
|
|
||||||
} else if (!isNaN(startDate.getTime()) && endDate <= startDate) {
|
|
||||||
errors.push('Event end time must be after the start time');
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
errors.push('Invalid end date and time format');
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.location || formData.location.trim() === '') {
|
|
||||||
errors.push('Event location is required');
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.will_or_have_room_booking === undefined || formData.will_or_have_room_booking === null) {
|
|
||||||
errors.push('Room booking status is required');
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
// Show the first error as a toast instead of setting error state
|
|
||||||
toast.error(errors[0]);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return valid;
|
if (!formData.event_description) {
|
||||||
|
toast.error('Please enter an event description');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.start_date_time) {
|
||||||
|
toast.error('Please enter a start date and time');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.end_date_time) {
|
||||||
|
toast.error('Please enter an end date and time');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.location) {
|
||||||
|
toast.error('Please enter an event location');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.will_or_have_room_booking === null || formData.will_or_have_room_booking === undefined) {
|
||||||
|
toast.error('Please specify if you have a room booking');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate TAP Form Section
|
// Validate TAP Form Section
|
||||||
const validateTAPFormSection = () => {
|
const validateTAPFormSection = () => {
|
||||||
// Verify that all required fields are filled
|
if (!formData.expected_attendance) {
|
||||||
if (!formData.will_or_have_room_booking && formData.will_or_have_room_booking !== false) {
|
toast.error('Please enter the expected attendance');
|
||||||
toast.error('Please indicate whether you will or have a room booking');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.expected_attendance || formData.expected_attendance <= 0) {
|
if (!formData.room_booking && formData.will_or_have_room_booking) {
|
||||||
toast.error('Please enter a valid expected attendance');
|
toast.error('Please upload your room booking confirmation');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// REQUIRED: Room booking files if will_or_have_room_booking is true
|
if (formData.food_drinks_being_served === null || formData.food_drinks_being_served === undefined) {
|
||||||
if (formData.will_or_have_room_booking && formData.room_booking_files.length === 0) {
|
toast.error('Please specify if food/drinks will be served');
|
||||||
toast.error('Room booking files are required when you need a room booking');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.food_drinks_being_served && formData.food_drinks_being_served !== false) {
|
|
||||||
toast.error('Please indicate whether food/drinks will be served');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate AS funding question if food is being served
|
|
||||||
if (formData.food_drinks_being_served && formData.needs_as_funding === undefined) {
|
|
||||||
toast.error('Please indicate whether you need AS funding');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -663,28 +431,17 @@ const EventRequestForm: React.FC = () => {
|
||||||
|
|
||||||
// Validate AS Funding Section
|
// Validate AS Funding Section
|
||||||
const validateASFundingSection = () => {
|
const validateASFundingSection = () => {
|
||||||
if (formData.as_funding_required || formData.needs_as_funding) {
|
if (formData.needs_as_funding) {
|
||||||
// REQUIRED: Invoice files if AS funding is needed
|
// Check if vendor is provided
|
||||||
if (!formData.invoice_files || formData.invoice_files.length === 0) {
|
if (!formData.invoiceData.vendor) {
|
||||||
toast.error('Invoice files are required when requesting AS funding');
|
toast.error('Please enter the vendor/restaurant name');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if invoice data is present and has items
|
// No longer require items in the invoice
|
||||||
if (!formData.invoiceData || !formData.invoiceData.items || formData.invoiceData.items.length === 0) {
|
// Check if at least one invoice file is uploaded
|
||||||
toast.error('Please add at least one item to your invoice');
|
if (!formData.invoice && (!formData.invoice_files || formData.invoice_files.length === 0)) {
|
||||||
return false;
|
toast.error('Please upload at least one invoice file');
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the total budget from invoice items
|
|
||||||
const totalBudget = formData.invoiceData.items.reduce(
|
|
||||||
(sum, item) => sum + (item.unitPrice * item.quantity), 0
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if the budget exceeds the maximum allowed ($5000 cap regardless of attendance)
|
|
||||||
const maxBudget = Math.min(formData.expected_attendance * 10, 5000);
|
|
||||||
if (totalBudget > maxBudget) {
|
|
||||||
toast.error(`Your budget ($${totalBudget.toFixed(2)}) exceeds the maximum allowed ($${maxBudget}). The absolute maximum is $5,000.`);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -694,11 +451,8 @@ const EventRequestForm: React.FC = () => {
|
||||||
|
|
||||||
// Validate all sections before submission
|
// Validate all sections before submission
|
||||||
const validateAllSections = () => {
|
const validateAllSections = () => {
|
||||||
// We no longer forcibly set end_date_time to match start_date_time
|
// Validate Event Details
|
||||||
// The end time is now configured separately with the same date
|
|
||||||
|
|
||||||
if (!validateEventDetailsSection()) {
|
if (!validateEventDetailsSection()) {
|
||||||
setCurrentStep(1);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -735,18 +489,7 @@ const EventRequestForm: React.FC = () => {
|
||||||
isValid = validateASFundingSection();
|
isValid = validateASFundingSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValid) {
|
if (isValid) {
|
||||||
return; // Don't proceed if validation fails
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're moving from step 4 to step 5
|
|
||||||
if (currentStep === 4 && nextStep === 5) {
|
|
||||||
// If food and drinks aren't being served or if AS funding isn't needed, skip to step 6 (review)
|
|
||||||
if (!formData.food_drinks_being_served || !formData.needs_as_funding) {
|
|
||||||
nextStep = 6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the current step
|
// Set the current step
|
||||||
setCurrentStep(nextStep);
|
setCurrentStep(nextStep);
|
||||||
|
|
||||||
|
@ -758,6 +501,7 @@ const EventRequestForm: React.FC = () => {
|
||||||
formReviewed: true
|
formReviewed: true
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle form submission with validation
|
// Handle form submission with validation
|
||||||
|
@ -789,14 +533,6 @@ const EventRequestForm: React.FC = () => {
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<CustomAlert
|
|
||||||
type="warning"
|
|
||||||
title="Multiple Events Notice"
|
|
||||||
message="If you have multiple events, you must submit a separate TAP form for each one. Multiple-day events require individual submissions for each day."
|
|
||||||
icon="heroicons:exclamation-triangle"
|
|
||||||
className="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold mb-4 text-primary">Event Request Form</h2>
|
<h2 className="text-2xl font-bold mb-4 text-primary">Event Request Form</h2>
|
||||||
|
|
||||||
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||||
|
@ -911,19 +647,55 @@ const EventRequestForm: React.FC = () => {
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
|
{formData.food_drinks_being_served && (
|
||||||
<div className="bg-base-200/50 p-6 rounded-lg mb-6">
|
<div className="bg-base-200/50 p-6 rounded-lg mb-6">
|
||||||
<h3 className="text-xl font-semibold mb-4">AS Funding Details</h3>
|
<h3 className="text-xl font-semibold mb-4">Do you need AS funding for this event?</h3>
|
||||||
<p className="mb-4">Please provide the necessary information for your AS funding request.</p>
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<button
|
||||||
|
className={`btn btn-lg ${formData.needs_as_funding ? 'btn-primary' : 'btn-outline'} flex-1`}
|
||||||
|
onClick={() => {
|
||||||
|
setFormData({ ...formData, needs_as_funding: true, as_funding_required: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn btn-lg ${!formData.needs_as_funding && formData.needs_as_funding !== null ? 'btn-primary' : 'btn-outline'} flex-1`}
|
||||||
|
onClick={() => {
|
||||||
|
setFormData({ ...formData, needs_as_funding: false, as_funding_required: false });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!formData.food_drinks_being_served && (
|
||||||
|
<div className="bg-base-200/50 p-6 rounded-lg mb-6">
|
||||||
|
<h3 className="text-xl font-semibold mb-4">AS Funding Information</h3>
|
||||||
|
<p className="mb-4">Since you're not serving food or drinks, AS funding is not applicable for this event.</p>
|
||||||
|
<div className="alert alert-info">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
|
||||||
|
<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"></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p>If you need to request AS funding for other purposes, please contact the AS office directly.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.needs_as_funding && formData.food_drinks_being_served && (
|
||||||
<ASFundingSection formData={formData} onDataChange={handleSectionDataChange} />
|
<ASFundingSection formData={formData} onDataChange={handleSectionDataChange} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between mt-8">
|
<div className="flex justify-between mt-8">
|
||||||
<button className="btn btn-outline" onClick={() => setCurrentStep(4)}>
|
<button className="btn btn-outline" onClick={() => setCurrentStep(4)}>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary" onClick={() => handleNextStep(6)}>
|
<button className="btn btn-primary" onClick={() => handleNextStep(6)}>
|
||||||
Next
|
Review Form
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@ -952,27 +724,18 @@ const EventRequestForm: React.FC = () => {
|
||||||
|
|
||||||
<div className="divider my-6">Ready to Submit?</div>
|
<div className="divider my-6">Ready to Submit?</div>
|
||||||
|
|
||||||
<CustomAlert
|
<div className="alert alert-info mb-6">
|
||||||
type="info"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
|
||||||
title="Important Note"
|
<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"></path>
|
||||||
message="Once submitted, you'll need to notify PR and/or Coordinators in the #-events Slack channel."
|
</svg>
|
||||||
icon="heroicons:information-circle"
|
<div>
|
||||||
className="mb-6"
|
<p>Once submitted, you'll need to notify PR and/or Coordinators in the #-events Slack channel.</p>
|
||||||
/>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between mt-8">
|
<div className="flex justify-between mt-8">
|
||||||
<button
|
<button className="btn btn-outline" onClick={() => setCurrentStep(5)}>
|
||||||
className="btn btn-outline"
|
|
||||||
onClick={() => {
|
|
||||||
// Skip the AS Funding section if not needed
|
|
||||||
if (!formData.food_drinks_being_served || !formData.needs_as_funding) {
|
|
||||||
setCurrentStep(4); // Go back to TAP Form section
|
|
||||||
} else {
|
|
||||||
setCurrentStep(5); // Go back to AS Funding section
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
@ -1015,6 +778,22 @@ const EventRequestForm: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
className="space-y-6"
|
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"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 stroke-current" 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>{error}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Progress indicator */}
|
{/* Progress indicator */}
|
||||||
<div className="w-full mb-6">
|
<div className="w-full mb-6">
|
||||||
<div className="flex justify-between mb-2">
|
<div className="flex justify-between mb-2">
|
||||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -3,21 +3,8 @@ import { motion } from 'framer-motion';
|
||||||
import type { EventRequestFormData } from './EventRequestForm';
|
import type { EventRequestFormData } from './EventRequestForm';
|
||||||
import type { EventRequest } from '../../../schemas/pocketbase';
|
import type { EventRequest } from '../../../schemas/pocketbase';
|
||||||
import { FlyerTypes, LogoOptions } 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Animation variants
|
||||||
const itemVariants = {
|
const itemVariants = {
|
||||||
hidden: { opacity: 0, y: 20 },
|
hidden: { opacity: 0, y: 20 },
|
||||||
visible: {
|
visible: {
|
||||||
|
@ -31,37 +18,6 @@ const itemVariants = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
// Flyer type options
|
||||||
const FLYER_TYPES = [
|
const FLYER_TYPES = [
|
||||||
{ value: FlyerTypes.DIGITAL_WITH_SOCIAL, label: 'Digital flyer (with social media advertising: Facebook, Instagram, Discord)' },
|
{ value: FlyerTypes.DIGITAL_WITH_SOCIAL, label: 'Digital flyer (with social media advertising: Facebook, Instagram, Discord)' },
|
||||||
|
@ -99,7 +55,6 @@ interface PRSectionProps {
|
||||||
|
|
||||||
const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
const [otherLogoFiles, setOtherLogoFiles] = useState<File[]>(formData.other_logos || []);
|
const [otherLogoFiles, setOtherLogoFiles] = useState<File[]>(formData.other_logos || []);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
|
|
||||||
// Handle checkbox change for flyer types
|
// Handle checkbox change for flyer types
|
||||||
const handleFlyerTypeChange = (type: string) => {
|
const handleFlyerTypeChange = (type: string) => {
|
||||||
|
@ -123,129 +78,53 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleLogoFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
const newFiles = Array.from(e.target.files) as File[];
|
const newFiles = Array.from(e.target.files) as File[];
|
||||||
// Combine existing files with new files instead of replacing
|
setOtherLogoFiles(newFiles);
|
||||||
const combinedFiles = [...otherLogoFiles, ...newFiles];
|
onDataChange({ other_logos: 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 (
|
return (
|
||||||
<motion.div
|
<div className="space-y-6">
|
||||||
className="space-y-8"
|
<h2 className="text-2xl font-bold mb-4 text-primary">PR Materials</h2>
|
||||||
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}>
|
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||||
<CustomAlert
|
<p className="text-sm">
|
||||||
type="info"
|
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.
|
||||||
title="Important Timeline"
|
</p>
|
||||||
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."
|
</div>
|
||||||
className="mb-6"
|
|
||||||
icon="heroicons:clock"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Type of material needed */}
|
{/* Type of material needed */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Type of Material Needed</span>
|
<span className="label-text font-medium text-lg">Type of material needed?</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
<div className="space-y-3 mt-3">
|
|
||||||
{FLYER_TYPES.map((type) => (
|
{FLYER_TYPES.map((type) => (
|
||||||
<motion.label
|
<label key={type.value} className="flex items-start gap-2 cursor-pointer hover:bg-base-300/30 p-2 rounded-md transition-colors">
|
||||||
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="checkbox checkbox-primary mt-1"
|
className="checkbox checkbox-primary mt-1"
|
||||||
checked={formData.flyer_type.includes(type.value)}
|
checked={formData.flyer_type.includes(type.value)}
|
||||||
onChange={() => handleFlyerTypeChange(type.value)}
|
onChange={() => handleFlyerTypeChange(type.value)}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{type.label}</span>
|
<span>{type.label}</span>
|
||||||
</motion.label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Other flyer type input */}
|
{/* Other flyer type input */}
|
||||||
{formData.flyer_type.includes(FlyerTypes.OTHER) && (
|
{formData.flyer_type.includes(FlyerTypes.OTHER) && (
|
||||||
<motion.div
|
<div className="mt-3 pl-7">
|
||||||
className="mt-4 pl-8"
|
<input
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
>
|
|
||||||
<motion.input
|
|
||||||
type="text"
|
type="text"
|
||||||
className="input input-bordered input-primary w-full"
|
className="input input-bordered w-full"
|
||||||
placeholder="Please specify other material needed"
|
placeholder="Please specify other material needed"
|
||||||
value={formData.other_flyer_type}
|
value={formData.other_flyer_type}
|
||||||
onChange={(e) => onDataChange({ other_flyer_type: e.target.value })}
|
onChange={(e) => onDataChange({ other_flyer_type: e.target.value })}
|
||||||
required
|
required
|
||||||
whileHover="hover"
|
|
||||||
variants={inputHoverVariants}
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
@ -255,233 +134,110 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
type === FlyerTypes.PHYSICAL_WITH_ADVERTISING ||
|
type === FlyerTypes.PHYSICAL_WITH_ADVERTISING ||
|
||||||
type === FlyerTypes.NEWSLETTER
|
type === FlyerTypes.NEWSLETTER
|
||||||
) && (
|
) && (
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Advertising Start Date</span>
|
<span className="label-text font-medium">When do you need us to start advertising?</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-500 mb-3">When do you need us to start advertising?</p>
|
<input
|
||||||
|
|
||||||
<motion.input
|
|
||||||
type="date"
|
type="date"
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300 mt-2"
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
value={formData.flyer_advertising_start_date}
|
value={formData.flyer_advertising_start_date}
|
||||||
onChange={(e) => onDataChange({ flyer_advertising_start_date: e.target.value })}
|
onChange={(e) => onDataChange({ flyer_advertising_start_date: e.target.value })}
|
||||||
required
|
required
|
||||||
whileHover="hover"
|
|
||||||
variants={inputHoverVariants}
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Logos Required */}
|
{/* Logos Required */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Logos Required</span>
|
<span className="label-text font-medium">Logos Required</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 mt-3">
|
|
||||||
{LOGO_OPTIONS.map((logo) => (
|
{LOGO_OPTIONS.map((logo) => (
|
||||||
<motion.label
|
<label key={logo.value} className="flex items-start gap-2 cursor-pointer">
|
||||||
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="checkbox checkbox-primary mt-1"
|
className="checkbox checkbox-primary mt-1"
|
||||||
checked={formData.required_logos.includes(logo.value)}
|
checked={formData.required_logos.includes(logo.value)}
|
||||||
onChange={() => handleLogoChange(logo.value)}
|
onChange={() => handleLogoChange(logo.value)}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{logo.label}</span>
|
<span>{logo.label}</span>
|
||||||
</motion.label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Logo file upload */}
|
{/* Logo file upload */}
|
||||||
{formData.required_logos.includes(LogoOptions.OTHER) && (
|
{formData.required_logos.includes(LogoOptions.OTHER) && (
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Logo Files</span>
|
<span className="label-text font-medium">Please share your logo files here</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</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
|
<input
|
||||||
id="logo-files"
|
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="file-input file-input-bordered w-full file-input-primary hover:file-input-ghost transition-all duration-300"
|
||||||
onChange={handleLogoFileChange}
|
onChange={handleLogoFileChange}
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple
|
multiple
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
{otherLogoFiles.length > 0 && (
|
||||||
<div className="flex flex-col items-center justify-center gap-3">
|
<div className="mt-2">
|
||||||
<motion.div
|
<p className="text-sm font-medium mb-1">Selected files:</p>
|
||||||
className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary"
|
<ul className="list-disc list-inside text-sm">
|
||||||
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) => (
|
{otherLogoFiles.map((file, index) => (
|
||||||
<div key={index} className="flex items-center justify-between bg-base-100 p-2 rounded">
|
<li key={index}>{file.name}</li>
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Format */}
|
{/* Format */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Required Format</span>
|
<span className="label-text font-medium">What format do you need it to be in?</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-500 mb-3">What format do you need the materials to be in?</p>
|
<select
|
||||||
|
|
||||||
<motion.select
|
|
||||||
className="select select-bordered focus:select-primary transition-all duration-300"
|
className="select select-bordered focus:select-primary transition-all duration-300"
|
||||||
value={formData.advertising_format}
|
value={formData.advertising_format}
|
||||||
onChange={(e) => onDataChange({ advertising_format: e.target.value })}
|
onChange={(e) => onDataChange({ advertising_format: e.target.value })}
|
||||||
required
|
required
|
||||||
whileHover="hover"
|
|
||||||
variants={inputHoverVariants}
|
|
||||||
>
|
>
|
||||||
<option value="">Select format</option>
|
<option value="">Select format</option>
|
||||||
{FORMAT_OPTIONS.map(option => (
|
{FORMAT_OPTIONS.map(option => (
|
||||||
<option key={option.value} value={option.value}>{option.label}</option>
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
))}
|
))}
|
||||||
</motion.select>
|
</select>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Additional specifications */}
|
{/* Additional specifications */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Additional Specifications</span>
|
<span className="label-text font-medium">Any other specifications and requests?</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-500 mb-3">Any other specifications and requests?</p>
|
<textarea
|
||||||
|
|
||||||
<motion.textarea
|
|
||||||
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px]"
|
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[120px]"
|
||||||
value={formData.flyer_additional_requests}
|
value={formData.flyer_additional_requests}
|
||||||
onChange={(e) => onDataChange({ flyer_additional_requests: e.target.value })}
|
onChange={(e) => onDataChange({ flyer_additional_requests: e.target.value })}
|
||||||
placeholder="Color scheme, overall design, examples to consider, etc."
|
placeholder="Color scheme, overall design, examples to consider, etc."
|
||||||
rows={4}
|
rows={4}
|
||||||
whileHover="hover"
|
|
||||||
variants={inputHoverVariants}
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Photography Needed */}
|
{/* Photography Needed */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Photography</span>
|
<span className="label-text font-medium">Photography Needed?</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-500 mb-3">Do you need photography for your event?</p>
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<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
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
className="radio radio-primary"
|
className="radio radio-primary"
|
||||||
|
@ -489,17 +245,9 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
onChange={() => onDataChange({ photography_needed: true })}
|
onChange={() => onDataChange({ photography_needed: true })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">Yes, we need photography</span>
|
<span>Yes</span>
|
||||||
</motion.label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<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
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
className="radio radio-primary"
|
className="radio radio-primary"
|
||||||
|
@ -507,11 +255,11 @@ const PRSection: React.FC<PRSectionProps> = ({ formData, onDataChange }) => {
|
||||||
onChange={() => onDataChange({ photography_needed: false })}
|
onChange={() => onDataChange({ photography_needed: false })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">No, we don't need photography</span>
|
<span>No</span>
|
||||||
</motion.label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,9 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import type { EventRequestFormData } from './EventRequestForm';
|
import type { EventRequestFormData } from './EventRequestForm';
|
||||||
import type { EventRequest } from '../../../schemas/pocketbase';
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Animation variants
|
||||||
const itemVariants = {
|
const itemVariants = {
|
||||||
hidden: { opacity: 0, y: 20 },
|
hidden: { opacity: 0, y: 20 },
|
||||||
visible: {
|
visible: {
|
||||||
|
@ -31,445 +17,92 @@ const itemVariants = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 {
|
interface TAPFormSectionProps {
|
||||||
formData: EventRequestFormData;
|
formData: EventRequestFormData;
|
||||||
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
onDataChange: (data: Partial<EventRequestFormData>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
|
const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange }) => {
|
||||||
const [roomBookingFiles, setRoomBookingFiles] = useState<File[]>(formData.room_booking_files || []);
|
const [roomBookingFile, setRoomBookingFile] = useState<File | null>(formData.room_booking);
|
||||||
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
|
// Handle room booking file upload
|
||||||
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>) => {
|
const handleRoomBookingFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
const newFiles = Array.from(e.target.files) as File[];
|
const file = e.target.files[0];
|
||||||
|
setRoomBookingFile(file);
|
||||||
// Check file sizes - 1MB limit for each file
|
onDataChange({ room_booking: 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 (
|
return (
|
||||||
<motion.div
|
<div className="space-y-6">
|
||||||
className="space-y-8"
|
<h2 className="text-2xl font-bold mb-4 text-primary">TAP Form Information</h2>
|
||||||
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}>
|
<div className="bg-base-300/50 p-4 rounded-lg mb-6">
|
||||||
<CustomAlert
|
<p className="text-sm">
|
||||||
type="info"
|
Please ensure you have ALL sections completed. If something is not available, let the coordinators know and be advised on how to proceed.
|
||||||
title="CRITICAL INFORMATION"
|
</p>
|
||||||
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."
|
</div>
|
||||||
className="mb-6"
|
|
||||||
icon="heroicons:exclamation-triangle"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Expected attendance */}
|
{/* Expected attendance */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Expected Attendance</span>
|
<span className="label-text font-medium text-lg">Expected attendance? Include a number NOT a range please.</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<motion.input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300 w-full pr-20"
|
className="input input-bordered focus:input-primary transition-all duration-300 w-full"
|
||||||
value={formData.expected_attendance || ''}
|
value={formData.expected_attendance || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => onDataChange({ expected_attendance: parseInt(e.target.value) || 0 })}
|
||||||
// Allow any attendance number, no longer limiting to 500
|
|
||||||
const attendance = parseInt(e.target.value) || 0;
|
|
||||||
onDataChange({ expected_attendance: attendance });
|
|
||||||
}}
|
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="Enter expected attendance"
|
placeholder="Enter expected attendance"
|
||||||
required
|
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">
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-400">
|
||||||
people
|
people
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{formData.expected_attendance > 0 && (
|
<div className="text-xs text-gray-400 mt-2">
|
||||||
<motion.div
|
<p>PROGRAMMING FUNDS EVENTS FUNDED BY PROGRAMMING FUNDS MAY ONLY ADMIT UC SAN DIEGO STUDENTS, STAFF OR FACULTY AS GUESTS.</p>
|
||||||
initial={{ opacity: 0, height: 0 }}
|
<p>ONLY UC SAN DIEGO UNDERGRADUATE STUDENTS MAY RECEIVE ITEMS FUNDED BY THE ASSOCIATED STUDENTS.</p>
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
<p>EVENT FUNDING IS GRANTED UP TO A MAXIMUM OF $10.00 PER EXPECTED STUDENT ATTENDEE AND $5,000 PER EVENT</p>
|
||||||
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>
|
</div>
|
||||||
)}
|
</motion.div>
|
||||||
|
|
||||||
{formData.will_or_have_room_booking ? (
|
{/* Room booking confirmation */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control bg-base-200/50 p-4 rounded-lg">
|
||||||
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'
|
<label className="label">
|
||||||
}`}
|
<span className="label-text font-medium text-lg">Room booking confirmation</span>
|
||||||
variants={fileUploadVariants}
|
<span className="label-text-alt text-error">*</span>
|
||||||
initial="initial"
|
</label>
|
||||||
whileHover="hover"
|
<div className="mt-2">
|
||||||
whileTap="tap"
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => document.getElementById('room-booking-file')?.click()}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
id="room-booking-file"
|
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="file-input file-input-bordered file-input-primary w-full"
|
||||||
onChange={handleRoomBookingFileChange}
|
onChange={handleRoomBookingFileChange}
|
||||||
accept=".pdf,.png,.jpg,.jpeg"
|
accept=".pdf,.png,.jpg,.jpeg"
|
||||||
multiple
|
|
||||||
/>
|
/>
|
||||||
|
{roomBookingFile && (
|
||||||
<div className="flex flex-col items-center justify-center gap-3">
|
<p className="text-sm mt-2">
|
||||||
<motion.div
|
Selected file: {roomBookingFile.name}
|
||||||
className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary"
|
</p>
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Please upload a screenshot of your room booking confirmation. Accepted formats: PDF, PNG, JPG.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.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 */}
|
{/* Food/Drinks */}
|
||||||
<motion.div
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
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">
|
<label className="label">
|
||||||
<span className="label-text font-medium text-lg">Food & Drinks</span>
|
<span className="label-text font-medium">Will you be serving food/drinks at your event?</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
</label>
|
</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-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<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
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
className="radio radio-primary"
|
className="radio radio-primary"
|
||||||
|
@ -477,17 +110,9 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
onChange={() => onDataChange({ food_drinks_being_served: true })}
|
onChange={() => onDataChange({ food_drinks_being_served: true })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">Yes, we'll have food/drinks</span>
|
<span>Yes</span>
|
||||||
</motion.label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<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
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
className="radio radio-primary"
|
className="radio radio-primary"
|
||||||
|
@ -495,93 +120,31 @@ const TAPFormSection: React.FC<TAPFormSectionProps> = ({ formData, onDataChange
|
||||||
onChange={() => onDataChange({ food_drinks_being_served: false })}
|
onChange={() => onDataChange({ food_drinks_being_served: false })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">No, no food/drinks</span>
|
<span>No</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>
|
</label>
|
||||||
<p className="text-sm text-gray-500 mb-3">Do you need funding from AS?</p>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<div className="flex gap-6 mt-2">
|
{/* AS Funding Notice - only show if food/drinks are being served */}
|
||||||
<motion.label
|
{formData.food_drinks_being_served && (
|
||||||
className={`flex items-center gap-3 cursor-pointer p-4 rounded-lg transition-colors ${formData.needs_as_funding === true
|
<motion.div
|
||||||
? 'bg-primary/20 border border-primary/50'
|
initial={{ opacity: 0, height: 0 }}
|
||||||
: 'bg-base-100 hover:bg-primary/10'
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
}`}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
whileHover={{ scale: 1.02 }}
|
className="alert alert-info"
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
>
|
>
|
||||||
<input
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-5 w-5 stroke-current">
|
||||||
type="radio"
|
<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"></path>
|
||||||
className="radio radio-primary"
|
</svg>
|
||||||
checked={formData.needs_as_funding === true}
|
<div>
|
||||||
onChange={() => onDataChange({
|
<h3 className="font-bold">Food and Drinks Information</h3>
|
||||||
needs_as_funding: true,
|
<div className="text-xs">
|
||||||
as_funding_required: true
|
If you're serving food or drinks, you'll be asked about AS funding in the next step. Please be prepared with vendor information and invoice details.
|
||||||
})}
|
</div>
|
||||||
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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,6 @@ import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase';
|
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 the global window interface to include our custom function
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -19,221 +17,10 @@ export interface EventRequest extends SchemaEventRequest {
|
||||||
invoice_data?: any;
|
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 {
|
interface UserEventRequestsProps {
|
||||||
eventRequests: EventRequest[];
|
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 UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: initialEventRequests }) => {
|
||||||
const [eventRequests, setEventRequests] = useState<EventRequest[]>(initialEventRequests);
|
const [eventRequests, setEventRequests] = useState<EventRequest[]>(initialEventRequests);
|
||||||
const [selectedRequest, setSelectedRequest] = useState<EventRequest | null>(null);
|
const [selectedRequest, setSelectedRequest] = useState<EventRequest | null>(null);
|
||||||
|
@ -258,14 +45,12 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
|
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||||
const updatedRequests = await dataSync.getData<EventRequest>(
|
const updatedRequests = await dataSync.getData<EventRequest>(
|
||||||
Collections.EVENT_REQUESTS,
|
Collections.EVENT_REQUESTS,
|
||||||
true, // Force sync
|
true, // Force sync
|
||||||
`requested_user="${userId}"`,
|
`requested_user="${userId}"`,
|
||||||
'-created',
|
'-created'
|
||||||
{}, // expand
|
|
||||||
true // Enable deletion detection for user-specific requests
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setEventRequests(updatedRequests);
|
setEventRequests(updatedRequests);
|
||||||
|
@ -284,7 +69,7 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
// Listen for tab visibility changes and refresh data when tab becomes visible
|
// Listen for tab visibility changes and refresh data when tab becomes visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTabVisible = () => {
|
const handleTabVisible = () => {
|
||||||
// console.log("Tab became visible, refreshing event requests...");
|
console.log("Tab became visible, refreshing event requests...");
|
||||||
refreshEventRequests();
|
refreshEventRequests();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -303,9 +88,10 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: 'numeric',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -405,8 +191,8 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1 }}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
|
||||||
|
@ -453,86 +239,56 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'table' ? (
|
{viewMode === 'table' ? (
|
||||||
<div className="overflow-x-auto overflow-y-auto rounded-xl shadow-sm max-h-[70vh]">
|
<div className="overflow-x-auto rounded-xl shadow-sm">
|
||||||
<table className="table table-zebra w-full text-xs">
|
<table className="table table-zebra w-full">
|
||||||
<thead className="bg-base-300/50 sticky top-0 z-10">
|
<thead className="bg-base-300/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-[17%]">Event Name</th>
|
<th>Event Name</th>
|
||||||
<th className="w-[16%]">Date</th>
|
<th>Date</th>
|
||||||
<th className="w-[14%]">Location</th>
|
<th>Location</th>
|
||||||
<th className="w-[7%] text-center">PR</th>
|
<th>PR Materials</th>
|
||||||
<th className="w-[7%] text-center">AS</th>
|
<th>AS Funding</th>
|
||||||
<th className="w-[15%]">Submitted</th>
|
<th>Submitted</th>
|
||||||
<th className="w-[14%] text-center">Status</th>
|
<th>Status</th>
|
||||||
<th className="w-[10%] px-0 text-center">View</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{eventRequests.map((request) => (
|
{eventRequests.map((request) => (
|
||||||
<tr key={request.id} className={`hover border-l-4 ${getCardBorderClass(request.status)}`}>
|
<tr key={request.id} className={`hover border-l-4 ${getCardBorderClass(request.status)}`}>
|
||||||
<td className="font-medium">
|
<td className="font-medium">{request.name}</td>
|
||||||
<div className="tooltip" data-tip={request.name}>
|
<td>{formatDate(request.start_date_time)}</td>
|
||||||
<span className="block truncate max-w-[125px]">
|
<td>{request.location}</td>
|
||||||
{truncateText(request.name, 18)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<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 ? (
|
{request.flyers_needed ? (
|
||||||
<span className="badge badge-success badge-sm">Yes</span>
|
<span className="badge badge-success badge-sm">Yes</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="badge badge-ghost badge-sm">No</span>
|
<span className="badge badge-ghost badge-sm">No</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td>
|
||||||
{request.as_funding_required ? (
|
{request.as_funding_required ? (
|
||||||
<span className="badge badge-success badge-sm">Yes</span>
|
<span className="badge badge-success badge-sm">Yes</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="badge badge-ghost badge-sm">No</span>
|
<span className="badge badge-ghost badge-sm">No</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{formatDate(request.created)}</td>
|
||||||
<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`}>
|
<span className={`badge ${getStatusBadge(request.status)} badge-sm`}>
|
||||||
{request.status || 'Submitted'}
|
{request.status || 'Submitted'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center px-0">
|
<td>
|
||||||
<div className="flex justify-center">
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-xs btn-ghost btn-circle tooltip flex items-center justify-center p-0"
|
className="btn btn-ghost btn-sm rounded-full"
|
||||||
data-tip="View Details"
|
onClick={() => openDetailModal(request)}
|
||||||
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}>
|
<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" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<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" 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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
@ -551,30 +307,24 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
>
|
>
|
||||||
<div className="card-body p-5">
|
<div className="card-body p-5">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<h3 className="card-title text-base tooltip" data-tip={request.name}>
|
<h3 className="card-title text-base">{request.name}</h3>
|
||||||
<span className="block truncate max-w-[180px]">
|
|
||||||
{truncateText(request.name, 25)}
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
<span className={`badge ${getStatusBadge(request.status)}`}>
|
<span className={`badge ${getStatusBadge(request.status)}`}>
|
||||||
{request.status || 'Pending'}
|
{request.status || 'Pending'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 mt-2 text-sm">
|
<div className="space-y-2 mt-2 text-sm">
|
||||||
<div className="flex items-start gap-2">
|
<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">
|
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<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>
|
</svg>
|
||||||
<span className="truncate">{formatDate(request.start_date_time)}</span>
|
<span>{formatDate(request.start_date_time)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
<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">
|
<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="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="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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="truncate tooltip" data-tip={request.location}>
|
<span>{request.location}</span>
|
||||||
{truncateText(request.location, 25)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
@ -590,17 +340,10 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
</div>
|
</div>
|
||||||
<div className="card-actions justify-end mt-4">
|
<div className="card-actions justify-end mt-4">
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-circle btn-ghost text-primary tooltip flex items-center justify-center"
|
className="btn btn-primary btn-sm"
|
||||||
data-tip="View Details"
|
onClick={() => openDetailModal(request)}
|
||||||
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}>
|
View Details
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -638,45 +381,382 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Use the new portal component for the modal */}
|
{/* Event Request Detail Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
{isModalOpen && selectedRequest && (
|
{isModalOpen && selectedRequest && (
|
||||||
<EventRequestModal
|
<motion.div
|
||||||
isOpen={isModalOpen}
|
initial={{ opacity: 0 }}
|
||||||
onClose={closeModal}
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 backdrop-blur-sm"
|
||||||
|
onClick={closeModal}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
|
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 z-10 bg-base-100 px-6 py-4 border-b border-base-300 flex justify-between items-center">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-xl font-bold text-base-content">{selectedRequest.name}</h2>
|
<h2 className="text-xl font-bold">{selectedRequest.name}</h2>
|
||||||
<span className={`badge ${getStatusBadge(selectedRequest.status)}`}>
|
<span className={`badge ${getStatusBadge(selectedRequest.status)}`}>
|
||||||
{selectedRequest.status || 'Pending'}
|
{selectedRequest.status || 'Pending'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-circle"
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
console.log('Full Preview button clicked', selectedRequest);
|
||||||
|
try {
|
||||||
|
// Direct call to the global function
|
||||||
|
if (typeof window.showEventRequestFormPreview === 'function') {
|
||||||
|
window.showEventRequestFormPreview(selectedRequest);
|
||||||
|
} else {
|
||||||
|
console.error('showEventRequestFormPreview is not a function', window.showEventRequestFormPreview);
|
||||||
|
// Fallback to event dispatch if function is not available
|
||||||
|
const event = new CustomEvent("showEventRequestPreviewModal", {
|
||||||
|
detail: { formData: selectedRequest }
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
console.log('Fallback: showEventRequestPreviewModal event dispatched');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error showing full preview:', error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Full Preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-circle btn-ghost"
|
||||||
onClick={closeModal}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{selectedRequest ? (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||||
<EventRequestFormPreview
|
<div>
|
||||||
formData={convertToFormData(selectedRequest)}
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
|
||||||
isModal={true}
|
<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="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>
|
||||||
<div className="flex items-center justify-center h-64">
|
Event Details
|
||||||
<div className="loading loading-spinner loading-lg text-primary"></div>
|
</h3>
|
||||||
|
<div className="space-y-4 bg-base-200/50 p-4 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Event Name</p>
|
||||||
|
<p className="font-medium">{selectedRequest.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Location</p>
|
||||||
|
<p className="font-medium">{selectedRequest.location}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Start Date & Time</p>
|
||||||
|
<p className="font-medium">{formatDate(selectedRequest.start_date_time)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">End Date & Time</p>
|
||||||
|
<p className="font-medium">{formatDate(selectedRequest.end_date_time)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Room Booking</p>
|
||||||
|
<p className="font-medium">{selectedRequest.will_or_have_room_booking ? 'Yes' : 'No'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Expected Attendance</p>
|
||||||
|
<p className="font-medium">{selectedRequest.expected_attendance || 'Not specified'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
|
||||||
|
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Event Description
|
||||||
|
</h3>
|
||||||
|
<div className="bg-base-200/50 p-4 rounded-lg h-full">
|
||||||
|
<p className="whitespace-pre-line">{selectedRequest.event_description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRequest.flyers_needed && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
|
||||||
|
<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="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>
|
||||||
|
PR Materials
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-base-200/50 p-4 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Flyer Types</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{selectedRequest.flyer_type?.join(', ') || 'Not specified'}
|
||||||
|
{selectedRequest.other_flyer_type && ` (${selectedRequest.other_flyer_type})`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Advertising Start Date</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{selectedRequest.flyer_advertising_start_date
|
||||||
|
? formatDate(selectedRequest.flyer_advertising_start_date)
|
||||||
|
: 'Not specified'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Required Logos</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{selectedRequest.required_logos?.join(', ') || 'None'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Advertising Format</p>
|
||||||
|
<p className="font-medium">{selectedRequest.advertising_format || 'Not specified'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<p className="text-sm text-base-content/60">Additional Requests</p>
|
||||||
|
<p className="font-medium whitespace-pre-line">
|
||||||
|
{selectedRequest.flyer_additional_requests || 'None'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRequest.as_funding_required && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2 text-primary">
|
||||||
|
<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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
AS Funding Details
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4 bg-base-200/50 p-4 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Food/Drinks Being Served</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{selectedRequest.food_drinks_being_served ? 'Yes' : 'No'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectedRequest.invoice_data && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Vendor</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{selectedRequest.invoice_data.vendor || 'Not specified'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Itemized Invoice</p>
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
let invoiceData: any = null;
|
||||||
|
|
||||||
|
// Parse the invoice data if it's a string, or use it directly if it's an object
|
||||||
|
if (typeof selectedRequest.itemized_invoice === 'string') {
|
||||||
|
try {
|
||||||
|
invoiceData = JSON.parse(selectedRequest.itemized_invoice);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse invoice JSON:', e);
|
||||||
|
return (
|
||||||
|
<pre className="bg-base-300 p-3 rounded-lg text-xs overflow-x-auto mt-2">
|
||||||
|
{selectedRequest.itemized_invoice || 'Not provided'}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (typeof selectedRequest.itemized_invoice === 'object') {
|
||||||
|
invoiceData = selectedRequest.itemized_invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have valid invoice data with items
|
||||||
|
if (invoiceData && Array.isArray(invoiceData.items) && invoiceData.items.length > 0) {
|
||||||
|
// Calculate total from items if not provided or if NaN
|
||||||
|
let calculatedTotal = 0;
|
||||||
|
|
||||||
|
// Try to use the provided total first
|
||||||
|
if (invoiceData.total !== undefined) {
|
||||||
|
const parsedTotal = typeof invoiceData.total === 'string'
|
||||||
|
? parseFloat(invoiceData.total)
|
||||||
|
: invoiceData.total;
|
||||||
|
|
||||||
|
if (!isNaN(parsedTotal)) {
|
||||||
|
calculatedTotal = parsedTotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If total is NaN or not provided, calculate from items
|
||||||
|
if (calculatedTotal === 0 || isNaN(calculatedTotal)) {
|
||||||
|
calculatedTotal = invoiceData.items.reduce((sum: number, item: any) => {
|
||||||
|
const quantity = typeof item.quantity === 'string'
|
||||||
|
? parseFloat(item.quantity)
|
||||||
|
: (item.quantity || 1);
|
||||||
|
|
||||||
|
const unitPrice = typeof item.unit_price === 'string'
|
||||||
|
? parseFloat(item.unit_price)
|
||||||
|
: (item.unit_price || 0);
|
||||||
|
|
||||||
|
const itemTotal = !isNaN(quantity) && !isNaN(unitPrice)
|
||||||
|
? quantity * unitPrice
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return sum + itemTotal;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Add tax and tip if available
|
||||||
|
if (invoiceData.tax && !isNaN(parseFloat(invoiceData.tax))) {
|
||||||
|
calculatedTotal += parseFloat(invoiceData.tax);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceData.tip && !isNaN(parseFloat(invoiceData.tip))) {
|
||||||
|
calculatedTotal += parseFloat(invoiceData.tip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-base-300 p-3 rounded-lg overflow-x-auto mt-2">
|
||||||
|
<table className="table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th className="text-right">Qty</th>
|
||||||
|
<th className="text-right">Price</th>
|
||||||
|
<th className="text-right">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoiceData.items.map((item: any, index: number) => {
|
||||||
|
const quantity = typeof item.quantity === 'string'
|
||||||
|
? parseFloat(item.quantity)
|
||||||
|
: (item.quantity || 1);
|
||||||
|
|
||||||
|
const unitPrice = typeof item.unit_price === 'string'
|
||||||
|
? parseFloat(item.unit_price)
|
||||||
|
: (item.unit_price || 0);
|
||||||
|
|
||||||
|
const itemTotal = !isNaN(quantity) && !isNaN(unitPrice)
|
||||||
|
? quantity * unitPrice
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<td>{item.item || 'Unnamed item'}</td>
|
||||||
|
<td className="text-right">{!isNaN(quantity) ? quantity : 1}</td>
|
||||||
|
<td className="text-right">${!isNaN(unitPrice) ? unitPrice.toFixed(2) : '0.00'}</td>
|
||||||
|
<td className="text-right">${!isNaN(itemTotal) ? itemTotal.toFixed(2) : '0.00'}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
{invoiceData.tax !== undefined && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-right font-medium">Tax:</td>
|
||||||
|
<td className="text-right">
|
||||||
|
${typeof invoiceData.tax === 'string'
|
||||||
|
? (parseFloat(invoiceData.tax) || 0).toFixed(2)
|
||||||
|
: (invoiceData.tax || 0).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{invoiceData.tip !== undefined && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-right font-medium">Tip:</td>
|
||||||
|
<td className="text-right">
|
||||||
|
${typeof invoiceData.tip === 'string'
|
||||||
|
? (parseFloat(invoiceData.tip) || 0).toFixed(2)
|
||||||
|
: (invoiceData.tip || 0).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-right font-bold">Total:</td>
|
||||||
|
<td className="text-right font-bold">
|
||||||
|
${!isNaN(calculatedTotal) ? calculatedTotal.toFixed(2) : '0.00'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{invoiceData.vendor && (
|
||||||
|
<div className="mt-3 text-sm">
|
||||||
|
<span className="font-medium">Vendor:</span> {invoiceData.vendor}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</EventRequestModal>
|
);
|
||||||
|
} else if (invoiceData && typeof invoiceData.total !== 'undefined') {
|
||||||
|
// If we have a total but no items, show a simplified view
|
||||||
|
const total = typeof invoiceData.total === 'string'
|
||||||
|
? parseFloat(invoiceData.total)
|
||||||
|
: invoiceData.total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-base-300 p-3 rounded-lg mt-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium">Total Amount:</span>
|
||||||
|
<span className="font-bold">${!isNaN(total) ? total.toFixed(2) : '0.00'}</span>
|
||||||
|
</div>
|
||||||
|
{invoiceData.vendor && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="font-medium">Vendor:</span> {invoiceData.vendor}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback to display the JSON in a readable format
|
||||||
|
return (
|
||||||
|
<pre className="bg-base-300 p-3 rounded-lg text-xs overflow-x-auto mt-2">
|
||||||
|
{typeof selectedRequest.itemized_invoice === 'object'
|
||||||
|
? JSON.stringify(selectedRequest.itemized_invoice, null, 2)
|
||||||
|
: (selectedRequest.itemized_invoice || 'Not provided')}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rendering invoice:', error);
|
||||||
|
return (
|
||||||
|
<pre className="bg-base-300 p-3 rounded-lg text-xs overflow-x-auto mt-2">
|
||||||
|
Error displaying invoice. Please check the console for details.
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 pt-4 border-t border-base-300">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-base-content/60">Submission Date</p>
|
||||||
|
<p className="font-medium">{formatDate(selectedRequest.created)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm text-base-content/60">Status:</p>
|
||||||
|
<span className={`badge ${getStatusBadge(selectedRequest.status)} badge-lg`}>
|
||||||
|
{selectedRequest.status || 'Pending'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,11 +3,9 @@ import { Authentication } from "../../scripts/pocketbase/Authentication";
|
||||||
import { Get } from "../../scripts/pocketbase/Get";
|
import { Get } from "../../scripts/pocketbase/Get";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import EventRequestManagementTable from "./Officer_EventRequestManagement/EventRequestManagementTable";
|
import EventRequestManagementTable from "./Officer_EventRequestManagement/EventRequestManagementTable";
|
||||||
import EventRequestModal from "./Officer_EventRequestManagement/EventRequestModal";
|
import type { EventRequest } from "../../schemas/pocketbase";
|
||||||
import type { EventRequest } from "../../schemas/pocketbase/schema";
|
|
||||||
import { Collections } from "../../schemas/pocketbase/schema";
|
import { Collections } from "../../schemas/pocketbase/schema";
|
||||||
import { Icon } from "astro-icon/components";
|
import { Icon } from "astro-icon/components";
|
||||||
import CustomAlert from "./universal/CustomAlert";
|
|
||||||
|
|
||||||
// Get instances
|
// Get instances
|
||||||
const get = Get.getInstance();
|
const get = Get.getInstance();
|
||||||
|
@ -24,7 +22,6 @@ interface ExtendedEventRequest extends EventRequest {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
emailVisibility?: boolean; // Add this field to the interface
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
|
@ -32,13 +29,6 @@ interface ExtendedEventRequest extends EventRequest {
|
||||||
[key: string]: any; // For other optional properties
|
[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
|
// Initialize variables for all event requests
|
||||||
let allEventRequests: ExtendedEventRequest[] = [];
|
let allEventRequests: ExtendedEventRequest[] = [];
|
||||||
let error = null;
|
let error = null;
|
||||||
|
@ -47,42 +37,37 @@ try {
|
||||||
// Don't check authentication here - let the client component handle it
|
// 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
|
// The server-side check is causing issues when the token is valid client-side but not server-side
|
||||||
|
|
||||||
|
console.log("Fetching event requests in Astro component...");
|
||||||
|
// Expand the requested_user field to get user details
|
||||||
allEventRequests = await get
|
allEventRequests = await get
|
||||||
.getAll<ExtendedEventRequest>(
|
.getAll<ExtendedEventRequest>(Collections.EVENT_REQUESTS, "", "-created", {
|
||||||
Collections.EVENT_REQUESTS,
|
expand: ["requested_user"],
|
||||||
"",
|
})
|
||||||
"-created",
|
|
||||||
{
|
|
||||||
expand: "requested_user",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("Error in get.getAll:", err);
|
console.error("Error in get.getAll:", err);
|
||||||
// Return empty array instead of throwing
|
// Return empty array instead of throwing
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Fetched ${allEventRequests.length} event requests in Astro component`,
|
||||||
|
);
|
||||||
|
|
||||||
// Process the event requests to add the requested_user_expand property
|
// Process the event requests to add the requested_user_expand property
|
||||||
allEventRequests = allEventRequests.map((request) => {
|
allEventRequests = allEventRequests.map((request) => {
|
||||||
const requestWithExpand = { ...request };
|
const requestWithExpand = { ...request };
|
||||||
|
|
||||||
// Add the requested_user_expand property if the expand data is available
|
// Add the requested_user_expand property if the expand data is available
|
||||||
if (
|
if (
|
||||||
request.expand?.requested_user &&
|
request.expand &&
|
||||||
request.expand.requested_user.name
|
request.expand.requested_user &&
|
||||||
|
request.expand.requested_user.name &&
|
||||||
|
request.expand.requested_user.email
|
||||||
) {
|
) {
|
||||||
// Always include email regardless of emailVisibility setting
|
|
||||||
requestWithExpand.requested_user_expand = {
|
requestWithExpand.requested_user_expand = {
|
||||||
name: request.expand.requested_user.name,
|
name: request.expand.requested_user.name,
|
||||||
email:
|
email: request.expand.requested_user.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;
|
return requestWithExpand;
|
||||||
|
@ -93,7 +78,7 @@ try {
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="w-full max-w-7xl mx-auto py-8 px-4 sm:px-6">
|
<div class="w-full max-w-7xl mx-auto py-8 px-4">
|
||||||
<style>
|
<style>
|
||||||
.event-table-container {
|
.event-table-container {
|
||||||
min-height: 600px;
|
min-height: 600px;
|
||||||
|
@ -106,210 +91,88 @@ try {
|
||||||
.event-table-container .overflow-x-auto {
|
.event-table-container .overflow-x-auto {
|
||||||
max-height: none !important;
|
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>
|
</style>
|
||||||
|
|
||||||
<div
|
<div class="mb-8">
|
||||||
class="mb-8 space-y-4 card-enter"
|
<h1 class="text-3xl font-bold text-white mb-2">Event Request Management</h1>
|
||||||
style={`animation-delay: ${ANIMATION_DELAYS.heading};`}
|
<p class="text-gray-300 mb-4">
|
||||||
>
|
Review and manage event requests submitted by officers. Update status and
|
||||||
<div class="flex items-center gap-3">
|
coordinate with the team.
|
||||||
<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>
|
</p>
|
||||||
|
<div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300">
|
||||||
<div
|
<p class="font-medium mb-2">As an executive officer, you can:</p>
|
||||||
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"
|
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||||
style={`animation-delay: ${ANIMATION_DELAYS.info};`}
|
<li>View all submitted event requests</li>
|
||||||
>
|
<li>Update the status of requests (Pending, Completed, Declined)</li>
|
||||||
<div class="flex items-start gap-3">
|
<li>Filter and sort requests by various criteria</li>
|
||||||
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
{
|
||||||
error && (
|
error && (
|
||||||
<div
|
<div class="alert alert-error mb-6">
|
||||||
class="mb-6 card-enter"
|
<svg
|
||||||
style={`animation-delay: ${ANIMATION_DELAYS.content};`}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6 stroke-current shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<CustomAlert
|
<path
|
||||||
client:load
|
stroke-linecap="round"
|
||||||
type="error"
|
stroke-linejoin="round"
|
||||||
title="Error fetching event requests"
|
stroke-width="2"
|
||||||
message={error.toString()}
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
icon="heroicons:exclamation-triangle"
|
|
||||||
/>
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Main page content including table and modal -->
|
{
|
||||||
<EventRequestModal client:load eventRequests={allEventRequests} />
|
!error && (
|
||||||
|
<div class="bg-base-200 rounded-lg shadow-xl min-h-[600px] event-table-container">
|
||||||
|
<div class="p-4 md:p-6 h-auto">
|
||||||
|
<EventRequestManagementTable
|
||||||
|
client:load
|
||||||
|
eventRequests={allEventRequests}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" define:vars={{ ANIMATION_DELAYS }}>
|
<script>
|
||||||
// Import the DataSyncService for client-side use
|
// Import the DataSyncService for client-side use
|
||||||
import { DataSyncService } from "../../scripts/database/DataSyncService";
|
import { DataSyncService } from "../../scripts/database/DataSyncService";
|
||||||
import { Collections } from "../../schemas/pocketbase/schema";
|
import { Collections } from "../../schemas/pocketbase/schema";
|
||||||
|
|
||||||
// Use a more efficient approach to refresh data only when needed
|
// Remove the visibilitychange event listener that causes full page refresh
|
||||||
|
// Instead, we'll use a more efficient approach to refresh data only when needed
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (document.visibilityState === "visible") {
|
if (document.visibilityState === "visible") {
|
||||||
// Dispatch a custom event that components can listen for
|
// Instead of reloading the entire page, dispatch a custom event
|
||||||
|
// that components can listen for to refresh their data
|
||||||
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
|
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also force refresh when this tab is clicked in dashboard
|
// Handle authentication errors
|
||||||
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 () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
// Initialize DataSyncService for client-side
|
// Initialize DataSyncService for client-side
|
||||||
const dataSync = DataSyncService.getInstance();
|
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
|
// Prefetch data into IndexedDB
|
||||||
try {
|
try {
|
||||||
await dataSync.syncCollection(
|
await dataSync.syncCollection(
|
||||||
Collections.EVENT_REQUESTS,
|
Collections.EVENT_REQUESTS,
|
||||||
"",
|
"",
|
||||||
"-created",
|
"-created",
|
||||||
"requested_user"
|
{ expand: "requested_user" },
|
||||||
);
|
);
|
||||||
|
console.log("Initial data sync complete");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error during initial data sync:", err);
|
console.error("Error during initial data sync:", err);
|
||||||
}
|
}
|
||||||
|
@ -320,10 +183,26 @@ try {
|
||||||
errorElement &&
|
errorElement &&
|
||||||
errorElement.textContent?.includes("Authentication error")
|
errorElement.textContent?.includes("Authentication error")
|
||||||
) {
|
) {
|
||||||
|
console.log(
|
||||||
|
"Authentication error detected in UI, redirecting to login...",
|
||||||
|
);
|
||||||
// Redirect to login page after a short delay
|
// Redirect to login page after a short delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if we have any event requests
|
||||||
|
const tableContainer = document.querySelector(".event-table-container");
|
||||||
|
if (tableContainer) {
|
||||||
|
console.log(
|
||||||
|
"Event table container found, component should load normally",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"Event table container not found, might be an issue with rendering",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,8 +1,11 @@
|
||||||
import { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
|
import { Update } from '../../../scripts/pocketbase/Update';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import EventRequestDetails from './EventRequestDetails';
|
||||||
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
||||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
|
|
||||||
|
@ -22,40 +25,27 @@ interface ExtendedEventRequest extends SchemaEventRequest {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
invoice_data?: any;
|
invoice_data?: any;
|
||||||
invoice_files?: string[]; // Array of invoice file IDs
|
|
||||||
status: "submitted" | "pending" | "completed" | "declined";
|
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 {
|
interface EventRequestManagementTableProps {
|
||||||
eventRequests: ExtendedEventRequest[];
|
eventRequests: ExtendedEventRequest[];
|
||||||
onRequestSelect: (request: ExtendedEventRequest) => void;
|
|
||||||
onStatusChange: (id: string, status: "submitted" | "pending" | "completed" | "declined") => Promise<void>;
|
|
||||||
isLoadingUserData?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EventRequestManagementTable = ({
|
const EventRequestManagementTable = ({ eventRequests: initialEventRequests }: EventRequestManagementTableProps) => {
|
||||||
eventRequests: initialEventRequests,
|
|
||||||
onRequestSelect,
|
|
||||||
onStatusChange,
|
|
||||||
isLoadingUserData = false
|
|
||||||
}: EventRequestManagementTableProps) => {
|
|
||||||
const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
const [eventRequests, setEventRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
||||||
const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
const [filteredRequests, setFilteredRequests] = useState<ExtendedEventRequest[]>(initialEventRequests);
|
||||||
|
const [selectedRequest, setSelectedRequest] = useState<ExtendedEventRequest | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('active');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||||
const [sortField, setSortField] = useState<string>('start_date_time');
|
const [sortField, setSortField] = useState<string>('created');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
// Add state for update modal
|
// Add state for update modal
|
||||||
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState<boolean>(false);
|
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState<boolean>(false);
|
||||||
const [requestToUpdate, setRequestToUpdate] = useState<ExtendedEventRequest | null>(null);
|
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
|
// Refresh event requests
|
||||||
const refreshEventRequests = async () => {
|
const refreshEventRequests = async () => {
|
||||||
|
@ -66,65 +56,23 @@ const EventRequestManagementTable = ({
|
||||||
// Don't check authentication here - try to fetch anyway
|
// Don't check authentication here - try to fetch anyway
|
||||||
// The token might be valid for the API even if isAuthenticated() returns false
|
// The token might be valid for the API even if isAuthenticated() returns false
|
||||||
|
|
||||||
// console.log("Fetching event requests...");
|
console.log("Fetching event requests...");
|
||||||
|
|
||||||
// Use DataSyncService to get data from IndexedDB with forced sync and deletion detection
|
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||||
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
|
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
|
||||||
Collections.EVENT_REQUESTS,
|
Collections.EVENT_REQUESTS,
|
||||||
true, // Force sync
|
true, // Force sync
|
||||||
'', // No filter - get all requests
|
'', // No filter
|
||||||
'-created',
|
'-created',
|
||||||
'requested_user', // Expand user data
|
'requested_user'
|
||||||
true // Enable deletion detection for all event requests
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// If we still have "Unknown" users, try to fetch them directly
|
console.log(`Fetched ${updatedRequests.length} event requests`);
|
||||||
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) {
|
setEventRequests(updatedRequests);
|
||||||
// TypeScript cast to access the properties
|
applyFilters(updatedRequests);
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error(`Error fetching user data for request ${request.id}:`, error);
|
console.error('Error refreshing event requests:', 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');
|
toast.error('Failed to refresh event requests');
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
|
@ -137,19 +85,9 @@ const EventRequestManagementTable = ({
|
||||||
|
|
||||||
// Apply status filter
|
// Apply status filter
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
if (statusFilter === 'active') {
|
filtered = filtered.filter(request =>
|
||||||
// Filter to show only submitted and pending events (hide completed and declined)
|
request.status?.toLowerCase() === statusFilter.toLowerCase()
|
||||||
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
|
// Apply search filter
|
||||||
|
@ -194,125 +132,46 @@ const EventRequestManagementTable = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update event request status
|
// Update event request status
|
||||||
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined", declineReason?: string): Promise<void> => {
|
const updateEventRequestStatus = async (id: string, status: "submitted" | "pending" | "completed" | "declined"): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Find the event request to get its current status and name
|
const update = Update.getInstance();
|
||||||
|
const result = await update.updateField('event_request', id, 'status', status);
|
||||||
|
|
||||||
|
// Find the event request to get its name
|
||||||
const eventRequest = eventRequests.find(req => req.id === id);
|
const eventRequest = eventRequests.find(req => req.id === id);
|
||||||
const eventName = eventRequest?.name || 'Event';
|
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
|
// Update local state
|
||||||
setEventRequests(prev =>
|
setEventRequests(prev =>
|
||||||
prev.map(request =>
|
prev.map(request =>
|
||||||
request.id === id ? {
|
request.id === id ? { ...request, status } : request
|
||||||
...request,
|
|
||||||
status,
|
|
||||||
...(status === 'declined' && declineReason ? { declined_reason: declineReason } : {})
|
|
||||||
} : request
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
setFilteredRequests(prev =>
|
setFilteredRequests(prev =>
|
||||||
prev.map(request =>
|
prev.map(request =>
|
||||||
request.id === id ? {
|
request.id === id ? { ...request, status } : request
|
||||||
...request,
|
|
||||||
status,
|
|
||||||
...(status === 'declined' && declineReason ? { declined_reason: declineReason } : {})
|
|
||||||
} : request
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update selected request if open
|
||||||
|
if (selectedRequest && selectedRequest.id === id) {
|
||||||
|
setSelectedRequest({ ...selectedRequest, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force sync to update IndexedDB
|
||||||
|
await dataSync.syncCollection<ExtendedEventRequest>(Collections.EVENT_REQUESTS);
|
||||||
|
|
||||||
|
// Show success toast with event name
|
||||||
toast.success(`"${eventName}" status updated to ${status}`);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error updating status:', error);
|
// Find the event request to get its name
|
||||||
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 eventRequest = eventRequests.find(req => req.id === id);
|
||||||
const eventName = eventRequest?.name || 'Event';
|
const eventName = eventRequest?.name || 'Event';
|
||||||
|
|
||||||
// Update local state
|
console.error('Error updating status:', error);
|
||||||
setEventRequests(prev =>
|
toast.error(`Failed to update status for "${eventName}"`);
|
||||||
prev.map(request =>
|
throw error; // Re-throw the error to be caught by the caller
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -333,50 +192,6 @@ const EventRequestManagementTable = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
// Get status badge class based on status
|
||||||
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
|
const getStatusBadge = (status?: "submitted" | "pending" | "completed" | "declined") => {
|
||||||
if (!status) return 'badge-warning';
|
if (!status) return 'badge-warning';
|
||||||
|
@ -395,45 +210,43 @@ const EventRequestManagementTable = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to truncate text
|
// Open modal with event request details
|
||||||
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) => {
|
const openDetailModal = (request: ExtendedEventRequest) => {
|
||||||
onRequestSelect(request);
|
setSelectedRequest(request);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
const closeModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedRequest(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open update modal
|
||||||
|
const openUpdateModal = (request: ExtendedEventRequest) => {
|
||||||
|
setRequestToUpdate(request);
|
||||||
|
setIsUpdateModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close update modal
|
||||||
|
const closeUpdateModal = () => {
|
||||||
|
setIsUpdateModalOpen(false);
|
||||||
|
setRequestToUpdate(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update status and close modal
|
||||||
|
const handleUpdateStatus = async (status: "submitted" | "pending" | "completed" | "declined") => {
|
||||||
|
if (requestToUpdate) {
|
||||||
|
try {
|
||||||
|
await updateEventRequestStatus(requestToUpdate.id, status);
|
||||||
|
// Toast is now shown in updateEventRequestStatus
|
||||||
|
closeUpdateModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in handleUpdateStatus:', error);
|
||||||
|
// Toast is now shown in updateEventRequestStatus
|
||||||
|
// Keep modal open so user can try again
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle sort change
|
// Handle sort change
|
||||||
|
@ -448,42 +261,10 @@ const EventRequestManagementTable = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
// Apply filters when filter state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyFilters();
|
applyFilters();
|
||||||
}, [statusFilter, searchTerm, sortField, sortDirection, eventRequests]);
|
}, [statusFilter, searchTerm, sortField, sortDirection]);
|
||||||
|
|
||||||
// Check authentication and refresh token if needed
|
// Check authentication and refresh token if needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -492,14 +273,14 @@ const EventRequestManagementTable = ({
|
||||||
|
|
||||||
// Check if we're authenticated
|
// Check if we're authenticated
|
||||||
if (!auth.isAuthenticated()) {
|
if (!auth.isAuthenticated()) {
|
||||||
// console.log("Authentication check failed - attempting to continue anyway");
|
console.log("Authentication check failed - attempting to continue anyway");
|
||||||
|
|
||||||
// Don't show error or redirect immediately - try to refresh first
|
// Don't show error or redirect immediately - try to refresh first
|
||||||
try {
|
try {
|
||||||
// Try to refresh event requests anyway - the token might be valid
|
// Try to refresh event requests anyway - the token might be valid
|
||||||
await refreshEventRequests();
|
await refreshEventRequests();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// console.error("Failed to refresh after auth check:", err);
|
console.error("Failed to refresh after auth check:", err);
|
||||||
toast.error("Authentication error. Please log in again.");
|
toast.error("Authentication error. Please log in again.");
|
||||||
|
|
||||||
// Only redirect if refresh fails
|
// Only redirect if refresh fails
|
||||||
|
@ -508,7 +289,7 @@ const EventRequestManagementTable = ({
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// console.log("Authentication check passed");
|
console.log("Authentication check passed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -523,7 +304,7 @@ const EventRequestManagementTable = ({
|
||||||
// Listen for tab visibility changes and refresh data when tab becomes visible
|
// Listen for tab visibility changes and refresh data when tab becomes visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTabVisible = () => {
|
const handleTabVisible = () => {
|
||||||
// console.log("Tab became visible, refreshing event requests...");
|
console.log("Tab became visible, refreshing event requests...");
|
||||||
refreshEventRequests();
|
refreshEventRequests();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -542,13 +323,13 @@ const EventRequestManagementTable = ({
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
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"
|
className="bg-base-200 rounded-xl p-8 text-center shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center justify-center py-6">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<h3 className="text-xl font-semibold mb-3 text-white">No Event Requests Found</h3>
|
<h3 className="text-xl font-semibold mb-3">No Event Requests Found</h3>
|
||||||
<p className="text-base-content/60 mb-6 max-w-md">
|
<p className="text-base-content/60 mb-6 max-w-md">
|
||||||
{statusFilter !== 'all' || searchTerm
|
{statusFilter !== 'all' || searchTerm
|
||||||
? 'No event requests match your current filters. Try adjusting your search criteria.'
|
? 'No event requests match your current filters. Try adjusting your search criteria.'
|
||||||
|
@ -556,7 +337,7 @@ const EventRequestManagementTable = ({
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-outline btn-sm gap-2"
|
className="btn btn-outline btn-sm gap-2"
|
||||||
onClick={refreshEventRequests}
|
onClick={refreshEventRequests}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
>
|
>
|
||||||
|
@ -576,7 +357,7 @@ const EventRequestManagementTable = ({
|
||||||
</button>
|
</button>
|
||||||
{(statusFilter !== 'all' || searchTerm) && (
|
{(statusFilter !== 'all' || searchTerm) && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost btn-sm gap-2"
|
className="btn btn-outline btn-sm gap-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStatusFilter('all');
|
setStatusFilter('all');
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
|
@ -603,43 +384,41 @@ const EventRequestManagementTable = ({
|
||||||
style={{ minHeight: "500px" }}
|
style={{ minHeight: "500px" }}
|
||||||
>
|
>
|
||||||
{/* Filters and controls */}
|
{/* 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 lg:flex-row justify-between items-start lg:items-center gap-4 mb-4">
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full lg:w-auto">
|
<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">
|
<div className="form-control w-full sm:w-auto">
|
||||||
|
<div className="input-group">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search events..."
|
placeholder="Search events..."
|
||||||
className="input bg-[#1e2029] border-[#2a2d3a] focus:border-primary w-full sm:w-64 pr-10 rounded-lg"
|
className="input input-bordered w-full sm:w-64"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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">
|
<button className="btn btn-square">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-full sm:w-auto">
|
</div>
|
||||||
<select
|
<select
|
||||||
className="select bg-[#1e2029] border-[#2a2d3a] focus:border-primary w-full appearance-none pr-10 rounded-lg"
|
className="select select-bordered w-full sm:w-auto"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="active">Active (Submitted & Pending)</option>
|
|
||||||
<option value="all">All Statuses</option>
|
<option value="all">All Statuses</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
<option value="declined">Declined</option>
|
<option value="declined">Declined</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 w-full lg:w-auto justify-between sm:justify-end">
|
<div className="flex items-center gap-3 w-full lg:w-auto justify-between sm:justify-end">
|
||||||
<span className="text-sm text-gray-400">
|
<span className="text-sm text-gray-400">
|
||||||
{filteredRequests.length} {filteredRequests.length === 1 ? 'request' : 'requests'} found
|
{filteredRequests.length} {filteredRequests.length === 1 ? 'request' : 'requests'} found
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-outline btn-sm gap-2"
|
className="btn btn-outline btn-sm gap-2"
|
||||||
onClick={refreshEventRequests}
|
onClick={refreshEventRequests}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
>
|
>
|
||||||
|
@ -662,121 +441,95 @@ const EventRequestManagementTable = ({
|
||||||
|
|
||||||
{/* Event requests table */}
|
{/* Event requests table */}
|
||||||
<div
|
<div
|
||||||
className="rounded-xl shadow-sm overflow-x-auto bg-base-100/10 border border-base-100/20"
|
className="rounded-xl shadow-sm overflow-x-auto"
|
||||||
style={{
|
style={{
|
||||||
maxHeight: "unset",
|
maxHeight: "unset",
|
||||||
height: "auto"
|
height: "auto"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<table className="table table-zebra w-full min-w-[600px]">
|
<table className="table table-zebra w-full">
|
||||||
<thead className="bg-base-300/50 sticky top-0 z-10">
|
<thead className="bg-base-300/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
className="cursor-pointer hover:bg-base-300 transition-colors"
|
className="cursor-pointer hover:bg-base-300"
|
||||||
onClick={() => handleSortChange('name')}
|
onClick={() => handleSortChange('name')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
Event Name
|
Event Name
|
||||||
{sortField === '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">
|
<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={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="cursor-pointer hover:bg-base-300 transition-colors hidden md:table-cell"
|
className="cursor-pointer hover:bg-base-300 hidden md:table-cell"
|
||||||
onClick={() => handleSortChange('start_date_time')}
|
onClick={() => handleSortChange('start_date_time')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
Date & Time
|
Date
|
||||||
{sortField === 'start_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">
|
<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={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="cursor-pointer hover:bg-base-300 transition-colors"
|
className="cursor-pointer hover:bg-base-300"
|
||||||
onClick={() => handleSortChange('requested_user')}
|
onClick={() => handleSortChange('requested_user')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
Requested By
|
Requested By
|
||||||
{sortField === 'requested_user' && (
|
{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">
|
<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={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="hidden lg:table-cell">PR Materials</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="hidden lg:table-cell">AS Funding</th>
|
||||||
<th
|
<th
|
||||||
className="cursor-pointer hover:bg-base-300 transition-colors hidden md:table-cell"
|
className="cursor-pointer hover:bg-base-300 hidden md:table-cell"
|
||||||
onClick={() => handleSortChange('created')}
|
onClick={() => handleSortChange('created')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
Submitted
|
Submitted
|
||||||
{sortField === 'created' && (
|
{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">
|
<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={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="cursor-pointer hover:bg-base-300 transition-colors"
|
className="cursor-pointer hover:bg-base-300"
|
||||||
onClick={() => handleSortChange('status')}
|
onClick={() => handleSortChange('status')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
Status
|
Status
|
||||||
{sortField === '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">
|
<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={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortDirection === 'asc' ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"} />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="w-20 min-w-[5rem]">Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredRequests.map((request) => (
|
{filteredRequests.map((request) => (
|
||||||
<tr key={request.id} className="hover transition-colors">
|
<tr key={request.id} className="hover">
|
||||||
<td className="font-medium">
|
<td className="font-medium">{request.name}</td>
|
||||||
<div className="truncate max-w-[180px] md:max-w-[250px]" title={request.name}>
|
<td className="hidden md:table-cell">{formatDate(request.start_date_time)}</td>
|
||||||
{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>
|
<td>
|
||||||
{(() => {
|
|
||||||
const { name, email } = getUserDisplayInfo(request);
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>{name}</span>
|
<span>{request.expand?.requested_user?.name || 'Unknown'}</span>
|
||||||
<span className="text-xs text-gray-400">{email}</span>
|
<span className="text-xs text-gray-400">{request.expand?.requested_user?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden lg:table-cell">
|
<td className="hidden lg:table-cell">
|
||||||
{request.flyers_needed ? (
|
{request.flyers_needed ? (
|
||||||
|
@ -785,28 +538,6 @@ const EventRequestManagementTable = ({
|
||||||
<span className="badge badge-ghost badge-sm">No</span>
|
<span className="badge badge-ghost badge-sm">No</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="hidden lg:table-cell">
|
||||||
{request.as_funding_required ? (
|
{request.as_funding_required ? (
|
||||||
<span className="badge badge-success badge-sm">Yes</span>
|
<span className="badge badge-success badge-sm">Yes</span>
|
||||||
|
@ -817,21 +548,25 @@ const EventRequestManagementTable = ({
|
||||||
<td className="hidden md:table-cell">{formatDate(request.created)}</td>
|
<td className="hidden md:table-cell">{formatDate(request.created)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${getStatusBadge(request.status)}`}>
|
<span className={`badge ${getStatusBadge(request.status)}`}>
|
||||||
{request.status?.charAt(0).toUpperCase() + request.status?.slice(1) || 'Pending'}
|
{request.status || 'Pending'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-primary btn-outline gap-1 min-h-[2rem] max-w-[5rem] flex-shrink-0"
|
className="btn btn-sm btn-outline"
|
||||||
onClick={() => openDetailModal(request)}
|
onClick={() => openUpdateModal(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">
|
Update
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => openDetailModal(request)}
|
||||||
|
>
|
||||||
|
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="hidden sm:inline">View</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -842,48 +577,83 @@ const EventRequestManagementTable = ({
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Decline Reason Modal */}
|
{/* Event request details modal - Now outside the main component div */}
|
||||||
{isDeclineModalOpen && (
|
{isModalOpen && selectedRequest && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<AnimatePresence>
|
||||||
|
<EventRequestDetails
|
||||||
|
request={selectedRequest}
|
||||||
|
onClose={closeModal}
|
||||||
|
onStatusChange={updateEventRequestStatus}
|
||||||
|
/>
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Update status modal */}
|
||||||
|
{isUpdateModalOpen && requestToUpdate && (
|
||||||
|
<AnimatePresence>
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
className="bg-base-200 rounded-lg p-6 w-full max-w-md shadow-xl"
|
transition={{ duration: 0.2 }}
|
||||||
|
className="bg-base-200 rounded-lg shadow-xl w-full max-w-md overflow-hidden flex flex-col"
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">
|
{/* Header */}
|
||||||
Decline Event Request
|
<div className="bg-base-300 p-4 flex justify-between items-center">
|
||||||
</h3>
|
<h3 className="text-xl font-bold">Update Status</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
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-sm btn-circle"
|
||||||
onClick={cancelDecline}
|
onClick={closeUpdateModal}
|
||||||
>
|
>
|
||||||
Cancel
|
<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>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="mb-4">
|
||||||
|
Update status for event: <span className="font-semibold">{requestToUpdate.name}</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline w-full justify-start"
|
||||||
|
onClick={() => handleUpdateStatus("pending")}
|
||||||
|
>
|
||||||
|
<span className="badge badge-warning mr-2">Pending</span>
|
||||||
|
Mark as Pending
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-error"
|
className="btn btn-outline w-full justify-start"
|
||||||
onClick={confirmDecline}
|
onClick={() => handleUpdateStatus("completed")}
|
||||||
disabled={!declineReason.trim()}
|
|
||||||
>
|
>
|
||||||
Decline Request
|
<span className="badge badge-success mr-2">Completed</span>
|
||||||
|
Mark as Completed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline w-full justify-start"
|
||||||
|
onClick={() => handleUpdateStatus("declined")}
|
||||||
|
>
|
||||||
|
<span className="badge badge-error mr-2">Declined</span>
|
||||||
|
Mark as Declined
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-base-300 flex justify-end">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={closeUpdateModal}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</AnimatePresence>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
|
|
@ -10,160 +10,6 @@ import { Stats } from "./ProfileSection/Stats";
|
||||||
<p class="opacity-70">Welcome to your IEEE UCSD dashboard</p>
|
<p class="opacity-70">Welcome to your IEEE UCSD dashboard</p>
|
||||||
</div>
|
</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 />
|
<Stats client:load />
|
||||||
|
|
||||||
<!-- Dashboard Content -->
|
<!-- Dashboard Content -->
|
||||||
|
|
|
@ -49,7 +49,7 @@ export default function ShowProfileLogs() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsFetchingAll(true);
|
setIsFetchingAll(true);
|
||||||
// console.log("Fetching logs for user:", userId);
|
console.log("Fetching logs for user:", userId);
|
||||||
|
|
||||||
// Use DataSyncService to fetch logs
|
// Use DataSyncService to fetch logs
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
|
@ -70,15 +70,15 @@ export default function ShowProfileLogs() {
|
||||||
"-created"
|
"-created"
|
||||||
);
|
);
|
||||||
|
|
||||||
// console.log("Fetched logs:", fetchedLogs.length);
|
console.log("Fetched logs:", fetchedLogs.length);
|
||||||
|
|
||||||
if (fetchedLogs.length === 0) {
|
if (fetchedLogs.length === 0) {
|
||||||
// If no logs found, try to fetch directly from PocketBase
|
// If no logs found, try to fetch directly from PocketBase
|
||||||
// console.log("No logs found in IndexedDB, trying direct fetch from PocketBase");
|
console.log("No logs found in IndexedDB, trying direct fetch from PocketBase");
|
||||||
try {
|
try {
|
||||||
const sendLog = SendLog.getInstance();
|
const sendLog = SendLog.getInstance();
|
||||||
const directLogs = await sendLog.getUserLogs(userId);
|
const directLogs = await sendLog.getUserLogs(userId);
|
||||||
// console.log("Direct fetch logs:", directLogs.length);
|
console.log("Direct fetch logs:", directLogs.length);
|
||||||
|
|
||||||
if (directLogs.length > 0) {
|
if (directLogs.length > 0) {
|
||||||
setAllLogs(directLogs);
|
setAllLogs(directLogs);
|
||||||
|
@ -90,7 +90,7 @@ export default function ShowProfileLogs() {
|
||||||
setTotalLogs(fetchedLogs.length);
|
setTotalLogs(fetchedLogs.length);
|
||||||
}
|
}
|
||||||
} catch (directError) {
|
} catch (directError) {
|
||||||
// console.error("Failed to fetch logs directly:", directError);
|
console.error("Failed to fetch logs directly:", directError);
|
||||||
setAllLogs(fetchedLogs);
|
setAllLogs(fetchedLogs);
|
||||||
setTotalPages(Math.ceil(fetchedLogs.length / LOGS_PER_PAGE));
|
setTotalPages(Math.ceil(fetchedLogs.length / LOGS_PER_PAGE));
|
||||||
setTotalLogs(fetchedLogs.length);
|
setTotalLogs(fetchedLogs.length);
|
||||||
|
@ -101,7 +101,7 @@ export default function ShowProfileLogs() {
|
||||||
setTotalLogs(fetchedLogs.length);
|
setTotalLogs(fetchedLogs.length);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("Failed to fetch logs:", error);
|
console.error("Failed to fetch logs:", error);
|
||||||
setError("Error loading activity");
|
setError("Error loading activity");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
@ -134,7 +134,7 @@ export default function ShowProfileLogs() {
|
||||||
// Update displayed logs whenever filtered results change
|
// Update displayed logs whenever filtered results change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLogs(filteredLogs);
|
setLogs(filteredLogs);
|
||||||
// console.log("Filtered logs updated:", filteredLogs.length, "logs");
|
console.log("Filtered logs updated:", filteredLogs.length, "logs");
|
||||||
}, [filteredLogs]);
|
}, [filteredLogs]);
|
||||||
|
|
||||||
// Debounced search handler
|
// Debounced search handler
|
||||||
|
@ -178,12 +178,12 @@ export default function ShowProfileLogs() {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
// Check if logs were loaded
|
// Check if logs were loaded
|
||||||
if (allLogs.length === 0) {
|
if (allLogs.length === 0) {
|
||||||
// console.log("No logs found after initial fetch, trying direct fetch");
|
console.log("No logs found after initial fetch, trying direct fetch");
|
||||||
await directFetchLogs();
|
await directFetchLogs();
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("Failed to load logs with retry:", error);
|
console.error("Failed to load logs with retry:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -200,14 +200,14 @@ export default function ShowProfileLogs() {
|
||||||
|
|
||||||
// Check if the logs collection exists and has any records
|
// Check if the logs collection exists and has any records
|
||||||
const result = await pb.collection(Collections.LOGS).getList(1, 1);
|
const result = await pb.collection(Collections.LOGS).getList(1, 1);
|
||||||
// console.log("Logs collection check:", {
|
console.log("Logs collection check:", {
|
||||||
// totalItems: result.totalItems,
|
totalItems: result.totalItems,
|
||||||
// page: result.page,
|
page: result.page,
|
||||||
// perPage: result.perPage,
|
perPage: result.perPage,
|
||||||
// totalPages: result.totalPages
|
totalPages: result.totalPages
|
||||||
// });
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("Failed to check logs collection:", error);
|
console.error("Failed to check logs collection:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -255,7 +255,7 @@ export default function ShowProfileLogs() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log("Direct fetching logs for user:", userId);
|
console.log("Direct fetching logs for user:", userId);
|
||||||
|
|
||||||
// Fetch logs directly from PocketBase
|
// Fetch logs directly from PocketBase
|
||||||
const result = await pb.collection(Collections.LOGS).getList<Log>(1, 100, {
|
const result = await pb.collection(Collections.LOGS).getList<Log>(1, 100, {
|
||||||
|
@ -264,10 +264,10 @@ export default function ShowProfileLogs() {
|
||||||
expand: "user"
|
expand: "user"
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log("Direct fetch result:", {
|
console.log("Direct fetch result:", {
|
||||||
// totalItems: result.totalItems,
|
totalItems: result.totalItems,
|
||||||
// items: result.items.length
|
items: result.items.length
|
||||||
// });
|
});
|
||||||
|
|
||||||
if (result.items.length > 0) {
|
if (result.items.length > 0) {
|
||||||
setAllLogs(result.items);
|
setAllLogs(result.items);
|
||||||
|
@ -275,7 +275,7 @@ export default function ShowProfileLogs() {
|
||||||
setTotalLogs(result.items.length);
|
setTotalLogs(result.items.length);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("Failed to direct fetch logs:", error);
|
console.error("Failed to direct fetch logs:", error);
|
||||||
setError("Error loading activity");
|
setError("Error loading activity");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
@ -315,13 +315,13 @@ export default function ShowProfileLogs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs
|
// Debug logs
|
||||||
// console.log("Render state:", {
|
console.log("Render state:", {
|
||||||
// logsLength: logs.length,
|
logsLength: logs.length,
|
||||||
// allLogsLength: allLogs.length,
|
allLogsLength: allLogs.length,
|
||||||
// searchQuery,
|
searchQuery,
|
||||||
// loading,
|
loading,
|
||||||
// currentPage
|
currentPage
|
||||||
// });
|
});
|
||||||
|
|
||||||
if (allLogs.length === 0 && !searchQuery && !loading) {
|
if (allLogs.length === 0 && !searchQuery && !loading) {
|
||||||
return (
|
return (
|
||||||
|
@ -348,10 +348,10 @@ export default function ShowProfileLogs() {
|
||||||
"Test log created for debugging",
|
"Test log created for debugging",
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
// console.log("Created test log");
|
console.log("Created test log");
|
||||||
setTimeout(() => fetchLogs(true), 1000);
|
setTimeout(() => fetchLogs(true), 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("Failed to create test log:", error);
|
console.error("Failed to create test log:", error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||||
|
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||||
import type { Event, User, LimitedUser } from "../../../schemas/pocketbase";
|
import type { Event, Log, User } from "../../../schemas/pocketbase";
|
||||||
import { Get } from "../../../scripts/pocketbase/Get";
|
import { Get } from "../../../scripts/pocketbase/Get";
|
||||||
import type { EventAttendee } from "../../../schemas/pocketbase";
|
import type { EventAttendee } from "../../../schemas/pocketbase";
|
||||||
import { Update } from "../../../scripts/pocketbase/Update";
|
import { Update } from "../../../scripts/pocketbase/Update";
|
||||||
|
|
||||||
// Extended User interface with member_type property
|
// Extended User interface with points property
|
||||||
interface ExtendedUser extends User {
|
interface ExtendedUser extends User {
|
||||||
|
points?: number;
|
||||||
member_type?: string;
|
member_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,13 +84,25 @@ export function Stats() {
|
||||||
|
|
||||||
setEventsAttended(attendedEvents.totalItems);
|
setEventsAttended(attendedEvents.totalItems);
|
||||||
|
|
||||||
// Calculate points from attendees
|
// Get user points - either from the user record or calculate from attendees
|
||||||
let totalPoints = 0;
|
let totalPoints = 0;
|
||||||
|
|
||||||
// Calculate quarterly points
|
// Calculate quarterly points
|
||||||
const quarterStartDate = getCurrentQuarterStartDate();
|
const quarterStartDate = getCurrentQuarterStartDate();
|
||||||
let pointsThisQuarter = 0;
|
let pointsThisQuarter = 0;
|
||||||
|
|
||||||
|
// If user has points field, use that for total points
|
||||||
|
if (currentUser && currentUser.points !== undefined) {
|
||||||
|
totalPoints = currentUser.points;
|
||||||
|
|
||||||
|
// Still need to calculate quarterly points from attendees
|
||||||
|
attendedEvents.items.forEach(attendee => {
|
||||||
|
const checkinDate = new Date(attendee.time_checked_in);
|
||||||
|
if (checkinDate >= quarterStartDate) {
|
||||||
|
pointsThisQuarter += attendee.points_earned || 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
// Calculate both total and quarterly points from attendees
|
// Calculate both total and quarterly points from attendees
|
||||||
attendedEvents.items.forEach(attendee => {
|
attendedEvents.items.forEach(attendee => {
|
||||||
const points = attendee.points_earned || 0;
|
const points = attendee.points_earned || 0;
|
||||||
|
@ -100,26 +114,17 @@ export function Stats() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to get the LimitedUser record to check if points match
|
// Update the user record with calculated points if needed
|
||||||
|
if (currentUser) {
|
||||||
try {
|
try {
|
||||||
const limitedUserRecord = await get.getOne(
|
const update = Update.getInstance();
|
||||||
Collections.LIMITED_USERS,
|
await update.updateFields(Collections.USERS, currentUser.id, {
|
||||||
userId
|
points: totalPoints
|
||||||
);
|
});
|
||||||
|
} catch (error) {
|
||||||
if (limitedUserRecord && limitedUserRecord.points) {
|
console.error("Error updating user points:", error);
|
||||||
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);
|
setPointsEarned(totalPoints);
|
||||||
|
@ -194,7 +199,7 @@ export function Stats() {
|
||||||
</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="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">
|
||||||
<div className="stat-title font-medium opacity-80">Points</div>
|
<div className="stat-title font-medium opacity-80">Loyalty Points</div>
|
||||||
<div className="stat-value text-secondary">{loyaltyPoints}</div>
|
<div className="stat-value text-secondary">{loyaltyPoints}</div>
|
||||||
<div className="stat-desc flex flex-col items-start gap-1 mt-1">
|
<div className="stat-desc flex flex-col items-start gap-1 mt-1">
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -4,23 +4,6 @@ import UserProfileSettings from "./SettingsSection/UserProfileSettings";
|
||||||
import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings";
|
import AccountSecuritySettings from "./SettingsSection/AccountSecuritySettings";
|
||||||
import NotificationSettings from "./SettingsSection/NotificationSettings";
|
import NotificationSettings from "./SettingsSection/NotificationSettings";
|
||||||
import DisplaySettings from "./SettingsSection/DisplaySettings";
|
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 id="settings-section" class="">
|
||||||
|
@ -29,69 +12,13 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
|
||||||
<p class="opacity-70">Manage your account settings and preferences</p>
|
<p class="opacity-70">Manage your account settings and preferences</p>
|
||||||
</div>
|
</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 -->
|
<!-- Profile Settings Card -->
|
||||||
<div
|
<div
|
||||||
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
||||||
>
|
>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3 class="card-title flex items-center gap-3">
|
<h3 class="card-title flex items-center gap-3">
|
||||||
<div
|
<div class="badge badge-primary p-3">
|
||||||
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" />
|
<Icon name="heroicons:user" class="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
Profile Information
|
Profile Information
|
||||||
|
@ -99,46 +26,18 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
|
||||||
<p class="text-sm opacity-70 mb-4">
|
<p class="text-sm opacity-70 mb-4">
|
||||||
Update your personal information and profile details
|
Update your personal information and profile details
|
||||||
</p>
|
</p>
|
||||||
<div class="h-px w-full bg-border my-4"></div>
|
<div class="divider"></div>
|
||||||
<UserProfileSettings
|
<UserProfileSettings client:load />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Account Security Settings Card -->
|
<!-- Account Security Settings Card -->
|
||||||
<div
|
<div
|
||||||
class="card bg-card shadow-xl border border-border hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6"
|
||||||
>
|
>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3 class="card-title flex items-center gap-3">
|
<h3 class="card-title flex items-center gap-3">
|
||||||
<div
|
<div class="badge badge-primary p-3">
|
||||||
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" />
|
<Icon name="heroicons:lock-closed" class="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
Account Security
|
Account Security
|
||||||
|
@ -146,25 +45,18 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
|
||||||
<p class="text-sm opacity-70 mb-4">
|
<p class="text-sm opacity-70 mb-4">
|
||||||
Manage your account security settings and authentication options
|
Manage your account security settings and authentication options
|
||||||
</p>
|
</p>
|
||||||
<div class="h-px w-full bg-border my-4"></div>
|
<div class="divider"></div>
|
||||||
<AccountSecuritySettings
|
<AccountSecuritySettings client:load />
|
||||||
client:load
|
|
||||||
logtoAppId={safeLogtoAppId}
|
|
||||||
logtoAppSecret={safeLogtoAppSecret}
|
|
||||||
logtoEndpoint={safeLogtoEndpoint}
|
|
||||||
logtoTokenEndpoint={safeLogtoTokenEndpoint}
|
|
||||||
logtoApiEndpoint={safeLogtoApiEndpoint}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notification Settings Card -->
|
<!-- Notification Settings Card -->
|
||||||
<div
|
<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"
|
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all duration-300 hover:-translate-y-1 transform mb-6 relative group"
|
||||||
>
|
>
|
||||||
<!-- Coming Soon Overlay -->
|
<!-- Coming Soon Overlay -->
|
||||||
<div
|
<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"
|
class="absolute inset-0 bg-base-300 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">
|
<div class="text-center">
|
||||||
<h4 class="text-xl font-bold">Coming Soon</h4>
|
<h4 class="text-xl font-bold">Coming Soon</h4>
|
||||||
|
@ -176,9 +68,7 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
|
||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3 class="card-title flex items-center gap-3">
|
<h3 class="card-title flex items-center gap-3">
|
||||||
<div
|
<div class="badge badge-primary p-3">
|
||||||
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" />
|
<Icon name="heroicons:bell" class="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
Notification Preferences
|
Notification Preferences
|
||||||
|
@ -186,7 +76,7 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
|
||||||
<p class="text-sm opacity-70 mb-4">
|
<p class="text-sm opacity-70 mb-4">
|
||||||
Customize how and when you receive notifications
|
Customize how and when you receive notifications
|
||||||
</p>
|
</p>
|
||||||
<div class="h-px w-full bg-border my-4"></div>
|
<div class="divider"></div>
|
||||||
<NotificationSettings client:load />
|
<NotificationSettings client:load />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -206,29 +96,6 @@ const safeLogtoApiEndpoint = logtoApiEndpoint || "";
|
||||||
Customize your dashboard appearance and display preferences
|
Customize your dashboard appearance and display preferences
|
||||||
</p>
|
</p>
|
||||||
<div class="divider"></div>
|
<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 />
|
<DisplaySettings client:load />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,23 +2,8 @@ import { useState, useEffect } from 'react';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { SendLog } from '../../../scripts/pocketbase/SendLog';
|
import { SendLog } from '../../../scripts/pocketbase/SendLog';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import PasswordChangeSettings from './PasswordChangeSettings';
|
|
||||||
|
|
||||||
interface AccountSecuritySettingsProps {
|
export default function AccountSecuritySettings() {
|
||||||
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 auth = Authentication.getInstance();
|
||||||
const logger = SendLog.getInstance();
|
const logger = SendLog.getInstance();
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
@ -59,7 +44,16 @@ export default function AccountSecuritySettings({
|
||||||
checkAuth();
|
checkAuth();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// No logout functions needed here as logout is handled in the dashboard menu
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await logger.send('logout', 'auth', 'User manually logged out from settings page');
|
||||||
|
await auth.logout();
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during logout:', error);
|
||||||
|
toast.error('Failed to log out. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const detectBrowser = (userAgent: string): string => {
|
const detectBrowser = (userAgent: string): string => {
|
||||||
if (userAgent.indexOf('Chrome') > -1) return 'Chrome';
|
if (userAgent.indexOf('Chrome') > -1) return 'Chrome';
|
||||||
|
@ -132,30 +126,6 @@ export default function AccountSecuritySettings({
|
||||||
</div>
|
</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 */}
|
{/* Authentication Options */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-lg mb-2">Authentication Options</h4>
|
<h4 className="font-semibold text-lg mb-2">Authentication Options</h4>
|
||||||
|
@ -163,6 +133,10 @@ export default function AccountSecuritySettings({
|
||||||
IEEE UCSD uses Single Sign-On (SSO) for authentication.
|
IEEE UCSD uses Single Sign-On (SSO) for authentication.
|
||||||
Password management is handled through your IEEEUCSD account.
|
Password management is handled through your IEEEUCSD account.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p className="text-sm text-info p-3 bg-info bg-opacity-10 rounded-lg">
|
||||||
|
To change your password, please use the "Forgot Password" option on the login page.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Account Actions */}
|
{/* Account Actions */}
|
||||||
|
@ -170,13 +144,17 @@ export default function AccountSecuritySettings({
|
||||||
<h4 className="font-semibold text-lg mb-2">Account Actions</h4>
|
<h4 className="font-semibold text-lg mb-2">Account Actions</h4>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="btn btn-error btn-outline w-full md:w-auto"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
|
||||||
<p className="text-sm text-warning p-3 bg-warning bg-opacity-10 rounded-lg">
|
<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,
|
If you need to delete your account or have other account-related issues,
|
||||||
please contact an IEEE UCSD administrator.
|
please contact an IEEE UCSD administrator.
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,44 +3,59 @@ import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { Update } from '../../../scripts/pocketbase/Update';
|
import { Update } from '../../../scripts/pocketbase/Update';
|
||||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { ThemeService, DEFAULT_THEME_SETTINGS, type ThemeSettings } from '../../../scripts/database/ThemeService';
|
|
||||||
|
// Default display preferences
|
||||||
|
const DEFAULT_DISPLAY_PREFERENCES = {
|
||||||
|
theme: 'dark',
|
||||||
|
fontSize: 'medium'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default accessibility settings
|
||||||
|
const DEFAULT_ACCESSIBILITY_SETTINGS = {
|
||||||
|
colorBlindMode: false,
|
||||||
|
reducedMotion: false
|
||||||
|
};
|
||||||
|
|
||||||
export default function DisplaySettings() {
|
export default function DisplaySettings() {
|
||||||
const auth = Authentication.getInstance();
|
const auth = Authentication.getInstance();
|
||||||
const update = Update.getInstance();
|
const update = Update.getInstance();
|
||||||
const themeService = ThemeService.getInstance();
|
const [theme, setTheme] = useState(DEFAULT_DISPLAY_PREFERENCES.theme);
|
||||||
|
const [fontSize, setFontSize] = useState(DEFAULT_DISPLAY_PREFERENCES.fontSize);
|
||||||
// Current applied settings
|
const [colorBlindMode, setColorBlindMode] = useState(DEFAULT_ACCESSIBILITY_SETTINGS.colorBlindMode);
|
||||||
const [currentSettings, setCurrentSettings] = useState<ThemeSettings | null>(null);
|
const [reducedMotion, setReducedMotion] = useState(DEFAULT_ACCESSIBILITY_SETTINGS.reducedMotion);
|
||||||
|
|
||||||
// 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);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// Track if form has unsaved changes
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
|
||||||
|
|
||||||
// Load saved preferences on component mount
|
// Load saved preferences on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadPreferences = async () => {
|
const loadPreferences = async () => {
|
||||||
try {
|
try {
|
||||||
// First load theme settings from IndexedDB
|
// First check localStorage for immediate UI updates
|
||||||
const themeSettings = await themeService.getThemeSettings();
|
const savedTheme = localStorage.getItem('theme') || DEFAULT_DISPLAY_PREFERENCES.theme;
|
||||||
|
// Ensure theme is either light or dark
|
||||||
|
const validTheme = ['light', 'dark'].includes(savedTheme) ? savedTheme : DEFAULT_DISPLAY_PREFERENCES.theme;
|
||||||
|
const savedFontSize = localStorage.getItem('fontSize') || DEFAULT_DISPLAY_PREFERENCES.fontSize;
|
||||||
|
const savedColorBlindMode = localStorage.getItem('colorBlindMode') === 'true';
|
||||||
|
const savedReducedMotion = localStorage.getItem('reducedMotion') === 'true';
|
||||||
|
|
||||||
// Store current settings
|
setTheme(validTheme);
|
||||||
setCurrentSettings(themeSettings);
|
setFontSize(savedFontSize);
|
||||||
|
setColorBlindMode(savedColorBlindMode);
|
||||||
|
setReducedMotion(savedReducedMotion);
|
||||||
|
|
||||||
// Set form state from theme settings
|
// Apply theme to document
|
||||||
setTheme(themeSettings.theme);
|
document.documentElement.setAttribute('data-theme', validTheme);
|
||||||
setFontSize(themeSettings.fontSize);
|
|
||||||
setColorBlindMode(themeSettings.colorBlindMode);
|
|
||||||
setReducedMotion(themeSettings.reducedMotion);
|
|
||||||
|
|
||||||
// Reset changes flag
|
// Apply font size
|
||||||
setHasChanges(false);
|
applyFontSize(savedFontSize);
|
||||||
|
|
||||||
|
// Apply accessibility settings
|
||||||
|
if (savedColorBlindMode) {
|
||||||
|
document.documentElement.classList.add('color-blind-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedReducedMotion) {
|
||||||
|
document.documentElement.classList.add('reduced-motion');
|
||||||
|
}
|
||||||
|
|
||||||
// Then check if user has saved preferences in their profile
|
// Then check if user has saved preferences in their profile
|
||||||
const user = auth.getCurrentUser();
|
const user = auth.getCurrentUser();
|
||||||
|
@ -53,20 +68,20 @@ export default function DisplaySettings() {
|
||||||
try {
|
try {
|
||||||
const userPrefs = JSON.parse(user.display_preferences);
|
const userPrefs = JSON.parse(user.display_preferences);
|
||||||
|
|
||||||
// Only update if values exist and are different from IndexedDB
|
// Only update if values exist and are different from localStorage
|
||||||
if (userPrefs.theme && ['light', 'dark'].includes(userPrefs.theme) && userPrefs.theme !== themeSettings.theme) {
|
if (userPrefs.theme && ['light', 'dark'].includes(userPrefs.theme) && userPrefs.theme !== validTheme) {
|
||||||
setTheme(userPrefs.theme as 'light' | 'dark');
|
setTheme(userPrefs.theme);
|
||||||
// Don't update theme service yet, wait for save
|
localStorage.setItem('theme', userPrefs.theme);
|
||||||
setHasChanges(true);
|
document.documentElement.setAttribute('data-theme', userPrefs.theme);
|
||||||
} else if (!['light', 'dark'].includes(userPrefs.theme)) {
|
} else if (!['light', 'dark'].includes(userPrefs.theme)) {
|
||||||
// If theme is not valid, mark for update
|
// If theme is not valid, mark for update
|
||||||
needsDisplayPrefsUpdate = true;
|
needsDisplayPrefsUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userPrefs.fontSize && userPrefs.fontSize !== themeSettings.fontSize) {
|
if (userPrefs.fontSize && userPrefs.fontSize !== savedFontSize) {
|
||||||
setFontSize(userPrefs.fontSize);
|
setFontSize(userPrefs.fontSize);
|
||||||
// Don't update theme service yet, wait for save
|
localStorage.setItem('fontSize', userPrefs.fontSize);
|
||||||
setHasChanges(true);
|
applyFontSize(userPrefs.fontSize);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing display preferences:', e);
|
console.error('Error parsing display preferences:', e);
|
||||||
|
@ -82,17 +97,27 @@ export default function DisplaySettings() {
|
||||||
const accessibilityPrefs = JSON.parse(user.accessibility_settings);
|
const accessibilityPrefs = JSON.parse(user.accessibility_settings);
|
||||||
|
|
||||||
if (typeof accessibilityPrefs.colorBlindMode === 'boolean' &&
|
if (typeof accessibilityPrefs.colorBlindMode === 'boolean' &&
|
||||||
accessibilityPrefs.colorBlindMode !== themeSettings.colorBlindMode) {
|
accessibilityPrefs.colorBlindMode !== savedColorBlindMode) {
|
||||||
setColorBlindMode(accessibilityPrefs.colorBlindMode);
|
setColorBlindMode(accessibilityPrefs.colorBlindMode);
|
||||||
// Don't update theme service yet, wait for save
|
localStorage.setItem('colorBlindMode', accessibilityPrefs.colorBlindMode.toString());
|
||||||
setHasChanges(true);
|
|
||||||
|
if (accessibilityPrefs.colorBlindMode) {
|
||||||
|
document.documentElement.classList.add('color-blind-mode');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('color-blind-mode');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof accessibilityPrefs.reducedMotion === 'boolean' &&
|
if (typeof accessibilityPrefs.reducedMotion === 'boolean' &&
|
||||||
accessibilityPrefs.reducedMotion !== themeSettings.reducedMotion) {
|
accessibilityPrefs.reducedMotion !== savedReducedMotion) {
|
||||||
setReducedMotion(accessibilityPrefs.reducedMotion);
|
setReducedMotion(accessibilityPrefs.reducedMotion);
|
||||||
// Don't update theme service yet, wait for save
|
localStorage.setItem('reducedMotion', accessibilityPrefs.reducedMotion.toString());
|
||||||
setHasChanges(true);
|
|
||||||
|
if (accessibilityPrefs.reducedMotion) {
|
||||||
|
document.documentElement.classList.add('reduced-motion');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('reduced-motion');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing accessibility settings:', e);
|
console.error('Error parsing accessibility settings:', e);
|
||||||
|
@ -116,23 +141,6 @@ export default function DisplaySettings() {
|
||||||
loadPreferences();
|
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
|
// Initialize default settings if not set
|
||||||
const initializeDefaultSettings = async (userId: string, updateDisplayPrefs: boolean, updateAccessibility: boolean) => {
|
const initializeDefaultSettings = async (userId: string, updateDisplayPrefs: boolean, updateAccessibility: boolean) => {
|
||||||
try {
|
try {
|
||||||
|
@ -154,38 +162,91 @@ export default function DisplaySettings() {
|
||||||
|
|
||||||
if (Object.keys(updateData).length > 0) {
|
if (Object.keys(updateData).length > 0) {
|
||||||
await update.updateFields(Collections.USERS, userId, updateData);
|
await update.updateFields(Collections.USERS, userId, updateData);
|
||||||
|
console.log('Initialized default display and accessibility settings');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing default settings:', error);
|
console.error('Error initializing default settings:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Apply font size to document
|
||||||
|
const applyFontSize = (size: string) => {
|
||||||
|
const htmlElement = document.documentElement;
|
||||||
|
|
||||||
|
// Remove existing font size classes
|
||||||
|
htmlElement.classList.remove('text-sm', 'text-base', 'text-lg', 'text-xl');
|
||||||
|
|
||||||
|
// Add new font size class
|
||||||
|
switch (size) {
|
||||||
|
case 'small':
|
||||||
|
htmlElement.classList.add('text-sm');
|
||||||
|
break;
|
||||||
|
case 'medium':
|
||||||
|
htmlElement.classList.add('text-base');
|
||||||
|
break;
|
||||||
|
case 'large':
|
||||||
|
htmlElement.classList.add('text-lg');
|
||||||
|
break;
|
||||||
|
case 'extra-large':
|
||||||
|
htmlElement.classList.add('text-xl');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle theme change
|
// Handle theme change
|
||||||
const handleThemeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleThemeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const newTheme = e.target.value as 'light' | 'dark';
|
const newTheme = e.target.value;
|
||||||
setTheme(newTheme);
|
setTheme(newTheme);
|
||||||
// Changes will be applied on save
|
|
||||||
|
// Apply theme to document
|
||||||
|
document.documentElement.setAttribute('data-theme', newTheme);
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle font size change
|
// Handle font size change
|
||||||
const handleFontSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleFontSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const newSize = e.target.value as 'small' | 'medium' | 'large' | 'extra-large';
|
const newSize = e.target.value;
|
||||||
setFontSize(newSize);
|
setFontSize(newSize);
|
||||||
// Changes will be applied on save
|
|
||||||
|
// Apply font size
|
||||||
|
applyFontSize(newSize);
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('fontSize', newSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle color blind mode toggle
|
// Handle color blind mode toggle
|
||||||
const handleColorBlindModeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleColorBlindModeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const enabled = e.target.checked;
|
const enabled = e.target.checked;
|
||||||
setColorBlindMode(enabled);
|
setColorBlindMode(enabled);
|
||||||
// Changes will be applied on save
|
|
||||||
|
// Apply to document
|
||||||
|
if (enabled) {
|
||||||
|
document.documentElement.classList.add('color-blind-mode');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('color-blind-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('colorBlindMode', enabled.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle reduced motion toggle
|
// Handle reduced motion toggle
|
||||||
const handleReducedMotionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleReducedMotionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const enabled = e.target.checked;
|
const enabled = e.target.checked;
|
||||||
setReducedMotion(enabled);
|
setReducedMotion(enabled);
|
||||||
// Changes will be applied on save
|
|
||||||
|
// Apply to document
|
||||||
|
if (enabled) {
|
||||||
|
document.documentElement.classList.add('reduced-motion');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('reduced-motion');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('reducedMotion', enabled.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
|
@ -209,17 +270,7 @@ export default function DisplaySettings() {
|
||||||
reducedMotion
|
reducedMotion
|
||||||
};
|
};
|
||||||
|
|
||||||
// First update IndexedDB with the new settings
|
// Update user record
|
||||||
await themeService.saveThemeSettings({
|
|
||||||
id: "current",
|
|
||||||
theme,
|
|
||||||
fontSize,
|
|
||||||
colorBlindMode,
|
|
||||||
reducedMotion,
|
|
||||||
updatedAt: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then update user record in PocketBase
|
|
||||||
await update.updateFields(
|
await update.updateFields(
|
||||||
Collections.USERS,
|
Collections.USERS,
|
||||||
user.id,
|
user.id,
|
||||||
|
@ -229,19 +280,6 @@ export default function DisplaySettings() {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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
|
// Show success message
|
||||||
toast.success('Display settings saved successfully!');
|
toast.success('Display settings saved successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -329,25 +367,18 @@ export default function DisplaySettings() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-info">
|
<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.
|
These settings are saved to your browser and your IEEE UCSD account. They will be applied whenever you log in.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="form-control">
|
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`btn btn-primary ${saving ? 'loading' : ''}`}
|
className={`btn btn-primary ${saving ? 'loading' : ''}`}
|
||||||
disabled={saving || !hasChanges}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : 'Save Settings'}
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -67,7 +67,7 @@ export default function NotificationSettings() {
|
||||||
{ notification_preferences: JSON.stringify(DEFAULT_NOTIFICATION_PREFERENCES) }
|
{ notification_preferences: JSON.stringify(DEFAULT_NOTIFICATION_PREFERENCES) }
|
||||||
);
|
);
|
||||||
setPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
setPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||||
// console.log('Initialized default notification preferences');
|
console.log('Initialized default notification preferences');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing default notification preferences:', error);
|
console.error('Error initializing default notification preferences:', error);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -2,195 +2,18 @@ import { useState, useEffect } from 'react';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { Update } from '../../../scripts/pocketbase/Update';
|
import { Update } from '../../../scripts/pocketbase/Update';
|
||||||
import { Collections, type User } from '../../../schemas/pocketbase/schema';
|
import { Collections, type User } from '../../../schemas/pocketbase/schema';
|
||||||
|
import allMajors from '../../../data/allUCSDMajors.txt?raw';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
// Define the majors directly in the component file to avoid import issues
|
export default function UserProfileSettings() {
|
||||||
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 auth = Authentication.getInstance();
|
||||||
const update = Update.getInstance();
|
const update = Update.getInstance();
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [logtoUserId, setLogtoUserId] = useState('');
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
username: '',
|
|
||||||
major: '',
|
major: '',
|
||||||
graduation_year: '',
|
graduation_year: '',
|
||||||
zelle_information: '',
|
zelle_information: '',
|
||||||
|
@ -198,143 +21,38 @@ export default function UserProfileSettings({
|
||||||
member_id: ''
|
member_id: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
// Access environment variables directly
|
// Parse the majors list from the text file and sort alphabetically
|
||||||
const envLogtoApiEndpoint = import.meta.env.LOGTO_API_ENDPOINT;
|
const majorsList = allMajors
|
||||||
|
.split('\n')
|
||||||
// Use environment variables or props (fallback)
|
.filter(major => major.trim() !== '')
|
||||||
const logtoApiEndpoint = envLogtoApiEndpoint || propLogtoApiEndpoint;
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadUserData = async () => {
|
const loadUserData = async () => {
|
||||||
try {
|
try {
|
||||||
const currentUser = auth.getCurrentUser();
|
const currentUser = auth.getCurrentUser();
|
||||||
if (!currentUser) {
|
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);
|
setUser(currentUser);
|
||||||
setFormData({
|
setFormData({
|
||||||
name: currentUser.name || '',
|
name: currentUser.name || '',
|
||||||
email: currentUser.email || '',
|
email: currentUser.email || '',
|
||||||
username: defaultUsername,
|
|
||||||
major: currentUser.major || '',
|
major: currentUser.major || '',
|
||||||
graduation_year: currentUser.graduation_year?.toString() || '',
|
graduation_year: currentUser.graduation_year?.toString() || '',
|
||||||
zelle_information: currentUser.zelle_information || '',
|
zelle_information: currentUser.zelle_information || '',
|
||||||
pid: currentUser.pid || '',
|
pid: currentUser.pid || '',
|
||||||
member_id: currentUser.member_id || ''
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading user data:', 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.');
|
toast.error('Failed to load user data. Please try again later.');
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadUserData();
|
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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
@ -350,12 +68,6 @@ export default function UserProfileSettings({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!user) throw new Error('User not authenticated');
|
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> = {
|
const updateData: Partial<User> = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
|
@ -419,22 +131,6 @@ export default function UserProfileSettings({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text">Email Address</span>
|
<span className="label-text">Email Address</span>
|
||||||
|
@ -498,21 +194,11 @@ export default function UserProfileSettings({
|
||||||
className="select select-bordered w-full"
|
className="select select-bordered w-full"
|
||||||
>
|
>
|
||||||
<option value="">Select a major</option>
|
<option value="">Select a major</option>
|
||||||
{(() => {
|
{majorsList.map((major, index) => (
|
||||||
// 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}>
|
<option key={index} value={major}>
|
||||||
{major}
|
{major}
|
||||||
</option>
|
</option>
|
||||||
));
|
))}
|
||||||
})()}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
99
src/components/dashboard/SponsorAnalytics.astro
Normal file
99
src/components/dashboard/SponsorAnalytics.astro
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
---
|
||||||
|
// Sponsor Analytics Component
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl mb-4">Analytics Dashboard</h2>
|
||||||
|
|
||||||
|
<!-- Metrics Overview -->
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||||
|
<div class="stat bg-base-200 rounded-box p-4">
|
||||||
|
<div class="stat-title">Resume Downloads</div>
|
||||||
|
<div class="stat-value text-primary">89</div>
|
||||||
|
<div class="stat-desc">↗︎ 14 (30 days)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat bg-base-200 rounded-box p-4">
|
||||||
|
<div class="stat-title">Event Attendance</div>
|
||||||
|
<div class="stat-value">45</div>
|
||||||
|
<div class="stat-desc">↘︎ 5 (30 days)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat bg-base-200 rounded-box p-4">
|
||||||
|
<div class="stat-title">Student Interactions</div>
|
||||||
|
<div class="stat-value text-secondary">124</div>
|
||||||
|
<div class="stat-desc">↗︎ 32 (30 days)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat bg-base-200 rounded-box p-4">
|
||||||
|
<div class="stat-title">Workshop Engagement</div>
|
||||||
|
<div class="stat-value">92%</div>
|
||||||
|
<div class="stat-desc">↗︎ 8% (30 days)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Analytics -->
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<!-- Event Performance -->
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Event Performance</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Attendance</th>
|
||||||
|
<th>Rating</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Tech Talk</td>
|
||||||
|
<td>32</td>
|
||||||
|
<td>4.8/5</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Workshop</td>
|
||||||
|
<td>28</td>
|
||||||
|
<td>4.6/5</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resume Analytics -->
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Resume Analytics</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Major</th>
|
||||||
|
<th>Downloads</th>
|
||||||
|
<th>Trend</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Computer Science</td>
|
||||||
|
<td>45</td>
|
||||||
|
<td>↗︎</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Electrical Engineering</td>
|
||||||
|
<td>32</td>
|
||||||
|
<td>↗︎</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -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>
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
78
src/components/dashboard/SponsorDashboard.astro
Normal file
78
src/components/dashboard/SponsorDashboard.astro
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
// Sponsor Dashboard Component
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl mb-4">Sponsor Dashboard</h2>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<!-- Sponsorship Status -->
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Sponsorship Status</h3>
|
||||||
|
<p class="text-primary font-semibold">Active</p>
|
||||||
|
<p class="text-sm opacity-70">Valid until: Dec 31, 2024</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Partnership Level -->
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Partnership Level</h3>
|
||||||
|
<p class="text-primary font-semibold">Platinum</p>
|
||||||
|
<p class="text-sm opacity-70">All benefits included</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Quick Actions</h3>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<button class="btn btn-primary btn-sm"
|
||||||
|
>Contact Us</button
|
||||||
|
>
|
||||||
|
<button class="btn btn-outline btn-sm"
|
||||||
|
>View Contract</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Recent Activity</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Activity</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>2024-01-15</td>
|
||||||
|
<td>Resume Book Access</td>
|
||||||
|
<td
|
||||||
|
><span class="badge badge-success"
|
||||||
|
>Completed</span
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2024-01-10</td>
|
||||||
|
<td>Workshop Scheduling</td>
|
||||||
|
<td
|
||||||
|
><span class="badge badge-warning">Pending</span
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -4,7 +4,6 @@ import FilePreview from '../universal/FilePreview';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import type { ItemizedExpense } from '../../../schemas/pocketbase';
|
import type { ItemizedExpense } from '../../../schemas/pocketbase';
|
||||||
// import ZoomablePreview from '../universal/ZoomablePreview';
|
|
||||||
|
|
||||||
interface ReceiptFormData {
|
interface ReceiptFormData {
|
||||||
file: File;
|
file: File;
|
||||||
|
@ -67,35 +66,6 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
const [locationAddress, setLocationAddress] = useState<string>('');
|
const [locationAddress, setLocationAddress] = useState<string>('');
|
||||||
const [notes, setNotes] = useState<string>('');
|
const [notes, setNotes] = useState<string>('');
|
||||||
const [error, setError] = 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>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files[0]) {
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
@ -174,69 +144,6 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
@ -248,11 +155,7 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
className="space-y-4 overflow-y-auto max-h-[70vh] pr-8 overflow-x-hidden"
|
className="space-y-4 overflow-y-auto max-h-[70vh] pr-4 custom-scrollbar"
|
||||||
style={{
|
|
||||||
scrollbarWidth: 'thin',
|
|
||||||
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
@ -288,9 +191,8 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Date and Location in Grid */}
|
{/* Date */}
|
||||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
<div className="form-control">
|
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-medium">Date</span>
|
<span className="label-text font-medium">Date</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
@ -302,27 +204,10 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
required
|
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>
|
</motion.div>
|
||||||
|
|
||||||
{/* Location Fields */}
|
{/* Location Name */}
|
||||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
<div className="form-control">
|
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-medium">Location Name</span>
|
<span className="label-text font-medium">Location Name</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
@ -332,12 +217,12 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
value={locationName}
|
value={locationName}
|
||||||
onChange={(e) => setLocationName(e.target.value)}
|
onChange={(e) => setLocationName(e.target.value)}
|
||||||
placeholder="Store/vendor name"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="form-control">
|
{/* Location Address */}
|
||||||
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-medium">Location Address</span>
|
<span className="label-text font-medium">Location Address</span>
|
||||||
<span className="label-text-alt text-error">*</span>
|
<span className="label-text-alt text-error">*</span>
|
||||||
|
@ -347,124 +232,37 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
className="input input-bordered focus:input-primary transition-all duration-300"
|
className="input input-bordered focus:input-primary transition-all duration-300"
|
||||||
value={locationAddress}
|
value={locationAddress}
|
||||||
onChange={(e) => setLocationAddress(e.target.value)}
|
onChange={(e) => setLocationAddress(e.target.value)}
|
||||||
placeholder="Full address"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Notes - Reduced height */}
|
{/* Notes */}
|
||||||
<motion.div variants={itemVariants} className="form-control">
|
<motion.div variants={itemVariants} className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-medium">Notes</span>
|
<span className="label-text font-medium">Notes</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300"
|
className="textarea textarea-bordered focus:textarea-primary transition-all duration-300 min-h-[100px]"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
rows={2}
|
rows={3}
|
||||||
placeholder="Additional notes..."
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</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 */}
|
{/* Itemized Expenses */}
|
||||||
<motion.div variants={itemVariants} className="space-y-4">
|
<motion.div variants={itemVariants} className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label className="text-lg font-medium">Itemized Expenses</label>
|
<label className="text-lg font-medium">Itemized Expenses</label>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
@ -476,48 +274,33 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
exit={{ opacity: 0, x: 20 }}
|
exit={{ opacity: 0, x: 20 }}
|
||||||
className="card bg-base-200/50 hover:bg-base-200 transition-colors duration-300 backdrop-blur-sm shadow-sm"
|
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="card-body p-4">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="grid gap-4">
|
||||||
<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">
|
<div className="form-control">
|
||||||
<label className="label py-1">
|
<label className="label">
|
||||||
<span className="label-text text-xs">Description</span>
|
<span className="label-text">Description</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input input-bordered input-sm"
|
className="input input-bordered"
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
|
onChange={(e) => handleExpenseItemChange(index, 'description', e.target.value)}
|
||||||
placeholder="What was purchased?"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label className="label py-1">
|
<label className="label">
|
||||||
<span className="label-text text-xs">Category</span>
|
<span className="label-text">Category</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
className="select select-bordered select-sm w-full"
|
className="select select-bordered"
|
||||||
value={item.category}
|
value={item.category}
|
||||||
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
|
onChange={(e) => handleExpenseItemChange(index, 'category', e.target.value)}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select...</option>
|
<option value="">Select category</option>
|
||||||
{EXPENSE_CATEGORIES.map(category => (
|
{EXPENSE_CATEGORIES.map(category => (
|
||||||
<option key={category} value={category}>{category}</option>
|
<option key={category} value={category}>{category}</option>
|
||||||
))}
|
))}
|
||||||
|
@ -525,19 +308,29 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label className="label py-1">
|
<label className="label">
|
||||||
<span className="label-text text-xs">Amount ($)</span>
|
<span className="label-text">Amount ($)</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-bordered input-sm w-full"
|
className="input input-bordered"
|
||||||
value={item.amount === 0 ? '' : item.amount}
|
value={item.amount}
|
||||||
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
|
onChange={(e) => handleExpenseItemChange(index, 'amount', Number(e.target.value))}
|
||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="0.00"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
{itemizedExpenses.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-square btn-sm btn-error"
|
||||||
|
onClick={() => removeExpenseItem(index)}
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:trash" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -545,41 +338,38 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</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>
|
||||||
|
|
||||||
|
{/* Tax */}
|
||||||
|
<motion.div variants={itemVariants} 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}
|
||||||
|
onChange={(e) => setTax(Number(e.target.value))}
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Total */}
|
{/* Total */}
|
||||||
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-3 shadow-sm">
|
<motion.div variants={itemVariants} className="card bg-base-200/50 backdrop-blur-sm p-4 shadow-sm">
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center text-sm text-base-content/70">
|
<div className="flex justify-between items-center text-base-content/70">
|
||||||
<span>Subtotal:</span>
|
<span>Subtotal:</span>
|
||||||
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
|
<span className="font-mono">${itemizedExpenses.reduce((sum, item) => sum + item.amount, 0).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-sm text-base-content/70">
|
<div className="flex justify-between items-center text-base-content/70">
|
||||||
<span>Tax:</span>
|
<span>Tax:</span>
|
||||||
<span className="font-mono">${tax.toFixed(2)}</span>
|
<span className="font-mono">${tax.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider my-1"></div>
|
<div className="divider my-1"></div>
|
||||||
<div className="flex justify-between items-center font-medium">
|
<div className="flex justify-between items-center font-medium text-lg">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span className="font-mono text-primary text-lg">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
|
<span className="font-mono text-primary">${(itemizedExpenses.reduce((sum, item) => sum + item.amount, 0) + tax).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@ -622,60 +412,13 @@ export default function ReceiptForm({ onSubmit, onCancel }: ReceiptFormProps) {
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||||
className="bg-base-200/50 backdrop-blur-sm rounded-xl shadow-sm relative"
|
className="bg-base-200/50 backdrop-blur-sm rounded-xl p-4 shadow-sm"
|
||||||
>
|
>
|
||||||
{/* Zoom Controls */}
|
<FilePreview
|
||||||
<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">
|
url={previewUrl}
|
||||||
<motion.button
|
filename={file?.name || ''}
|
||||||
whileHover={{ scale: 1.1 }}
|
isModal={false}
|
||||||
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>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||||
import { EmailClient } from '../../../scripts/email/EmailClient';
|
|
||||||
import ReceiptForm from './ReceiptForm';
|
import ReceiptForm from './ReceiptForm';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import FilePreview from '../universal/FilePreview';
|
import FilePreview from '../universal/FilePreview';
|
||||||
import type { ItemizedExpense, Reimbursement } from '../../../schemas/pocketbase';
|
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
|
||||||
|
|
||||||
interface ReceiptFormData {
|
interface ReceiptFormData {
|
||||||
file: File;
|
file: File;
|
||||||
|
@ -118,22 +117,13 @@ export default function ReimbursementForm() {
|
||||||
const userId = pb.authStore.model?.id;
|
const userId = pb.authStore.model?.id;
|
||||||
|
|
||||||
if (!userId) {
|
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');
|
throw new Error('User not authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await pb.collection('users').getOne(userId);
|
const user = await pb.collection('users').getOne(userId);
|
||||||
setHasZelleInfo(!!user.zelle_information);
|
setHasZelleInfo(!!user.zelle_information);
|
||||||
} catch (error) {
|
} 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);
|
console.error('Error checking Zelle information:', error);
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
@ -185,10 +175,6 @@ export default function ReimbursementForm() {
|
||||||
const userId = pb.authStore.model?.id;
|
const userId = pb.authStore.model?.id;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Silently return without error when on dashboard page
|
|
||||||
if (window.location.pathname.includes('/dashboard')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.error('User not authenticated');
|
toast.error('User not authenticated');
|
||||||
throw new Error('User not authenticated');
|
throw new Error('User not authenticated');
|
||||||
}
|
}
|
||||||
|
@ -258,11 +244,6 @@ export default function ReimbursementForm() {
|
||||||
const userId = pb.authStore.model?.id;
|
const userId = pb.authStore.model?.id;
|
||||||
|
|
||||||
if (!userId) {
|
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');
|
throw new Error('User not authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -278,34 +259,11 @@ export default function ReimbursementForm() {
|
||||||
formData.append('receipts', JSON.stringify(request.receipts));
|
formData.append('receipts', JSON.stringify(request.receipts));
|
||||||
formData.append('department', request.department);
|
formData.append('department', request.department);
|
||||||
|
|
||||||
// Create the reimbursement record
|
await pb.collection('reimbursement').create(formData);
|
||||||
const newReimbursement = await pb.collection('reimbursement').create(formData);
|
|
||||||
|
|
||||||
// Sync the reimbursements collection to update IndexedDB
|
// Sync the reimbursements collection to update IndexedDB
|
||||||
const dataSync = DataSyncService.getInstance();
|
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);
|
await dataSync.syncCollection(Collections.REIMBURSEMENTS);
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setRequest({
|
setRequest({
|
||||||
|
@ -332,14 +290,6 @@ export default function ReimbursementForm() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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) {
|
} catch (error) {
|
||||||
console.error('Error submitting reimbursement request:', error);
|
console.error('Error submitting reimbursement request:', error);
|
||||||
toast.error('Failed to submit reimbursement request. Please try again.');
|
toast.error('Failed to submit reimbursement request. Please try again.');
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { Get } from '../../../scripts/pocketbase/Get';
|
import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
|
@ -112,48 +112,27 @@ export default function ReimbursementList() {
|
||||||
const fileManager = FileManager.getInstance();
|
const fileManager = FileManager.getInstance();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log('Component mounted');
|
console.log('Component mounted');
|
||||||
fetchReimbursements();
|
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
|
// Add effect to monitor requests state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log('Requests state updated:', requests);
|
console.log('Requests state updated:', requests);
|
||||||
// console.log('Number of requests:', requests.length);
|
console.log('Number of requests:', requests.length);
|
||||||
}, [requests]);
|
}, [requests]);
|
||||||
|
|
||||||
// Add a useEffect to log preview URL and filename changes
|
// Add a useEffect to log preview URL and filename changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log('Preview URL changed:', previewUrl);
|
console.log('Preview URL changed:', previewUrl);
|
||||||
// console.log('Preview filename changed:', previewFilename);
|
console.log('Preview filename changed:', previewFilename);
|
||||||
}, [previewUrl, previewFilename]);
|
}, [previewUrl, previewFilename]);
|
||||||
|
|
||||||
// Add a useEffect to log when the preview modal is shown/hidden
|
// Add a useEffect to log when the preview modal is shown/hidden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log('Show preview changed:', showPreview);
|
console.log('Show preview changed:', showPreview);
|
||||||
if (showPreview) {
|
if (showPreview) {
|
||||||
// console.log('Selected receipt:', selectedReceipt);
|
console.log('Selected receipt:', selectedReceipt);
|
||||||
}
|
}
|
||||||
}, [showPreview, selectedReceipt]);
|
}, [showPreview, selectedReceipt]);
|
||||||
|
|
||||||
|
@ -166,18 +145,13 @@ export default function ReimbursementList() {
|
||||||
const userId = pb.authStore.model?.id;
|
const userId = pb.authStore.model?.id;
|
||||||
|
|
||||||
if (!userId) {
|
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');
|
throw new Error('User not authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||||
const dataSync = DataSyncService.getInstance();
|
const dataSync = DataSyncService.getInstance();
|
||||||
|
|
||||||
// Sync reimbursements collection with force sync
|
// Sync reimbursements collection
|
||||||
await dataSync.syncCollection(
|
await dataSync.syncCollection(
|
||||||
Collections.REIMBURSEMENTS,
|
Collections.REIMBURSEMENTS,
|
||||||
`submitted_by="${userId}"`,
|
`submitted_by="${userId}"`,
|
||||||
|
@ -185,15 +159,15 @@ export default function ReimbursementList() {
|
||||||
'audit_notes'
|
'audit_notes'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get reimbursements from IndexedDB with forced sync to ensure latest data
|
// Get reimbursements from IndexedDB
|
||||||
const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
|
const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
|
||||||
Collections.REIMBURSEMENTS,
|
Collections.REIMBURSEMENTS,
|
||||||
true, // Force sync to ensure we have the latest data
|
false, // Don't force sync again
|
||||||
`submitted_by="${userId}"`,
|
`submitted_by="${userId}"`,
|
||||||
'-created'
|
'-created'
|
||||||
);
|
);
|
||||||
|
|
||||||
// console.log('Reimbursement records from IndexedDB:', reimbursementRecords);
|
console.log('Reimbursement records from IndexedDB:', reimbursementRecords);
|
||||||
|
|
||||||
// Process the records
|
// Process the records
|
||||||
const processedRecords = reimbursementRecords.map(record => {
|
const processedRecords = reimbursementRecords.map(record => {
|
||||||
|
@ -209,7 +183,7 @@ export default function ReimbursementList() {
|
||||||
auditNotes = record.audit_notes;
|
auditNotes = record.audit_notes;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// console.error('Error parsing audit notes:', e);
|
console.error('Error parsing audit notes:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -243,7 +217,7 @@ export default function ReimbursementList() {
|
||||||
itemizedExpenses = receiptRecord.itemized_expenses as ItemizedExpense[];
|
itemizedExpenses = receiptRecord.itemized_expenses as ItemizedExpense[];
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// console.error('Error parsing itemized expenses:', e);
|
console.error('Error parsing itemized expenses:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -267,13 +241,13 @@ export default function ReimbursementList() {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// console.error(`Error fetching receipt ${receiptId}:`, e);
|
console.error(`Error fetching receipt ${receiptId}:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// console.error('Error fetching reimbursements:', err);
|
console.error('Error fetching reimbursements:', err);
|
||||||
setError('Failed to load reimbursements. Please try again.');
|
setError('Failed to load reimbursements. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
@ -282,7 +256,7 @@ export default function ReimbursementList() {
|
||||||
|
|
||||||
const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => {
|
const handlePreviewFile = async (request: ReimbursementRequest, receiptId: string) => {
|
||||||
try {
|
try {
|
||||||
// console.log('Previewing file for receipt ID:', receiptId);
|
console.log('Previewing file for receipt ID:', receiptId);
|
||||||
const pb = auth.getPocketBase();
|
const pb = auth.getPocketBase();
|
||||||
const fileManager = FileManager.getInstance();
|
const fileManager = FileManager.getInstance();
|
||||||
|
|
||||||
|
@ -291,13 +265,13 @@ export default function ReimbursementList() {
|
||||||
|
|
||||||
// Check if we already have the receipt details in our map
|
// Check if we already have the receipt details in our map
|
||||||
if (receiptDetailsMap[receiptId]) {
|
if (receiptDetailsMap[receiptId]) {
|
||||||
// console.log('Using cached receipt details');
|
console.log('Using cached receipt details');
|
||||||
// Use the cached receipt details
|
// Use the cached receipt details
|
||||||
setSelectedReceipt(receiptDetailsMap[receiptId]);
|
setSelectedReceipt(receiptDetailsMap[receiptId]);
|
||||||
|
|
||||||
// Check if the receipt has a file
|
// Check if the receipt has a file
|
||||||
if (!receiptDetailsMap[receiptId].file) {
|
if (!receiptDetailsMap[receiptId].file) {
|
||||||
// console.error('Receipt has no file attached');
|
console.error('Receipt has no file attached');
|
||||||
toast.error('This receipt has no file attached');
|
toast.error('This receipt has no file attached');
|
||||||
setPreviewUrl('');
|
setPreviewUrl('');
|
||||||
setPreviewFilename('');
|
setPreviewFilename('');
|
||||||
|
@ -306,7 +280,7 @@ export default function ReimbursementList() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the file URL with token for protected files
|
// Get the file URL with token for protected files
|
||||||
// console.log('Getting file URL with token');
|
console.log('Getting file URL with token');
|
||||||
const url = await fileManager.getFileUrlWithToken(
|
const url = await fileManager.getFileUrlWithToken(
|
||||||
'receipts',
|
'receipts',
|
||||||
receiptId,
|
receiptId,
|
||||||
|
@ -316,7 +290,7 @@ export default function ReimbursementList() {
|
||||||
|
|
||||||
// Check if the URL is empty
|
// Check if the URL is empty
|
||||||
if (!url) {
|
if (!url) {
|
||||||
// console.error('Failed to get file URL: Empty URL returned');
|
console.error('Failed to get file URL: Empty URL returned');
|
||||||
toast.error('Failed to load receipt: Could not generate file URL');
|
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
|
// Still show the preview modal but with empty URL to display the error message
|
||||||
setPreviewUrl('');
|
setPreviewUrl('');
|
||||||
|
@ -325,7 +299,7 @@ export default function ReimbursementList() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('Got URL:', url.substring(0, 50) + '...');
|
console.log('Got URL:', url.substring(0, 50) + '...');
|
||||||
|
|
||||||
// Set the preview URL and filename
|
// Set the preview URL and filename
|
||||||
setPreviewUrl(url);
|
setPreviewUrl(url);
|
||||||
|
@ -335,28 +309,28 @@ export default function ReimbursementList() {
|
||||||
setShowPreview(true);
|
setShowPreview(true);
|
||||||
|
|
||||||
// Log the current state
|
// Log the current state
|
||||||
// console.log('Current state after setting:', {
|
console.log('Current state after setting:', {
|
||||||
// previewUrl: url,
|
previewUrl: url,
|
||||||
// previewFilename: receiptDetailsMap[receiptId].file,
|
previewFilename: receiptDetailsMap[receiptId].file,
|
||||||
// showPreview: true
|
showPreview: true
|
||||||
// });
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in the map, get the receipt record using its ID
|
// If not in the map, get the receipt record using its ID
|
||||||
// console.log('Fetching receipt details from server');
|
console.log('Fetching receipt details from server');
|
||||||
const receiptRecord = await pb.collection('receipts').getOne(receiptId, {
|
const receiptRecord = await pb.collection('receipts').getOne(receiptId, {
|
||||||
$autoCancel: false
|
$autoCancel: false
|
||||||
});
|
});
|
||||||
|
|
||||||
if (receiptRecord) {
|
if (receiptRecord) {
|
||||||
// console.log('Receipt record found:', receiptRecord.id);
|
console.log('Receipt record found:', receiptRecord.id);
|
||||||
// console.log('Receipt file:', receiptRecord.file);
|
console.log('Receipt file:', receiptRecord.file);
|
||||||
|
|
||||||
// Check if the receipt has a file
|
// Check if the receipt has a file
|
||||||
if (!receiptRecord.file) {
|
if (!receiptRecord.file) {
|
||||||
// console.error('Receipt has no file attached');
|
console.error('Receipt has no file attached');
|
||||||
toast.error('This receipt has no file attached');
|
toast.error('This receipt has no file attached');
|
||||||
setPreviewUrl('');
|
setPreviewUrl('');
|
||||||
setPreviewFilename('');
|
setPreviewFilename('');
|
||||||
|
@ -393,7 +367,7 @@ export default function ReimbursementList() {
|
||||||
setSelectedReceipt(receiptDetails);
|
setSelectedReceipt(receiptDetails);
|
||||||
|
|
||||||
// Get the file URL with token for protected files
|
// Get the file URL with token for protected files
|
||||||
// console.log('Getting file URL with token for new receipt');
|
console.log('Getting file URL with token for new receipt');
|
||||||
const url = await fileManager.getFileUrlWithToken(
|
const url = await fileManager.getFileUrlWithToken(
|
||||||
'receipts',
|
'receipts',
|
||||||
receiptRecord.id,
|
receiptRecord.id,
|
||||||
|
@ -403,7 +377,7 @@ export default function ReimbursementList() {
|
||||||
|
|
||||||
// Check if the URL is empty
|
// Check if the URL is empty
|
||||||
if (!url) {
|
if (!url) {
|
||||||
// console.error('Failed to get file URL: Empty URL returned');
|
console.error('Failed to get file URL: Empty URL returned');
|
||||||
toast.error('Failed to load receipt: Could not generate file URL');
|
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
|
// Still show the preview modal but with empty URL to display the error message
|
||||||
setPreviewUrl('');
|
setPreviewUrl('');
|
||||||
|
@ -412,7 +386,7 @@ export default function ReimbursementList() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('Got URL:', url.substring(0, 50) + '...');
|
console.log('Got URL:', url.substring(0, 50) + '...');
|
||||||
|
|
||||||
// Set the preview URL and filename
|
// Set the preview URL and filename
|
||||||
setPreviewUrl(url);
|
setPreviewUrl(url);
|
||||||
|
@ -422,16 +396,16 @@ export default function ReimbursementList() {
|
||||||
setShowPreview(true);
|
setShowPreview(true);
|
||||||
|
|
||||||
// Log the current state
|
// Log the current state
|
||||||
// console.log('Current state after setting:', {
|
console.log('Current state after setting:', {
|
||||||
// previewUrl: url,
|
previewUrl: url,
|
||||||
// previewFilename: receiptRecord.file,
|
previewFilename: receiptRecord.file,
|
||||||
// showPreview: true
|
showPreview: true
|
||||||
// });
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Receipt not found');
|
throw new Error('Receipt not found');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('Error loading receipt:', error);
|
console.error('Error loading receipt:', error);
|
||||||
toast.error('Failed to load receipt. Please try again.');
|
toast.error('Failed to load receipt. Please try again.');
|
||||||
// Show the preview modal with empty URL to display the error message
|
// Show the preview modal with empty URL to display the error message
|
||||||
setPreviewUrl('');
|
setPreviewUrl('');
|
||||||
|
@ -449,7 +423,7 @@ export default function ReimbursementList() {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
// console.log('Rendering loading state');
|
console.log('Rendering loading state');
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
@ -463,7 +437,7 @@ export default function ReimbursementList() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
// console.log('Rendering error state:', error);
|
console.log('Rendering error state:', error);
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -20 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
@ -476,8 +450,8 @@ export default function ReimbursementList() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('Rendering main component. Requests:', requests);
|
console.log('Rendering main component. Requests:', requests);
|
||||||
// console.log('Requests length:', requests.length);
|
console.log('Requests length:', requests.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -508,7 +482,7 @@ export default function ReimbursementList() {
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
{requests.map((request, index) => {
|
{requests.map((request, index) => {
|
||||||
// console.log('Rendering request:', request);
|
console.log('Rendering request:', request);
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={request.id}
|
key={request.id}
|
||||||
|
@ -568,7 +542,7 @@ export default function ReimbursementList() {
|
||||||
? 'bg-success text-success-content ring-2 ring-success/20'
|
? 'bg-success text-success-content ring-2 ring-success/20'
|
||||||
: status === 'in_progress'
|
: status === 'in_progress'
|
||||||
? 'bg-warning text-warning-content ring-2 ring-warning/20'
|
? 'bg-warning text-warning-content ring-2 ring-warning/20'
|
||||||
: 'bg-primary text-white ring-2 ring-primary/20'
|
: 'bg-primary text-primary-content ring-2 ring-primary/20'
|
||||||
: isActive
|
: isActive
|
||||||
? status === 'rejected'
|
? status === 'rejected'
|
||||||
? 'bg-error/20 text-error'
|
? 'bg-error/20 text-error'
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { Get } from '../../../scripts/pocketbase/Get';
|
||||||
import { Update } from '../../../scripts/pocketbase/Update';
|
import { Update } from '../../../scripts/pocketbase/Update';
|
||||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||||
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
||||||
import { EmailClient } from '../../../scripts/email/EmailClient';
|
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
|
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
|
||||||
|
@ -33,10 +32,6 @@ interface FilterOptions {
|
||||||
dateRange: 'all' | 'week' | 'month' | 'year';
|
dateRange: 'all' | 'week' | 'month' | 'year';
|
||||||
sortBy: 'date_of_purchase' | 'total_amount' | 'status';
|
sortBy: 'date_of_purchase' | 'total_amount' | 'status';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
hidePaid: boolean; // Auto-hide paid reimbursements
|
|
||||||
hideRejected: boolean; // Auto-hide rejected reimbursements
|
|
||||||
compactView: boolean; // Toggle for compact list view
|
|
||||||
search: string; // Search query
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ItemizedExpense {
|
interface ItemizedExpense {
|
||||||
|
@ -58,11 +53,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
department: [],
|
department: [],
|
||||||
dateRange: 'all',
|
dateRange: 'all',
|
||||||
sortBy: 'date_of_purchase',
|
sortBy: 'date_of_purchase',
|
||||||
sortOrder: 'desc',
|
sortOrder: 'desc'
|
||||||
hidePaid: true,
|
|
||||||
hideRejected: true,
|
|
||||||
compactView: false,
|
|
||||||
search: ''
|
|
||||||
});
|
});
|
||||||
const [auditNote, setAuditNote] = useState('');
|
const [auditNote, setAuditNote] = useState('');
|
||||||
const [loadingStatus, setLoadingStatus] = useState(false);
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||||
|
@ -119,21 +110,6 @@ export default function ReimbursementManagementPortal() {
|
||||||
filter = `(${statusFilter})`;
|
filter = `(${statusFilter})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When searching, don't auto-hide paid/rejected unless explicitly filtered
|
|
||||||
const isSearching = filters.search.trim().length > 0;
|
|
||||||
|
|
||||||
// Auto-hide paid reimbursements if the option is enabled and not searching
|
|
||||||
if (filters.hidePaid && !isSearching) {
|
|
||||||
const hidePaidFilter = 'status != "paid"';
|
|
||||||
filter = filter ? `${filter} && ${hidePaidFilter}` : hidePaidFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-hide rejected reimbursements if the option is enabled and not searching
|
|
||||||
if (filters.hideRejected && !isSearching) {
|
|
||||||
const hideRejectedFilter = 'status != "rejected"';
|
|
||||||
filter = filter ? `${filter} && ${hideRejectedFilter}` : hideRejectedFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.department.length > 0) {
|
if (filters.department.length > 0) {
|
||||||
const departmentFilter = filters.department.map(d => `department = "${d}"`).join(' || ');
|
const departmentFilter = filters.department.map(d => `department = "${d}"`).join(' || ');
|
||||||
filter = filter ? `${filter} && (${departmentFilter})` : `(${departmentFilter})`;
|
filter = filter ? `${filter} && (${departmentFilter})` : `(${departmentFilter})`;
|
||||||
|
@ -158,6 +134,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await get.getAll<ExtendedReimbursement>('reimbursement', filter, sort);
|
const records = await get.getAll<ExtendedReimbursement>('reimbursement', filter, sort);
|
||||||
|
console.log('Loaded reimbursements:', records);
|
||||||
|
|
||||||
// Load user data for submitters
|
// Load user data for submitters
|
||||||
const userIds = new Set(records.map(r => r.submitted_by));
|
const userIds = new Set(records.map(r => r.submitted_by));
|
||||||
|
@ -184,12 +161,15 @@ export default function ReimbursementManagementPortal() {
|
||||||
submitter: userMap[record.submitted_by]
|
submitter: userMap[record.submitted_by]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
setReimbursements(enrichedRecords);
|
||||||
|
|
||||||
// Load associated receipts
|
// Load associated receipts
|
||||||
const receiptIds = enrichedRecords.flatMap(r => r.receipts || []);
|
const receiptIds = enrichedRecords.flatMap(r => r.receipts || []);
|
||||||
|
console.log('Extracted receipt IDs:', receiptIds, 'from reimbursements:', enrichedRecords.map(r => ({ id: r.id, receipts: r.receipts })));
|
||||||
|
|
||||||
let receiptMap: Record<string, ExtendedReceipt> = {};
|
|
||||||
if (receiptIds.length > 0) {
|
if (receiptIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
console.log('Attempting to load receipts with IDs:', receiptIds);
|
||||||
const receiptRecords = await Promise.all(
|
const receiptRecords = await Promise.all(
|
||||||
receiptIds.map(async id => {
|
receiptIds.map(async id => {
|
||||||
try {
|
try {
|
||||||
|
@ -222,10 +202,12 @@ export default function ReimbursementManagementPortal() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const validReceipts = receiptRecords.filter((r): r is ExtendedReceipt => r !== null);
|
const validReceipts = receiptRecords.filter((r): r is ExtendedReceipt => r !== null);
|
||||||
|
console.log('Successfully loaded receipt records:', validReceipts);
|
||||||
|
|
||||||
receiptMap = Object.fromEntries(
|
const receiptMap = Object.fromEntries(
|
||||||
validReceipts.map(receipt => [receipt.id, receipt])
|
validReceipts.map(receipt => [receipt.id, receipt])
|
||||||
);
|
);
|
||||||
|
console.log('Created receipt map:', receiptMap);
|
||||||
setReceipts(receiptMap);
|
setReceipts(receiptMap);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error loading receipts:', error);
|
console.error('Error loading receipts:', error);
|
||||||
|
@ -237,55 +219,9 @@ export default function ReimbursementManagementPortal() {
|
||||||
toast.error('Failed to load receipts: ' + (error?.message || 'Unknown error'));
|
toast.error('Failed to load receipts: ' + (error?.message || 'Unknown error'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// console.log('No receipt IDs found in reimbursements');
|
console.log('No receipt IDs found in reimbursements');
|
||||||
setReceipts({});
|
setReceipts({});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply client-side search filtering
|
|
||||||
let filteredRecords = enrichedRecords;
|
|
||||||
if (isSearching) {
|
|
||||||
const searchTerm = filters.search.toLowerCase().trim();
|
|
||||||
|
|
||||||
filteredRecords = enrichedRecords.filter(record => {
|
|
||||||
// Search in title
|
|
||||||
if (record.title.toLowerCase().includes(searchTerm)) return true;
|
|
||||||
|
|
||||||
// Search in submitter name
|
|
||||||
if (record.submitter?.name?.toLowerCase().includes(searchTerm)) return true;
|
|
||||||
|
|
||||||
// Search in date (multiple formats)
|
|
||||||
const date = new Date(record.date_of_purchase);
|
|
||||||
const dateFormats = [
|
|
||||||
date.toLocaleDateString(), // Default locale format
|
|
||||||
date.toLocaleDateString('en-US'), // MM/DD/YYYY
|
|
||||||
date.toISOString().split('T')[0], // YYYY-MM-DD
|
|
||||||
date.toDateString(), // "Mon Jan 01 2024"
|
|
||||||
`${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`, // M/D/YYYY
|
|
||||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` // YYYY-MM-DD
|
|
||||||
];
|
|
||||||
if (dateFormats.some(format => format.toLowerCase().includes(searchTerm))) return true;
|
|
||||||
|
|
||||||
// Search in receipt location names
|
|
||||||
const reimbursementReceipts = record.receipts?.map(id => receiptMap[id]).filter(Boolean) || [];
|
|
||||||
if (reimbursementReceipts.some(receipt =>
|
|
||||||
receipt.location_name?.toLowerCase().includes(searchTerm) ||
|
|
||||||
receipt.location_address?.toLowerCase().includes(searchTerm)
|
|
||||||
)) return true;
|
|
||||||
|
|
||||||
// Search in department
|
|
||||||
if (record.department.toLowerCase().includes(searchTerm)) return true;
|
|
||||||
|
|
||||||
// Search in status
|
|
||||||
if (record.status.toLowerCase().replace('_', ' ').includes(searchTerm)) return true;
|
|
||||||
|
|
||||||
// Search in additional info
|
|
||||||
if (record.additional_info?.toLowerCase().includes(searchTerm)) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setReimbursements(filteredRecords);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading reimbursements:', error);
|
console.error('Error loading reimbursements:', error);
|
||||||
toast.error('Failed to load reimbursements. Please try again later.');
|
toast.error('Failed to load reimbursements. Please try again later.');
|
||||||
|
@ -435,7 +371,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the updateStatus function
|
// Update the updateStatus function
|
||||||
const updateStatus = async (id: string, status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'in_progress' | 'paid', showToast: boolean = true) => {
|
const updateStatus = async (id: string, status: 'submitted' | 'under_review' | 'approved' | 'rejected' | 'in_progress' | 'paid') => {
|
||||||
try {
|
try {
|
||||||
setLoadingStatus(true);
|
setLoadingStatus(true);
|
||||||
const update = Update.getInstance();
|
const update = Update.getInstance();
|
||||||
|
@ -444,28 +380,15 @@ export default function ReimbursementManagementPortal() {
|
||||||
|
|
||||||
if (!userId) throw new Error('User not authenticated');
|
if (!userId) throw new Error('User not authenticated');
|
||||||
|
|
||||||
// Store previous status for email notification
|
|
||||||
const previousStatus = selectedReimbursement?.status || 'unknown';
|
|
||||||
|
|
||||||
await update.updateFields('reimbursement', id, { status });
|
await update.updateFields('reimbursement', id, { status });
|
||||||
|
|
||||||
// Add audit log for status change
|
// Add audit log for status change
|
||||||
await addAuditLog(id, 'status_change', {
|
await addAuditLog(id, 'status_change', {
|
||||||
from: previousStatus,
|
from: selectedReimbursement?.status,
|
||||||
to: status
|
to: status
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send email notification
|
|
||||||
try {
|
|
||||||
await EmailClient.notifyStatusChange(id, status, previousStatus, userId);
|
|
||||||
} catch (emailError) {
|
|
||||||
console.error('Failed to send email notification:', emailError);
|
|
||||||
// Don't fail the entire operation if email fails
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showToast) {
|
|
||||||
toast.success(`Reimbursement ${status} successfully`);
|
toast.success(`Reimbursement ${status} successfully`);
|
||||||
}
|
|
||||||
await refreshAuditData(id);
|
await refreshAuditData(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating status:', error);
|
console.error('Error updating status:', error);
|
||||||
|
@ -565,7 +488,8 @@ export default function ReimbursementManagementPortal() {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Don't show the receipt modal when auditing
|
setSelectedReceipt(receipt);
|
||||||
|
setShowReceiptModal(true);
|
||||||
toast.success('Receipt audited successfully');
|
toast.success('Receipt audited successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error auditing receipt:', error);
|
console.error('Error auditing receipt:', error);
|
||||||
|
@ -663,21 +587,6 @@ export default function ReimbursementManagementPortal() {
|
||||||
is_private: isPrivateNote
|
is_private: isPrivateNote
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send email notification for public comments
|
|
||||||
if (!isPrivateNote) {
|
|
||||||
try {
|
|
||||||
await EmailClient.notifyComment(
|
|
||||||
selectedReimbursement.id,
|
|
||||||
auditNote.trim(),
|
|
||||||
userId,
|
|
||||||
isPrivateNote
|
|
||||||
);
|
|
||||||
} catch (emailError) {
|
|
||||||
console.error('Failed to send comment email notification:', emailError);
|
|
||||||
// Don't fail the entire operation if email fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Audit note saved successfully');
|
toast.success('Audit note saved successfully');
|
||||||
setAuditNote('');
|
setAuditNote('');
|
||||||
setIsPrivateNote(true);
|
setIsPrivateNote(true);
|
||||||
|
@ -709,8 +618,8 @@ export default function ReimbursementManagementPortal() {
|
||||||
try {
|
try {
|
||||||
setLoadingStatus(true);
|
setLoadingStatus(true);
|
||||||
|
|
||||||
// First update the status (passing false to suppress the toast message)
|
// First update the status
|
||||||
await updateStatus(rejectingId, 'rejected', false);
|
await updateStatus(rejectingId, 'rejected');
|
||||||
|
|
||||||
// Then add the rejection reason as a public note
|
// Then add the rejection reason as a public note
|
||||||
const auth = Authentication.getInstance();
|
const auth = Authentication.getInstance();
|
||||||
|
@ -789,59 +698,12 @@ export default function ReimbursementManagementPortal() {
|
||||||
<h2 className="text-lg sm:text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
<h2 className="text-lg sm:text-xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||||
Reimbursement Requests
|
Reimbursement Requests
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="badge badge-primary badge-md font-medium">
|
<span className="badge badge-primary badge-md font-medium">
|
||||||
{reimbursements.length} Total
|
{reimbursements.length} Total
|
||||||
</span>
|
</span>
|
||||||
{filters.hidePaid && (
|
|
||||||
<span className="badge badge-ghost badge-sm font-medium" title="Paid reimbursements are automatically hidden">
|
|
||||||
<Icon icon="heroicons:eye-slash" className="h-3 w-3 mr-1" />
|
|
||||||
Paid Hidden
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{filters.hideRejected && (
|
|
||||||
<span className="badge badge-ghost badge-sm font-medium" title="Rejected reimbursements are automatically hidden">
|
|
||||||
<Icon icon="heroicons:eye-slash" className="h-3 w-3 mr-1" />
|
|
||||||
Rejected Hidden
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
||||||
{/* Search Bar */}
|
|
||||||
<div className="form-control sm:col-span-2">
|
|
||||||
<div className="join h-9 relative">
|
|
||||||
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
|
||||||
<Icon icon="heroicons:magnifying-glass" className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={`input input-bordered input-sm w-full focus:outline-none h-full join-item rounded-l-none ${filters.search ? 'pr-16' : 'pr-8'}`}
|
|
||||||
placeholder="Search by title, user, date, receipt location..."
|
|
||||||
value={filters.search}
|
|
||||||
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
|
||||||
/>
|
|
||||||
{filters.search && (
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-sm absolute right-2 top-0 h-full px-2"
|
|
||||||
onClick={() => setFilters(prev => ({ ...prev, search: '' }))}
|
|
||||||
>
|
|
||||||
<Icon icon="heroicons:x-mark" className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{filters.search && (
|
|
||||||
<div className="label py-1">
|
|
||||||
<span className="label-text-alt text-info">
|
|
||||||
<Icon icon="heroicons:information-circle" className="h-3 w-3 inline mr-1" />
|
|
||||||
Search includes all reimbursements (including paid/rejected)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<div className="join h-9 relative">
|
<div className="join h-9 relative">
|
||||||
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
||||||
|
@ -897,7 +759,6 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Department Filter */}
|
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<div className="join h-9 relative">
|
<div className="join h-9 relative">
|
||||||
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
||||||
|
@ -950,7 +811,6 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date Range Filter */}
|
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<div className="join h-9">
|
<div className="join h-9">
|
||||||
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
||||||
|
@ -969,8 +829,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort Controls */}
|
<div className="form-control md:col-span-2">
|
||||||
<div className="form-control">
|
|
||||||
<div className="join h-9">
|
<div className="join h-9">
|
||||||
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
<div className="flex items-center justify-center w-9 bg-base-200 border border-base-300 rounded-l-lg join-item">
|
||||||
<Icon icon="heroicons:arrows-up-down" className="h-4 w-4" />
|
<Icon icon="heroicons:arrows-up-down" className="h-4 w-4" />
|
||||||
|
@ -996,54 +855,6 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Additional Filter Options */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-4 border-t border-base-300 mt-4">
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label cursor-pointer justify-start gap-3 p-0">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-primary checkbox-sm"
|
|
||||||
checked={filters.hidePaid}
|
|
||||||
onChange={(e) => setFilters({ ...filters, hidePaid: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon icon="heroicons:eye-slash" className="h-4 w-4 text-base-content/70" />
|
|
||||||
<span className="label-text font-medium">Auto-hide paid requests</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label cursor-pointer justify-start gap-3 p-0">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-primary checkbox-sm"
|
|
||||||
checked={filters.hideRejected}
|
|
||||||
onChange={(e) => setFilters({ ...filters, hideRejected: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon icon="heroicons:eye-slash" className="h-4 w-4 text-base-content/70" />
|
|
||||||
<span className="label-text font-medium">Auto-hide rejected requests</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-control">
|
|
||||||
<label className="label cursor-pointer justify-start gap-3 p-0">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-primary checkbox-sm"
|
|
||||||
checked={filters.compactView}
|
|
||||||
onChange={(e) => setFilters({ ...filters, compactView: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon icon="heroicons:list-bullet" className="h-4 w-4 text-base-content/70" />
|
|
||||||
<span className="label-text font-medium">Compact view</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
@ -1067,7 +878,7 @@ export default function ReimbursementManagementPortal() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<div className={`${filters.compactView ? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2' : 'space-y-4'}`}>
|
<div className="space-y-4">
|
||||||
{reimbursements.map((reimbursement, index) => (
|
{reimbursements.map((reimbursement, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={reimbursement.id}
|
key={reimbursement.id}
|
||||||
|
@ -1078,34 +889,6 @@ export default function ReimbursementManagementPortal() {
|
||||||
${selectedReimbursement?.id === reimbursement.id ? 'ring-2 ring-primary shadow-lg scale-[1.02]' : 'hover:scale-[1.01] hover:shadow-md'}`}
|
${selectedReimbursement?.id === reimbursement.id ? 'ring-2 ring-primary shadow-lg scale-[1.02]' : 'hover:scale-[1.01] hover:shadow-md'}`}
|
||||||
onClick={() => setSelectedReimbursement(reimbursement)}
|
onClick={() => setSelectedReimbursement(reimbursement)}
|
||||||
>
|
>
|
||||||
{filters.compactView ? (
|
|
||||||
// Compact Grid View
|
|
||||||
<div className="card-body p-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="font-semibold text-sm group-hover:text-primary transition-colors line-clamp-2 leading-tight">
|
|
||||||
{reimbursement.title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center justify-between text-xs text-base-content/70">
|
|
||||||
<span>{new Date(reimbursement.date_of_purchase).toLocaleDateString()}</span>
|
|
||||||
<span className="font-mono font-bold text-primary text-sm">
|
|
||||||
${reimbursement.total_amount.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<span className={`badge badge-sm ${reimbursement.status === 'approved' ? 'badge-success' :
|
|
||||||
reimbursement.status === 'rejected' ? 'badge-error' :
|
|
||||||
reimbursement.status === 'under_review' ? 'badge-info' :
|
|
||||||
reimbursement.status === 'in_progress' ? 'badge-warning' :
|
|
||||||
reimbursement.status === 'paid' ? 'badge-success' :
|
|
||||||
'badge-ghost'
|
|
||||||
} capitalize font-medium whitespace-nowrap`}>
|
|
||||||
{reimbursement.status.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Regular View
|
|
||||||
<div className="card-body p-5">
|
<div className="card-body p-5">
|
||||||
<div className="flex justify-between items-start gap-4">
|
<div className="flex justify-between items-start gap-4">
|
||||||
<div className="space-y-2 flex-1 min-w-0">
|
<div className="space-y-2 flex-1 min-w-0">
|
||||||
|
@ -1147,7 +930,6 @@ export default function ReimbursementManagementPortal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -14,8 +14,6 @@ interface ImageWithFallbackProps {
|
||||||
const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) => {
|
const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) => {
|
||||||
const [imgSrc, setImgSrc] = useState<string>(url);
|
const [imgSrc, setImgSrc] = useState<string>(url);
|
||||||
const [isObjectUrl, setIsObjectUrl] = useState<boolean>(false);
|
const [isObjectUrl, setIsObjectUrl] = useState<boolean>(false);
|
||||||
const [errorCount, setErrorCount] = useState<number>(0);
|
|
||||||
const maxRetries = 2;
|
|
||||||
|
|
||||||
// Clean up object URL when component unmounts
|
// Clean up object URL when component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -26,51 +24,13 @@ const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) =
|
||||||
};
|
};
|
||||||
}, [imgSrc, url, isObjectUrl]);
|
}, [imgSrc, url, isObjectUrl]);
|
||||||
|
|
||||||
// Reset when URL changes
|
|
||||||
useEffect(() => {
|
|
||||||
setImgSrc(url);
|
|
||||||
setIsObjectUrl(false);
|
|
||||||
setErrorCount(0);
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
// Special handling for blob URLs
|
|
||||||
useEffect(() => {
|
|
||||||
const handleBlobUrl = async () => {
|
|
||||||
if (url.startsWith('blob:') && !isObjectUrl) {
|
|
||||||
try {
|
|
||||||
// For blob URLs, we don't need to fetch again, just set directly
|
|
||||||
setImgSrc(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error with blob URL:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleBlobUrl();
|
|
||||||
}, [url, isObjectUrl]);
|
|
||||||
|
|
||||||
const handleError = async () => {
|
const handleError = async () => {
|
||||||
// Prevent infinite retry loops
|
console.error('Image failed to load:', url);
|
||||||
if (errorCount >= maxRetries) {
|
|
||||||
console.error(`Image failed to load after ${maxRetries} attempts:`, url);
|
|
||||||
onError('Failed to load image after multiple attempts. The file may be corrupted or unsupported.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorCount(prev => prev + 1);
|
|
||||||
console.error(`Image failed to load (attempt ${errorCount + 1}):`, url);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Skip fetch for blob URLs that already failed
|
|
||||||
if (url.startsWith('blob:')) {
|
|
||||||
throw new Error('Blob URL failed to load directly');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to fetch the image as a blob and create an object URL
|
// Try to fetch the image as a blob and create an object URL
|
||||||
const response = await fetch(url, {
|
console.log('Trying to fetch image as blob:', url);
|
||||||
mode: 'cors',
|
const response = await fetch(url, { mode: 'cors' });
|
||||||
cache: 'no-cache' // Avoid caching issues
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
@ -78,24 +38,27 @@ const ImageWithFallback = ({ url, filename, onError }: ImageWithFallbackProps) =
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
console.log('Created object URL:', objectUrl);
|
||||||
|
|
||||||
// Update the image source with the object URL
|
// Update the image source with the object URL
|
||||||
setImgSrc(objectUrl);
|
setImgSrc(objectUrl);
|
||||||
setIsObjectUrl(true);
|
setIsObjectUrl(true);
|
||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
console.error('Error fetching image as blob:', fetchError);
|
console.error('Error fetching image as blob:', fetchError);
|
||||||
|
|
||||||
// Only show error to user on final retry
|
|
||||||
if (errorCount >= maxRetries - 1) {
|
|
||||||
onError('Failed to load image. This might be due to permission issues or the file may not exist.');
|
onError('Failed to load image. This might be due to permission issues or the file may not exist.');
|
||||||
}
|
|
||||||
|
// Log additional details
|
||||||
|
console.log('Image URL that failed:', url);
|
||||||
|
console.log('Current auth status:',
|
||||||
|
Authentication.getInstance().isAuthenticated() ? 'Authenticated' : 'Not authenticated'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={imgSrc}
|
src={imgSrc}
|
||||||
alt={filename || 'Image preview'}
|
alt={filename}
|
||||||
className="max-w-full h-auto rounded-lg"
|
className="max-w-full h-auto rounded-lg"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
|
@ -204,22 +167,6 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check cache first
|
|
||||||
const cacheKey = `${state.url}_${state.filename}`;
|
|
||||||
const cachedData = contentCache.get(cacheKey);
|
|
||||||
|
|
||||||
if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) {
|
|
||||||
// Use cached data
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: cachedData.content,
|
|
||||||
fileType: cachedData.fileType,
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
loadingRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special handling for PDFs
|
// Special handling for PDFs
|
||||||
if (state.url.endsWith('.pdf')) {
|
if (state.url.endsWith('.pdf')) {
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
|
@ -228,377 +175,12 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
fileType: 'application/pdf',
|
fileType: 'application/pdf',
|
||||||
loading: false
|
loading: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'pdf',
|
|
||||||
fileType: 'application/pdf',
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle image files
|
// Rest of your existing loadContent logic
|
||||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
|
// ... existing content loading code ...
|
||||||
if (imageExtensions.some(ext => state.url.toLowerCase().endsWith(ext) ||
|
|
||||||
(state.filename && state.filename.toLowerCase().endsWith(ext)))) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: 'image',
|
|
||||||
fileType: 'image/' + (state.url.split('.').pop() || 'jpeg'),
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'image',
|
|
||||||
fileType: 'image/' + (state.url.split('.').pop() || 'jpeg'),
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
loadingRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle video files
|
|
||||||
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi'];
|
|
||||||
if (videoExtensions.some(ext => state.url.toLowerCase().endsWith(ext) ||
|
|
||||||
(state.filename && state.filename.toLowerCase().endsWith(ext)))) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: 'video',
|
|
||||||
fileType: 'video/' + (state.url.split('.').pop() || 'mp4'),
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'video',
|
|
||||||
fileType: 'video/' + (state.url.split('.').pop() || 'mp4'),
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
loadingRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other file types, try to fetch the content
|
|
||||||
// Handle blob URLs (for local file previews)
|
|
||||||
if (state.url.startsWith('blob:')) {
|
|
||||||
try {
|
|
||||||
// Determine file type from filename if available
|
|
||||||
let fileType = '';
|
|
||||||
if (state.filename) {
|
|
||||||
const extension = state.filename.split('.').pop()?.toLowerCase();
|
|
||||||
if (extension) {
|
|
||||||
switch (extension) {
|
|
||||||
case 'jpg':
|
|
||||||
case 'jpeg':
|
|
||||||
case 'png':
|
|
||||||
case 'gif':
|
|
||||||
case 'webp':
|
|
||||||
case 'bmp':
|
|
||||||
case 'svg':
|
|
||||||
fileType = `image/${extension === 'jpg' ? 'jpeg' : extension}`;
|
|
||||||
break;
|
|
||||||
case 'mp4':
|
|
||||||
case 'webm':
|
|
||||||
case 'ogg':
|
|
||||||
case 'mov':
|
|
||||||
fileType = `video/${extension}`;
|
|
||||||
break;
|
|
||||||
case 'pdf':
|
|
||||||
fileType = 'application/pdf';
|
|
||||||
break;
|
|
||||||
case 'doc':
|
|
||||||
fileType = 'application/msword';
|
|
||||||
break;
|
|
||||||
case 'docx':
|
|
||||||
fileType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
||||||
break;
|
|
||||||
case 'xls':
|
|
||||||
fileType = 'application/vnd.ms-excel';
|
|
||||||
break;
|
|
||||||
case 'xlsx':
|
|
||||||
fileType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
||||||
break;
|
|
||||||
case 'ppt':
|
|
||||||
fileType = 'application/vnd.ms-powerpoint';
|
|
||||||
break;
|
|
||||||
case 'pptx':
|
|
||||||
fileType = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
|
|
||||||
break;
|
|
||||||
case 'txt':
|
|
||||||
case 'md':
|
|
||||||
case 'js':
|
|
||||||
case 'jsx':
|
|
||||||
case 'ts':
|
|
||||||
case 'tsx':
|
|
||||||
case 'html':
|
|
||||||
case 'css':
|
|
||||||
case 'json':
|
|
||||||
case 'yml':
|
|
||||||
case 'yaml':
|
|
||||||
case 'csv':
|
|
||||||
fileType = 'text/plain';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
fileType = 'application/octet-stream';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to fetch the blob
|
|
||||||
const response = await fetch(state.url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
// If we couldn't determine file type from filename, use the blob type
|
|
||||||
if (!fileType && blob.type) {
|
|
||||||
fileType = blob.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle different file types
|
|
||||||
if (fileType.startsWith('image/') ||
|
|
||||||
(state.filename && /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(state.filename))) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: 'image',
|
|
||||||
fileType: fileType || 'image/jpeg',
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'image',
|
|
||||||
fileType: fileType || 'image/jpeg',
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else if (fileType.startsWith('video/') ||
|
|
||||||
(state.filename && /\.(mp4|webm|ogg|mov|avi)$/i.test(state.filename))) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: 'video',
|
|
||||||
fileType: fileType || 'video/mp4',
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'video',
|
|
||||||
fileType: fileType || 'video/mp4',
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else if (fileType === 'application/pdf' ||
|
|
||||||
(state.filename && /\.pdf$/i.test(state.filename))) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: 'pdf',
|
|
||||||
fileType: 'application/pdf',
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'pdf',
|
|
||||||
fileType: 'application/pdf',
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
fileType === 'application/msword' ||
|
|
||||||
fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
|
||||||
fileType === 'application/vnd.ms-excel' ||
|
|
||||||
fileType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
|
||||||
fileType === 'application/vnd.ms-powerpoint' ||
|
|
||||||
fileType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
|
||||||
(state.filename && /\.(doc|docx|xls|xlsx|ppt|pptx)$/i.test(state.filename))
|
|
||||||
) {
|
|
||||||
// Handle Office documents with a document icon and download option
|
|
||||||
const extension = state.filename?.split('.').pop()?.toLowerCase() || '';
|
|
||||||
let documentType = 'document';
|
|
||||||
|
|
||||||
if (['xls', 'xlsx'].includes(extension)) {
|
|
||||||
documentType = 'spreadsheet';
|
|
||||||
} else if (['ppt', 'pptx'].includes(extension)) {
|
|
||||||
documentType = 'presentation';
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: `document-${documentType}`,
|
|
||||||
fileType: fileType || `application/${documentType}`,
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: `document-${documentType}`,
|
|
||||||
fileType: fileType || `application/${documentType}`,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// For text files, read the content
|
|
||||||
try {
|
|
||||||
const text = await blob.text();
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: text,
|
|
||||||
fileType: fileType || 'text/plain',
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: text,
|
|
||||||
fileType: fileType || 'text/plain',
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} catch (textError) {
|
|
||||||
console.error('Error reading blob as text:', textError);
|
|
||||||
throw new Error('Failed to read file content');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingRef.current = false;
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing blob URL:', error);
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: 'Failed to load file preview. Please try again or proceed with upload.',
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
loadingRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For remote files
|
|
||||||
const response = await fetch(state.url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
|
||||||
|
|
||||||
if (contentType.startsWith('image/')) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: 'image',
|
|
||||||
fileType: contentType,
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'image',
|
|
||||||
fileType: contentType,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else if (contentType.startsWith('video/')) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: 'video',
|
|
||||||
fileType: contentType,
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'video',
|
|
||||||
fileType: contentType,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else if (contentType === 'application/pdf') {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: 'pdf',
|
|
||||||
fileType: contentType,
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'pdf',
|
|
||||||
fileType: contentType,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
contentType === 'application/msword' ||
|
|
||||||
contentType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
|
||||||
(state.filename && /\.(doc|docx)$/i.test(state.filename))
|
|
||||||
) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: 'document-document',
|
|
||||||
fileType: contentType || 'application/document',
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'document-document',
|
|
||||||
fileType: contentType || 'application/document',
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
contentType === 'application/vnd.ms-excel' ||
|
|
||||||
contentType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
|
||||||
(state.filename && /\.(xls|xlsx)$/i.test(state.filename))
|
|
||||||
) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: 'document-spreadsheet',
|
|
||||||
fileType: contentType || 'application/spreadsheet',
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'document-spreadsheet',
|
|
||||||
fileType: contentType || 'application/spreadsheet',
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
contentType === 'application/vnd.ms-powerpoint' ||
|
|
||||||
contentType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
|
||||||
(state.filename && /\.(ppt|pptx)$/i.test(state.filename))
|
|
||||||
) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: 'document-presentation',
|
|
||||||
fileType: contentType || 'application/presentation',
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: 'document-presentation',
|
|
||||||
fileType: contentType || 'application/presentation',
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// For text files, read the content
|
|
||||||
const text = await response.text();
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
content: text,
|
|
||||||
fileType: contentType,
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
contentCache.set(cacheKey, {
|
|
||||||
content: text,
|
|
||||||
fileType: contentType,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading content:', err);
|
console.error('Error loading content:', err);
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
|
@ -611,20 +193,8 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
}, [state.url]);
|
}, [state.url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.url) return;
|
if (!state.url || (!state.isVisible && isModal)) return;
|
||||||
|
|
||||||
// For modal, only load when visible
|
|
||||||
if (isModal && !state.isVisible) return;
|
|
||||||
|
|
||||||
// Reset loading state when URL changes
|
|
||||||
loadingRef.current = false;
|
|
||||||
|
|
||||||
// Small timeout to ensure state updates are processed
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
loadContent();
|
loadContent();
|
||||||
}, 50);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [state.url, state.isVisible, isModal, loadContent]);
|
}, [state.url, state.isVisible, isModal, loadContent]);
|
||||||
|
|
||||||
// Intersection observer effect
|
// Intersection observer effect
|
||||||
|
@ -794,14 +364,7 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
// Update the Try Again button handler
|
// Update the Try Again button handler
|
||||||
const handleTryAgain = useCallback(() => {
|
const handleTryAgain = useCallback(() => {
|
||||||
loadingRef.current = false; // Reset loading ref
|
loadingRef.current = false; // Reset loading ref
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: null,
|
|
||||||
loading: true
|
|
||||||
}));
|
|
||||||
setTimeout(() => {
|
|
||||||
loadContent();
|
loadContent();
|
||||||
}, 100); // Small delay to ensure state is updated
|
|
||||||
}, [loadContent]);
|
}, [loadContent]);
|
||||||
|
|
||||||
// If URL is empty, show a message
|
// If URL is empty, show a message
|
||||||
|
@ -836,8 +399,7 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="preview-content overflow-auto border border-base-300 rounded-lg bg-base-200/50 relative">
|
<div className="preview-content overflow-auto border border-base-300 rounded-lg bg-base-200/50 relative">
|
||||||
{/* Initial loading state - show immediately when URL is provided but content hasn't loaded yet */}
|
{!state.loading && !state.error && state.content === null && (
|
||||||
{state.url && !state.loading && !state.error && state.content === null && (
|
|
||||||
<div className="flex justify-center items-center p-8">
|
<div className="flex justify-center items-center p-8">
|
||||||
<span className="loading loading-spinner loading-lg"></span>
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -886,39 +448,22 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
|
|
||||||
{!state.loading && !state.error && state.content === 'video' && (
|
{!state.loading && !state.error && state.content === 'video' && (
|
||||||
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
|
<div className="flex justify-center bg-base-200 p-4 rounded-b-lg">
|
||||||
<div className="w-full max-w-2xl">
|
|
||||||
<video
|
<video
|
||||||
controls
|
controls
|
||||||
className="max-w-full h-auto rounded-lg"
|
className="max-w-full h-auto rounded-lg"
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
src={state.url}
|
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.error('Video failed to load:', e);
|
console.error('Video failed to load:', e);
|
||||||
|
|
||||||
// For blob URLs, try a different approach
|
|
||||||
if (state.url.startsWith('blob:')) {
|
|
||||||
const videoElement = e.target as HTMLVideoElement;
|
|
||||||
|
|
||||||
// Try to set the src directly
|
|
||||||
try {
|
|
||||||
videoElement.src = state.url;
|
|
||||||
videoElement.load();
|
|
||||||
return;
|
|
||||||
} catch (directError) {
|
|
||||||
console.error('Direct src assignment failed:', directError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
error: 'Failed to load video. This might be due to permission issues or the file may not exist.'
|
error: 'Failed to load video. This might be due to permission issues or the file may not exist.'
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<source src={state.url} type={state.fileType || 'video/mp4'} />
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!state.loading && !state.error && state.content === 'pdf' && (
|
{!state.loading && !state.error && state.content === 'pdf' && (
|
||||||
|
@ -977,41 +522,6 @@ export default function FilePreview({ url: initialUrl = '', filename: initialFil
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!state.loading && !state.error && state.content && state.content.startsWith('document-') && (
|
|
||||||
<div className="flex flex-col items-center justify-center bg-base-200 p-8 rounded-b-lg">
|
|
||||||
<div className="bg-primary/10 p-6 rounded-full mb-6">
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
state.content === 'document-spreadsheet'
|
|
||||||
? "mdi:file-excel"
|
|
||||||
: state.content === 'document-presentation'
|
|
||||||
? "mdi:file-powerpoint"
|
|
||||||
: "mdi:file-word"
|
|
||||||
}
|
|
||||||
className="h-16 w-16 text-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold mb-2">{state.filename}</h3>
|
|
||||||
<p className="text-base-content/70 mb-6 text-center max-w-md">
|
|
||||||
This document cannot be previewed in the browser. Please download it to view its contents.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href={state.url}
|
|
||||||
download={state.filename}
|
|
||||||
className="btn btn-primary btn-lg gap-2"
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:download" className="h-5 w-5" />
|
|
||||||
Download {
|
|
||||||
state.content === 'document-spreadsheet'
|
|
||||||
? 'Spreadsheet'
|
|
||||||
: state.content === 'document-presentation'
|
|
||||||
? 'Presentation'
|
|
||||||
: 'Document'
|
|
||||||
}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!state.loading && !state.error && state.content && !['image', 'video', 'pdf'].includes(state.content) && (
|
{!state.loading && !state.error && state.content && !['image', 'video', 'pdf'].includes(state.content) && (
|
||||||
<div className="overflow-x-auto max-h-[600px] bg-base-200">
|
<div className="overflow-x-auto max-h-[600px] bg-base-200">
|
||||||
<div className={`p-1 ${state.filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}>
|
<div className={`p-1 ${state.filename.toLowerCase().endsWith('.csv') ? 'p-4' : ''}`}>
|
||||||
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,20 +1,7 @@
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
// Centralized toast provider to ensure consistent rendering
|
// Centralized toast provider to ensure consistent rendering
|
||||||
export default function ToastProvider() {
|
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 (
|
return (
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-center"
|
position="top-center"
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -45,17 +45,9 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
|
||||||
if (!day) return [];
|
if (!day) return [];
|
||||||
const dayStr = formatDate(day);
|
const dayStr = formatDate(day);
|
||||||
return events.filter((event) => {
|
return events.filter((event) => {
|
||||||
let eventDate;
|
const eventDate = event.start.dateTime
|
||||||
if (event.start.dateTime) {
|
? new Date(event.start.dateTime).toISOString().split("T")[0]
|
||||||
// For events with specific times, convert to local timezone
|
: event.start.date;
|
||||||
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;
|
|
||||||
}
|
|
||||||
return eventDate === dayStr;
|
return eventDate === dayStr;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -76,26 +68,26 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
|
||||||
|
|
||||||
const loadGapiAndListEvents = async () => {
|
const loadGapiAndListEvents = async () => {
|
||||||
try {
|
try {
|
||||||
// console.log("Starting to load events...");
|
console.log("Starting to load events...");
|
||||||
|
|
||||||
if (typeof window.gapi === "undefined") {
|
if (typeof window.gapi === "undefined") {
|
||||||
// console.log("Loading GAPI script...");
|
console.log("Loading GAPI script...");
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.src = "https://apis.google.com/js/api.js";
|
script.src = "https://apis.google.com/js/api.js";
|
||||||
document.body.appendChild(script);
|
document.body.appendChild(script);
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
// console.log("GAPI script loaded");
|
console.log("GAPI script loaded");
|
||||||
window.gapi.load("client", resolve);
|
window.gapi.load("client", resolve);
|
||||||
};
|
};
|
||||||
script.onerror = () => {
|
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."));
|
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({
|
await window.gapi.client.init({
|
||||||
apiKey: CALENDAR_API_KEY,
|
apiKey: CALENDAR_API_KEY,
|
||||||
discoveryDocs: [
|
discoveryDocs: [
|
||||||
|
@ -115,7 +107,7 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// console.log("Fetching events...");
|
console.log("Fetching events...");
|
||||||
const response = await window.gapi.client.calendar.events.list({
|
const response = await window.gapi.client.calendar.events.list({
|
||||||
calendarId: calendarId,
|
calendarId: calendarId,
|
||||||
timeZone: userTimeZone,
|
timeZone: userTimeZone,
|
||||||
|
@ -125,13 +117,13 @@ const Calendar = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
|
||||||
orderBy: "startTime",
|
orderBy: "startTime",
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log("Response received:", response);
|
console.log("Response received:", response);
|
||||||
|
|
||||||
if (response.result.items) {
|
if (response.result.items) {
|
||||||
setEvents(response.result.items);
|
setEvents(response.result.items);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("Detailed Error: ", error);
|
console.error("Detailed Error: ", error);
|
||||||
setError(error.message || "Failed to load events");
|
setError(error.message || "Failed to load events");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
@ -1,27 +1,27 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
const UpcomingEvent = ({ name, location, date, time, delay, description }) => (
|
const UpcomingEvent = ({ name, location, date, time, delay, description }) => (
|
||||||
<div className="text-white w-[40vw] pl-[8%] md:border-l-[0.3vw] border-l-[0.5vw] border-white/70 md:pb-[5%] pb-[10%] relative">
|
<div className="text-white w-[40vw] pl-[8%] md:border-l-[0.3vw] border-l-[0.5vw] border-white/70 pb-[5%] relative">
|
||||||
<p
|
<p
|
||||||
data-inview
|
data-inview
|
||||||
className={`animate-duration-500 animate-delay-${delay * 200} in-view:animate-fade-left py-[0.2%] pl-[8%] md:pl-[2%] md:pr-[2%] w-fit border-[0.1vw] font-light rounded-full md:text-[1.3vw] text-[2.3vw]`}
|
className={`animate-duration-500 animate-delay-${delay * 200} in-view:animate-fade-left py-[0.2%] px-[2%] w-fit border-[0.1vw] font-light rounded-full md:text-[1.3vw] text-[2.3vw]`}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
data-inview
|
data-inview
|
||||||
className={`animate-duration-500 animate-delay-${delay * 200 + 100} in-view:animate-fade-left md:flex justify-between items-center min-w-[70%] w-fit md:text-[1.2vw] text-[2vw] my-[2%]`}
|
className={`animate-duration-500 animate-delay-${delay * 200 + 100} in-view:animate-fade-left flex justify-between items-center min-w-[70%] w-fit md:text-[1.2vw] text-[2vw] my-[2%]`}
|
||||||
>
|
>
|
||||||
Location: {location}
|
Location: {location}
|
||||||
{date && (
|
{date && (
|
||||||
<>
|
<>
|
||||||
<div className="md:visible invisible bg-white h-[0.5vw] w-[0.5vw] rounded-full mx-[0.5vw]" />
|
<div className="bg-white h-[0.5vw] w-[0.5vw] rounded-full mx-[0.5vw]" />
|
||||||
<p>{date}</p>
|
<p>{date}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{time && (
|
{time && (
|
||||||
<>
|
<>
|
||||||
<div className="md:visible invisible bg-white h-[0.5vw] w-[0.5vw] rounded-full mx-[0.5vw]" />
|
<div className="bg-white h-[0.5vw] w-[0.5vw] rounded-full mx-[0.5vw]" />
|
||||||
<p>{time}</p>
|
<p>{time}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -48,16 +48,16 @@ const EventList = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
|
||||||
|
|
||||||
const loadGapiAndListEvents = async () => {
|
const loadGapiAndListEvents = async () => {
|
||||||
try {
|
try {
|
||||||
// console.log("Starting to load events...");
|
console.log("Starting to load events...");
|
||||||
|
|
||||||
if (typeof window.gapi === "undefined") {
|
if (typeof window.gapi === "undefined") {
|
||||||
// console.log("Loading GAPI script...");
|
console.log("Loading GAPI script...");
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.src = "https://apis.google.com/js/api.js";
|
script.src = "https://apis.google.com/js/api.js";
|
||||||
document.body.appendChild(script);
|
document.body.appendChild(script);
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
// console.log("GAPI script loaded");
|
console.log("GAPI script loaded");
|
||||||
window.gapi.load("client", resolve);
|
window.gapi.load("client", resolve);
|
||||||
};
|
};
|
||||||
script.onerror = () => {
|
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({
|
await window.gapi.client.init({
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
discoveryDocs: [
|
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({
|
const response = await window.gapi.client.calendar.events.list({
|
||||||
calendarId: calendarId,
|
calendarId: calendarId,
|
||||||
timeZone: userTimeZone,
|
timeZone: userTimeZone,
|
||||||
|
@ -85,7 +85,7 @@ const EventList = ({ CALENDAR_API_KEY, EVENT_CALENDAR_ID }) => {
|
||||||
orderBy: "startTime",
|
orderBy: "startTime",
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log("Response received:", response);
|
console.log("Response received:", response);
|
||||||
|
|
||||||
if (response.result.items) {
|
if (response.result.items) {
|
||||||
setEvents(response.result.items);
|
setEvents(response.result.items);
|
||||||
|
|
20
src/components/events/UpcomingEvent.astro
Normal file
20
src/components/events/UpcomingEvent.astro
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
const {name, location, date, time, description, delay} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="text-white w-[40vw] pl-[8%] border-l-[0.3vw] border-white/70 pb-[5%] relative">
|
||||||
|
<p data-inview class={`animate-delay-${delay} in-view:animate-fade-left py-[0.2%] px-[2%] w-fit border-[0.1vw] font-light rounded-full text-[1.3vw]`}>
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
<div data-inview class={`animate-delay-${delay} in-view:animate-fade-left flex justify-between items-center min-w-[70%] w-fit text-[1.2vw] my-[2%]`}>
|
||||||
|
<p>Location: {location}</p>
|
||||||
|
<div class="bg-white h-[0.5vw] w-[0.5vw] rounded-full" />
|
||||||
|
<p>{date}</p>
|
||||||
|
<div class="bg-white h-[0.5vw] w-[0.5vw] rounded-full" />
|
||||||
|
<p>{time}</p>
|
||||||
|
</div>
|
||||||
|
<p data-inview class={`animate-delay-${delay} in-view:animate-fade-left text-[1vw] text-white/60`}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<div class="bg-ieee-yellow h-[1.2vw] w-[1.2vw] rounded-full absolute -top-[1.5%] -left-[2%]" />
|
||||||
|
</div>
|
|
@ -6,60 +6,38 @@ import { IoMdCalendar } from "react-icons/io";
|
||||||
import { RiRobot2Fill } from "react-icons/ri";
|
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
|
<img src={image} alt="involvement background" class="opacity-70 aspect-[230/425] object-cover rounded-[2vw] group-hover:opacity-50 duration-300"/>
|
||||||
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
|
<Link
|
||||||
href={link}
|
href={link}
|
||||||
target={text==="H.A.R.D. HACK"? "_blank":"_self"}
|
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"
|
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="w-full flex justify-end md:pr-[5%] pr-[2vw]">
|
||||||
<div
|
<div class="bg-white w-fit rounded-full aspect-square p-[0.5vw] text-ieee-black text-[4.5vw] md:text-[2vw]">
|
||||||
class="bg-white w-fit rounded-full aspect-square p-[0.5vw] text-ieee-black text-[4.5vw] md:text-[2vw]"
|
|
||||||
>
|
|
||||||
{
|
{
|
||||||
text === "PROJECTS" ? (
|
text === "PROJECTS"? <RiRobot2Fill/>:
|
||||||
<RiRobot2Fill />
|
text === "EVENTS"? <IoMdCalendar/>:
|
||||||
) : text === "EVENTS" ? (
|
|
||||||
<IoMdCalendar />
|
|
||||||
) : (
|
|
||||||
<FaGear/>
|
<FaGear/>
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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%]">
|
||||||
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">
|
||||||
<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}
|
{text}
|
||||||
</p>
|
</p>
|
||||||
<GoArrowDownRight
|
<GoArrowDownRight className="text-[5vw] md:text-[3vw] leading-none group-hover:text-ieee-yellow"/>
|
||||||
className="text-[5vw] md:text-[3vw] leading-none group-hover:text-ieee-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{text === "H.A.R.D. HACK" &&
|
||||||
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">
|
<p class="text-[1.8vw] md:text-[1vw] text-center pt-[10%] group-hover:text-ieee-yellow duration-300">
|
||||||
UC San Diego’s largest hardware focused hackathon hold by IEEE UCSD,
|
UC San Diego’s largest
|
||||||
HKN, and TNT
|
hardware focused hackathon
|
||||||
|
hold by IEEE UCSD, HKN, and TNT
|
||||||
</p>
|
</p>
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -4,35 +4,20 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
|
||||||
const { title, text, link, number, delay } = Astro.props;
|
const { title, text, link, number, delay } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<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}`}>
|
||||||
data-inview
|
<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"/>
|
||||||
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">
|
<p class="text-[2.7vw] md:text-[1.7vw] font-bold text-center">
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
<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%]"}`}>
|
||||||
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}
|
{text}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link
|
<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">
|
||||||
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
|
more details
|
||||||
<IoIosArrowDroprightCircle
|
<IoIosArrowDroprightCircle className="ml-[0.5vw] text-[2vw] md:text-[1.4vw]"/>
|
||||||
className="ml-[0.5vw] text-[2vw] md:text-[1.4vw]"
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
<p
|
<p class="text-[7vw] md:text-[3.7vw] font-bold text-ieee-blue-300/50 absolute bottom-[5%]">
|
||||||
class="text-[7vw] md:text-[3.7vw] font-bold text-ieee-blue-300/50 absolute bottom-[5%]"
|
|
||||||
>
|
|
||||||
{number}
|
{number}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
|
@ -7,9 +7,9 @@ import { RiInstagramFill } from "react-icons/ri";
|
||||||
import { MdEmail } from "react-icons/md";
|
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
|
<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
|
<p
|
||||||
data-inview
|
data-inview
|
||||||
|
@ -60,12 +60,12 @@ import { MdEmail } from "react-icons/md";
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<Image
|
||||||
src={landingimg}
|
src={landingimg}
|
||||||
alt="circuit"
|
alt="circuit"
|
||||||
class="w-[95%] md:h-[45vw] h-[60vw] object-contain"
|
class="w-[95%] md:h-[40vw] h-[60vw] object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,32 +3,29 @@ import { LiaDotCircle } from "react-icons/lia";
|
||||||
import ProjectSection from "./ProjectSection.astro";
|
import ProjectSection from "./ProjectSection.astro";
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
<div class="text-white md:mt-[15%] mt-[25%] mb-[30%] mx-[10%] relative">
|
<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%]">
|
<div class="flex items-center md:text-[2.7vw] text-[4.5vw] mb-[7%]">
|
||||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
|
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
|
||||||
<p>Annual Projects</p>
|
<p>
|
||||||
|
Annual Projects
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p class="md:text-[1.2vw] text-[2vw] mt-[5%] mb-[3%] md:ml-[25%] ml-[15%] font-light">
|
||||||
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.
|
||||||
>
|
|
||||||
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>
|
</p>
|
||||||
|
|
||||||
<div
|
<div class="flex items-center md:text-[1.2vw] text-[2vw] font-light mb-[7%] justify-between md:ml-[20%] ml-[10%]">
|
||||||
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%]"/>
|
<LiaDotCircle className="md:text-[2vw] text-[3vw] pt-[0.5%]"/>
|
||||||
<p class="md:text-[2vw] text-[3vw] mr-[2vw]">Skills & Requirements</p>
|
<p class="md:text-[2vw] text-[3vw]">
|
||||||
|
Skills & Requirements
|
||||||
|
</p>
|
||||||
<p class="w-3/5">
|
<p class="w-3/5">
|
||||||
IEEE @ UCSD’s annual projects are intended for students with intermediate
|
IEEE @ UCSD’s 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.
|
||||||
experience with hardware or software. Participation on teams assemble an
|
|
||||||
array of skills and talents of soft and hard skills.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProjectSection />
|
<ProjectSection />
|
||||||
|
|
||||||
</div>
|
</div>
|
|
@ -7,13 +7,13 @@ import { Image } from "astro:assets";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<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"
|
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) => (
|
Object.entries(annualProjects).map(([title, project], index) => (
|
||||||
<a
|
<a
|
||||||
href={project.url || "#"}
|
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" : ""}`}
|
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}
|
data-project={index + 1}
|
||||||
target={title === "Supercomputing" ? "_blank" : "_self"}
|
target={title === "Supercomputing" ? "_blank" : "_self"}
|
||||||
>
|
>
|
||||||
|
@ -23,24 +23,24 @@ import { Image } from "astro:assets";
|
||||||
alt={`${title} Project`}
|
alt={`${title} Project`}
|
||||||
width={668}
|
width={668}
|
||||||
height={990}
|
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"
|
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="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">
|
<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%]">
|
<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}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[3vw] md:text-[1.3vw] block md:hidden md:[.expanded_&]:block transition-all duration-300 overflow-hidden mb-[3vw]">
|
<p class="text-[2vw] md:text-[1.3vw] md:hidden md:[.expanded_&]:contents transition-all duration-300 overflow-hidden">
|
||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</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="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-[3vw] md:[.expanded_&]:mt-[5%]">
|
<div class="flex items-center md:text-[1.3vw] text-[2vw] md:[.expanded_&]:mt-[5%]">
|
||||||
more details
|
more details
|
||||||
<IoIosArrowDroprightCircle className="ml-[0.5vw] text-[3vw] md:text-[1.4vw]" />
|
<IoIosArrowDroprightCircle className="ml-[0.5vw] text-[1.4vw]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GoArrowDownRight className="text-[3.2vw] [.expanded_&]:text-[0px] pt-[2%] hidden md:block" />
|
<GoArrowDownRight className="text-[3.2vw] [.expanded_&]:text-[0px] pt-[2%]" />
|
||||||
</div>
|
</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%]">
|
<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%]">
|
||||||
|
@ -77,20 +77,12 @@ import { Image } from "astro:assets";
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.project-card {
|
|
||||||
height: auto;
|
|
||||||
margin-bottom: 5vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function initializeProjectCards() {
|
function initializeProjectCards() {
|
||||||
const projectCards = document.querySelectorAll(".project-card");
|
const projectCards = document.querySelectorAll(".project-card");
|
||||||
const STORAGE_KEY = "lastExpandedCardIndex";
|
const STORAGE_KEY = "lastExpandedCardIndex";
|
||||||
const isMobile = window.innerWidth < 768;
|
|
||||||
|
|
||||||
// Function to remove expanded class from all cards
|
// Function to remove expanded class from all cards
|
||||||
function removeExpandedFromAll() {
|
function removeExpandedFromAll() {
|
||||||
|
@ -113,32 +105,19 @@ import { Image } from "astro:assets";
|
||||||
const lastExpandedIndex = parseInt(
|
const lastExpandedIndex = parseInt(
|
||||||
localStorage.getItem(STORAGE_KEY) || "0",
|
localStorage.getItem(STORAGE_KEY) || "0",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only apply expanded state on desktop
|
|
||||||
if (!isMobile) {
|
|
||||||
expandCard(lastExpandedIndex);
|
expandCard(lastExpandedIndex);
|
||||||
}
|
|
||||||
|
|
||||||
// Add hover listeners to each card
|
// Add hover listeners to each card
|
||||||
projectCards.forEach((card, index) => {
|
projectCards.forEach((card, index) => {
|
||||||
card.addEventListener("mouseenter", () => {
|
card.addEventListener("mouseenter", () => {
|
||||||
if (!isMobile) {
|
|
||||||
expandCard(index);
|
expandCard(index);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle window resize
|
// Handle window resize
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
const currentIndex = parseInt(localStorage.getItem(STORAGE_KEY) || "0");
|
const currentIndex = parseInt(localStorage.getItem(STORAGE_KEY) || "0");
|
||||||
const isMobileNow = window.innerWidth < 768;
|
|
||||||
|
|
||||||
if (!isMobileNow) {
|
|
||||||
expandCard(currentIndex);
|
expandCard(currentIndex);
|
||||||
} else {
|
|
||||||
// On mobile, remove expanded class from all cards
|
|
||||||
removeExpandedFromAll();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -150,20 +129,20 @@ import { Image } from "astro:assets";
|
||||||
function handleImageLoading() {
|
function handleImageLoading() {
|
||||||
const projectImages = document.querySelectorAll(".project-card img");
|
const projectImages = document.querySelectorAll(".project-card img");
|
||||||
|
|
||||||
projectImages.forEach((image) => {
|
projectImages.forEach((image, index) => {
|
||||||
// Ensure the image is fully loaded, even if it's already in cache
|
// Ensure the image is fully loaded, even if it's already in cache
|
||||||
if ((image as HTMLImageElement).complete) {
|
if (image.complete) {
|
||||||
(image as HTMLImageElement).style.opacity = "1";
|
image.style.opacity = "1";
|
||||||
const skeleton = image.previousElementSibling;
|
const skeleton = image.previousElementSibling;
|
||||||
if (skeleton && skeleton.classList.contains("skeleton")) {
|
if (skeleton && skeleton.classList.contains("skeleton")) {
|
||||||
(skeleton as HTMLElement).style.display = "none";
|
skeleton.style.display = "none";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
image.addEventListener("load", () => {
|
image.addEventListener("load", () => {
|
||||||
(image as HTMLImageElement).style.opacity = "1";
|
image.style.opacity = "1";
|
||||||
const skeleton = image.previousElementSibling;
|
const skeleton = image.previousElementSibling;
|
||||||
if (skeleton && skeleton.classList.contains("skeleton")) {
|
if (skeleton && skeleton.classList.contains("skeleton")) {
|
||||||
(skeleton as HTMLElement).style.display = "none";
|
skeleton.style.display = "none";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,14 +16,13 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
|
||||||
<Image
|
<Image
|
||||||
src={qp}
|
src={qp}
|
||||||
alt="qp showcase photo"
|
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:w-[70vw] w-[85vw] md:aspect-[2.5/1] aspect-[2.5/1.2] rounded-full"
|
||||||
data-inview
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
data-inview
|
data-inview
|
||||||
href="/projects/quarterly"
|
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"
|
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
|
more details
|
||||||
|
@ -50,9 +49,7 @@ import { IoIosArrowDroprightCircle } from "react-icons/io";
|
||||||
|
|
||||||
if (image) {
|
if (image) {
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
if (skeleton) {
|
|
||||||
skeleton.style.display = "none";
|
skeleton.style.display = "none";
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
---
|
---
|
||||||
import Subtitle from "../core/Subtitle.astro";
|
import Subtitle from "../core/Subtitle.astro";
|
||||||
import { Image } from "astro:assets";
|
import { Image } from "astro:assets";
|
||||||
import qp from "../../images/qp2.jpg";
|
import qp from "../../images/qp.webp";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="flex flex-col items-center pb-[5%]">
|
<div class="flex flex-col items-center pb-[5%]">
|
||||||
<Subtitle title="We Are Excited To See You Here!" />
|
<Subtitle title="We Are Excited To See You Here!" />
|
||||||
|
|
||||||
<p class="w-1/3 text-center md:text-[1.2vw] text-[1.6vw] pb-[5%]">
|
<p class="w-1/3 text-center text-[1.2vw] pb-[5%]">
|
||||||
If you have further questions about QP, please to reach out to the QP chairs or Vice Chair Projects through the Board page!
|
Lorem ipsum is placeholder text commonly used in the graphic, print, and
|
||||||
|
publishing industries f
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Image
|
<Image
|
||||||
src={qp}
|
src={qp}
|
||||||
alt="board group photos"
|
alt="board group photos"
|
||||||
class="md:w-1/2 w-[65vw] rounded-[2vw] object-cover aspect-[2/1] opacity-85"
|
class="w-1/2 rounded-[2vw] object-cover aspect-[2/1]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,28 +3,24 @@ import { Image } from "astro:assets";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LiaDotCircle } from "react-icons/lia";
|
import { LiaDotCircle } from "react-icons/lia";
|
||||||
import mentorship from "../../images/mentorship.webp";
|
import mentorship from "../../images/mentorship.webp";
|
||||||
import join from "../../images/join.png";
|
import jonathan from "../../images/about3.webp";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="flex justify-center mb-[10%]">
|
<div class="flex justify-center mb-[10%]">
|
||||||
<div class="flex md:w-[70%] w-[80%] items-center">
|
<div class="flex w-[70%] items-center">
|
||||||
<div class="w-1/2 flex justify-center h-full relative">
|
<div class="w-1/2 flex justify-center h-full relative">
|
||||||
<Image src={mentorship} alt="blue background" class="mr-[20%]" />
|
<Image src={mentorship} alt="blue background" class="mr-[20%]" />
|
||||||
<Image
|
<Image
|
||||||
src={join}
|
src={jonathan}
|
||||||
alt="blue background"
|
alt="blue background"
|
||||||
<<<<<<< HEAD
|
class="absolute rounded-full object-cover aspect-[262/433] w-[54%] top-[5%] left-0"
|
||||||
class="absolute rounded-full object-cover aspect-[262/433] w-[54%] top-[5%] left-0 opacity-80"
|
|
||||||
=======
|
|
||||||
class="absolute rounded-full object-cover aspect-[262/433] w-[54%] md:top-[5%] top-[9%] left-0"
|
|
||||||
>>>>>>> 08bcf09f9c08053ec40f1c3ae02bbed3a374fb83
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 font-light md:text-[1.2vw] text-[1.6vw]">
|
<div class="w-1/2 font-light text-[1.2vw]">
|
||||||
<div class="flex items-center md:text-[2.3vw] text-[4vw] mb-[5%]">
|
<div class="flex items-center text-[2.3vw] mb-[5%]">
|
||||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
||||||
<p
|
<p
|
||||||
class="md:text-[2.3vw] text-[4vw] font-bold text-transparent bg-clip-text bg-gradient-to-r from-white to-ieee-yellow"
|
class="text-[2.3vw] font-bold text-transparent bg-clip-text bg-gradient-to-r from-white to-ieee-yellow"
|
||||||
>
|
>
|
||||||
How to join
|
How to join
|
||||||
</p>
|
</p>
|
||||||
|
@ -39,9 +35,9 @@ import join from "../../images/join.png";
|
||||||
</p>
|
</p>
|
||||||
<div class="w-full flex justify-center">
|
<div class="w-full flex justify-center">
|
||||||
<Link
|
<Link
|
||||||
href="https://tinyurl.com/20242025QPApps"
|
href="/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="py-[2%] px-[15%] md:text-[1.2vw] text-[1.6vw] border-[0.1vw] border-white rounded-[0.5vw] hover:text-ieee-yellow hover:border-ieee-yellow duration-300 font-light"
|
className="py-[2%] px-[15%] text-[1.2vw] border-[0.1vw] border-white rounded-[0.5vw] hover:text-ieee-yellow hover:border-ieee-yellow duration-300 font-light"
|
||||||
>
|
>
|
||||||
JOIN
|
JOIN
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -7,12 +7,12 @@ import jonathan from "../../images/about3.webp";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="flex justify-center mt-[10%] mb-[3%]">
|
<div class="flex justify-center mt-[10%] mb-[3%]">
|
||||||
<div class="flex md:w-[70%] w-[80%] items-center">
|
<div class="flex w-[70%] items-center">
|
||||||
<div class="md:w-1/2 w-[60%] font-light md:text-[1.2vw] text-[1.6vw]">
|
<div class="w-1/2 font-light text-[1.2vw]">
|
||||||
<div class="flex items-center md:text-[2.3vw] text-[4vw] mb-[5%]">
|
<div class="flex items-center text-[2.3vw] mb-[5%]">
|
||||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]" />
|
||||||
<p
|
<p
|
||||||
class="md:text-[2.3vw] text-[4vw] font-bold text-transparent bg-clip-text bg-gradient-to-l from-white to-ieee-yellow"
|
class="text-[2.3vw] font-bold text-transparent bg-clip-text bg-gradient-to-l from-white to-ieee-yellow"
|
||||||
>
|
>
|
||||||
Mentorship
|
Mentorship
|
||||||
</p>
|
</p>
|
||||||
|
@ -31,9 +31,9 @@ import jonathan from "../../images/about3.webp";
|
||||||
</p>
|
</p>
|
||||||
<div class="w-full flex justify-center">
|
<div class="w-full flex justify-center">
|
||||||
<Link
|
<Link
|
||||||
href="https://tinyurl.com/20242025QPMentors"
|
href="/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="p-[2%] md:text-[1.2vw] text-[1.6vw] border-[0.1vw] border-white rounded-[0.5vw] hover:text-ieee-yellow hover:border-ieee-yellow duration-300 font-light"
|
className="p-[2%] text-[1.2vw] border-[0.1vw] border-white rounded-[0.5vw] hover:text-ieee-yellow hover:border-ieee-yellow duration-300 font-light"
|
||||||
>
|
>
|
||||||
BECOME A MENTOR
|
BECOME A MENTOR
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -45,7 +45,7 @@ import jonathan from "../../images/about3.webp";
|
||||||
<Image
|
<Image
|
||||||
src={jonathan}
|
src={jonathan}
|
||||||
alt="blue background"
|
alt="blue background"
|
||||||
class="absolute rounded-full object-cover aspect-[262/433] w-[54%] md:top-[5%] top-[15%] right-[10%]"
|
class="absolute rounded-full object-cover aspect-[262/433] w-[54%] top-[5%] right-[10%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,31 +4,15 @@ import { GoArrowDownRight } from "react-icons/go";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Link
|
<Link href={link} target="_blank" className={`ease-in-out group relative col-span-${col} text-white`}>
|
||||||
href={link}
|
<img src={image} alt="past projects image" class="h-[30vh] object-cover w-full rounded-[1.5vw] opacity-80 group-hover:opacity-40 duration-300" />
|
||||||
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
|
<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%]">
|
||||||
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.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">
|
||||||
<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}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
<GoArrowDownRight
|
<GoArrowDownRight className="text-[3vw] leading-none group-hover:text-ieee-yellow duration-300 "/>
|
||||||
className="text-[3vw] leading-none group-hover:text-ieee-yellow duration-300 "
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
|
@ -6,7 +6,7 @@ import Subtitle from "../core/Subtitle.astro";
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<Subtitle title="Past Projects" />
|
<Subtitle title="Past Projects" />
|
||||||
|
|
||||||
<div class="w-[80%] md:w-[70%] grid grid-cols-6 gap-[1.5vw]">
|
<div class="w-[70%] grid grid-cols-6 gap-[1.5vw]">
|
||||||
{projects.map((project)=>(
|
{projects.map((project)=>(
|
||||||
<PastProject
|
<PastProject
|
||||||
title = {project.title}
|
title = {project.title}
|
||||||
|
|
|
@ -3,7 +3,7 @@ const { text, number, col, position, width } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`relative col-span-${col} border-[0.1vw] border-white/30 text-[1.5vw] md:text-[1.2vw] bg-gradient-to-b from-white/10 to-ieee-blue-300/10 px-[1vw] py-[2vw] rounded-[1vw] `}
|
class={`relative col-span-${col} border-[0.1vw] border-white/30 text-[1.2vw] bg-gradient-to-b from-white/10 to-ieee-blue-300/10 px-[1vw] py-[2vw] rounded-[1vw] `}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class={`${position} text-[4vw] opacity-10 font-bold absolute leading-tight `}
|
class={`${position} text-[4vw] opacity-10 font-bold absolute leading-tight `}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import Subtitle from "../core/Subtitle.astro";
|
||||||
|
|
||||||
<div class="flex flex-col items-center mt-[7%] mb-[10%]">
|
<div class="flex flex-col items-center mt-[7%] mb-[10%]">
|
||||||
<Subtitle title="How it works:" />
|
<Subtitle title="How it works:" />
|
||||||
<div class="grid grid-rows-2 md:w-[70%] w-[80%] gap-[2vw]">
|
<div class="grid grid-rows-2 w-[70%] gap-[2vw]">
|
||||||
<div class="grid grid-cols-4 gap-[1.5vw]">
|
<div class="grid grid-cols-4 gap-[1.5vw]">
|
||||||
{
|
{
|
||||||
steps
|
steps
|
||||||
|
|
|
@ -1,19 +1,22 @@
|
||||||
---
|
---
|
||||||
const {timeline} = Astro.props;
|
import qpTimeline from "../../data/qpTimeline.json";
|
||||||
|
const { events } = qpTimeline;
|
||||||
import Subtitle from "../core/Subtitle.astro";
|
import Subtitle from "../core/Subtitle.astro";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="flex flex-col items-center md:mt-[10%] mt-[25%] md:mb-[15%] mb-[35%]">
|
<div class="flex flex-col items-center mt-[10%] mb-[15%]">
|
||||||
<Subtitle title="Timeline" />
|
<Subtitle title="Timeline" />
|
||||||
|
|
||||||
<div
|
<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 max-w-[65vw] py-[10%] mt-[3%]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute top-1/2 w-full h-[0.1vw] bg-gray-200 transform -translate-y-1/2"
|
class="absolute top-1/2 w-full h-[0.1vw] bg-gray-200 transform -translate-y-1/2"
|
||||||
/>
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
{timeline.map((event, index) => (
|
{
|
||||||
|
events.map((event, index) => (
|
||||||
<div
|
<div
|
||||||
class="relative flex flex-col items-center"
|
class="relative flex flex-col items-center"
|
||||||
style={`flex: 1`}
|
style={`flex: 1`}
|
||||||
|
@ -24,10 +27,10 @@ import Subtitle from "../core/Subtitle.astro";
|
||||||
} flex flex-col items-center`}
|
} flex flex-col items-center`}
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="font-bold text-ieee-yellow md:text-[1.4vw] text-[2vw]">
|
<div class="font-bold text-ieee-yellow text-[1.4vw]">
|
||||||
{event.week}
|
{event.week}
|
||||||
</div>
|
</div>
|
||||||
<div class="md:text-[1.1vw] text-[1.5vw] text-gray-300 md:max-w-[10vw] max-w-[15vw]">
|
<div class="text-[1.1vw] text-gray-300 max-w-[10vw]">
|
||||||
{event.description}
|
{event.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -45,6 +48,7 @@ import Subtitle from "../core/Subtitle.astro";
|
||||||
<div class="w-[1.2vw] h-[1.2vw] bg-white border-2 rounded-full" />
|
<div class="w-[1.2vw] h-[1.2vw] bg-white border-2 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
import { FaDiscord , FaGlobe} from "react-icons/fa";
|
import { FaDiscord} from "react-icons/fa";
|
||||||
|
import { RiInstagramFill } from "react-icons/ri";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LiaDotCircle } from "react-icons/lia";
|
import { LiaDotCircle } from "react-icons/lia";
|
||||||
---
|
---
|
||||||
|
@ -7,29 +8,29 @@ import { LiaDotCircle } from "react-icons/lia";
|
||||||
<div class="flex justify-center my-[10%]">
|
<div class="flex justify-center my-[10%]">
|
||||||
<div class="w-1/2 flex justify-between">
|
<div class="w-1/2 flex justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center md:text-[2.5vw] text-[4vw]">
|
<div class="flex items-center text-[2.5vw]">
|
||||||
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
|
<LiaDotCircle className=" mr-[1vw] pt-[0.5%]"/>
|
||||||
<p>
|
<p>
|
||||||
Contacts
|
Contacts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="md:text-[1.1vw] text-[1.5vw] ml-[5%] text-nowrap">
|
<p class="text-[1.1vw] ml-[5%] text-nowrap">
|
||||||
To stay up to date, join discord server
|
To stay up to date, join discord server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<Link href="https://ieee-ucsd-robocupssl.github.io/TeamWebsite/index.html" 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] 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">
|
<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">
|
||||||
<FaGlobe />
|
|
||||||
</div>
|
|
||||||
<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">
|
|
||||||
<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 />
|
<FaDiscord />
|
||||||
</div>
|
</div>
|
||||||
<p class="md:text-[1.3vw] text-[2vw] font-semibold">Discord</p>
|
<p class="text-[1.3vw] font-semibold">Facebook</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<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] text-[2.2vw] text-ieee-black/80 border-ieee-blue-100 bg-gradient-radial from-white via-white to-white/40">
|
||||||
|
<RiInstagramFill />
|
||||||
|
</div>
|
||||||
|
<p class="text-[1.3vw] font-semibold">Instagram</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -9,22 +9,23 @@ import robocup from "../../images/robocup.webp";
|
||||||
<Image
|
<Image
|
||||||
src={robocup}
|
src={robocup}
|
||||||
alt="robocub competition image"
|
alt="robocub competition image"
|
||||||
class="md:w-3/5 w-[90%] rounded-[2vw] object-cover aspect-[8/5] opacity-40"
|
class="w-3/5 rounded-[2vw] object-cover aspect-[8/5] opacity-40"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
href="https://docs.google.com/forms/d/e/1FAIpQLSex5VejEiClvgcfhSBQJ9IH5Q008j-HWC5Y9YAa56yIHgGBvw/viewform?usp=sf_link"
|
href="/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="absolute aspect-[8/5] md:w-3/5 w-[90%] p-[5%] group -bottom-[3%]"
|
className="absolute aspect-[8/5] w-3/5 p-[5%] group -bottom-[3%]"
|
||||||
>
|
>
|
||||||
<div class="w-full flex justify-end items-center h-4/5">
|
<div class="w-full flex justify-end items-center h-4/5">
|
||||||
<p class="md:text-[4.5vw] text-[7vw] font-bold w-fit">
|
<p class="text-[4.5vw] font-bold w-fit">
|
||||||
We Are<br />
|
We Are<br />
|
||||||
Hiring
|
Hiring
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:text-[1vw] text-[1.5vw] md:pt-[3%]">
|
<div class="text-[1vw] pt-[3%]">
|
||||||
<p>
|
<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>
|
||||||
<p>
|
<p>
|
||||||
Fill out this application form (due week 2 of each quarter if
|
Fill out this application form (due week 2 of each quarter if
|
||||||
|
@ -32,10 +33,10 @@ import robocup from "../../images/robocup.webp";
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex w-full justify-end items-end md:text-[3.5vw] text-[6vw] mt-[3%] group-hover:text-ieee-yellow duration-300"
|
class="flex w-full justify-end items-end text-[3.5vw] mt-[3%] group-hover:text-ieee-yellow duration-300"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
class="mr-[2%] w-fit py-[1%] px-[2%] md:text-[1.2vw] text-[2vw] border-[0.1vw] group-hover:text-ieee-yellow border-white rounded-full group-hover:border-ieee-yellow duration-300 font-light"
|
class="mr-[2%] w-fit py-[1%] px-[2%] text-[1.2vw] border-[0.1vw] group-hover:text-ieee-yellow border-white rounded-full group-hover:border-ieee-yellow duration-300 font-light"
|
||||||
>
|
>
|
||||||
Application form
|
Application form
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -1,25 +1,16 @@
|
||||||
---
|
---
|
||||||
import Title from "../core/Title.astro";
|
import Title from "../core/Title.astro";
|
||||||
import { Image } from "astro:assets";
|
import { Image } from "astro:assets";
|
||||||
import model from "../../images/robot.png";
|
import model from "../../images/model.webp";
|
||||||
import roboLogo from "../../images/roboLogo.png";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<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
|
<Title title="Robocup" />
|
||||||
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>
|
|
||||||
<Image
|
<Image
|
||||||
data-inview
|
|
||||||
src={model}
|
src={model}
|
||||||
alt="Robocup robot 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>
|
</div>
|
||||||
|
|
16
src/components/robocub/Subteam.astro
Normal file
16
src/components/robocub/Subteam.astro
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
---
|
||||||
|
import { FaGear, FaMicrochip, FaCode } from "react-icons/fa6";
|
||||||
|
import { LuBrainCircuit } from "react-icons/lu";
|
||||||
|
const { title, list } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="px-[10%] flex flex-col justify-center items-center w-[20vw] h-[38vh] bg-gradient-to-b from-ieee-blue-100/25 to-ieee-black backdrop-blur rounded-[2vw] border-white/40 border-[0.1vw]"
|
||||||
|
>
|
||||||
|
<p class="text-[1.5vw] mb-[10%] font-semibold pt-[10%]">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<ul class="text-[1vw] font-light">
|
||||||
|
{list.map((item: string) => <li>• {item}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
import Subtitle from "../core/Subtitle.astro";
|
import Subtitle from "../core/Subtitle.astro";
|
||||||
|
import Subteam from "./Subteam.astro";
|
||||||
import subteams from "../../data/subteams.json";
|
import subteams from "../../data/subteams.json";
|
||||||
import { IoIosArrowBack, IoIosArrowForward } from "react-icons/io";
|
import { IoIosArrowBack, IoIosArrowForward } from "react-icons/io";
|
||||||
import { FaGear, FaMicrochip, FaCode } from "react-icons/fa6";
|
import { FaGear, FaMicrochip, FaCode } from "react-icons/fa6";
|
||||||
|
@ -10,9 +11,9 @@ const duplicatedSubteams = [...subteams, ...subteams, ...subteams];
|
||||||
const centerIndex = Math.floor(subteams.length); // center in the middle
|
const centerIndex = Math.floor(subteams.length); // center in the middle
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="flex flex-col items-center md:my-[10%] my-[20%] relative overflow-x-clip">
|
<div class="flex flex-col items-center my-[10%] relative">
|
||||||
<Subtitle title="Subteams" />
|
<Subtitle title="Subteams" />
|
||||||
<div class="relative md:w-[75vw] w-[85vw] h-[25vw] mt-[8%] md:mt-[5%]">
|
<div class="relative w-[75vw] h-[50vh] mt-[3%]">
|
||||||
<div id="carousel" class="absolute w-full h-full flex justify-center">
|
<div id="carousel" class="absolute w-full h-full flex justify-center">
|
||||||
{
|
{
|
||||||
duplicatedSubteams.map((subteam, index) => {
|
duplicatedSubteams.map((subteam, index) => {
|
||||||
|
@ -26,7 +27,7 @@ const centerIndex = Math.floor(subteams.length); // center in the middle
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="carousel-item absolute transition-all duration-500 md:w-[22vw] w-[25vw]"
|
class="carousel-item absolute transition-all duration-500"
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(${distance * 12}vw) scale(${
|
transform: `translateX(${distance * 12}vw) scale(${
|
||||||
Math.abs(distance) === 0
|
Math.abs(distance) === 0
|
||||||
|
@ -42,16 +43,17 @@ const centerIndex = Math.floor(subteams.length); // center in the middle
|
||||||
: Math.abs(distance) === 1
|
: Math.abs(distance) === 1
|
||||||
? 20
|
? 20
|
||||||
: 10,
|
: 10,
|
||||||
|
width: "20vw",
|
||||||
}}
|
}}
|
||||||
data-index={index}
|
data-index={index}
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="relative w-full h-full backdrop-blur-md overflow-hidden rounded-[2vw]">
|
<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 w-[20vw] h-[38vh] 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%]">
|
<p class="text-[1.5vw] mb-[10%] font-semibold pt-[10%]">
|
||||||
{subteam.title}
|
{subteam.title}
|
||||||
</p>
|
</p>
|
||||||
<ul class="md:text-[1vw] text-[1.5vw] font-light">
|
<ul class="text-[1vw] font-light">
|
||||||
{subteam.list.map(
|
{subteam.list.map(
|
||||||
(item: string) => (
|
(item: string) => (
|
||||||
<li>• {item}</li>
|
<li>• {item}</li>
|
||||||
|
@ -60,7 +62,7 @@ const centerIndex = Math.floor(subteams.length); // center in the middle
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="-top-[10%] left-1/2 -translate-x-1/2 w-fit p-[5%] shadow-ieee-blue-300 md:text-[3.2vw] text-[4.5vw] bg-gradient-to-b from-ieee-blue-100 to-ieee-blue-300 rounded-full absolute">
|
<div class="-top-[10%] left-1/2 -translate-x-1/2 w-fit p-[5%] shadow-ieee-blue-300 text-[3.2vw] bg-gradient-to-b from-ieee-blue-100 to-ieee-blue-300 rounded-full absolute">
|
||||||
{subteam.title === "Mechanical" && (
|
{subteam.title === "Mechanical" && (
|
||||||
<FaGear />
|
<FaGear />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,29 +1,30 @@
|
||||||
---
|
---
|
||||||
import { LiaDotCircle } from "react-icons/lia";
|
import { LiaDotCircle } from "react-icons/lia";
|
||||||
import Link from "next/link";
|
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%]" >
|
<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 aspect-[1512/526] opacity-25">
|
||||||
|
|
||||||
<div class = "w-full flex justify-evenly absolute bottom-[20%] left-[4%]">
|
<div class = "w-full flex justify-evenly absolute bottom-[28%] ml-[5%]">
|
||||||
<div data-inview class = "in-view:animate-fade-right flex items-center md:text-[2.5vw] text-[4vw]">
|
|
||||||
<LiaDotCircle className=" mr-[1vw] md:text-[2.6vw]"/>
|
<div class = "flex items-center text-[2.5vw] font-extralight">
|
||||||
|
<LiaDotCircle className=" mr-[1vw] text-[2.5vw]"/>
|
||||||
<p>
|
<p>
|
||||||
Competition
|
Competition
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-inview class = "md:text-[1.6vw] text-[2.2vw] tracking-wider in-view:animate-flip-up max-w-[60%]">
|
<div class = "text-[1.5vw] font-extralight tracking-wider">
|
||||||
<p class = "w-[35vw] md:mt-[25vw] mt-[35vw]">
|
<p class = "w-[35vw] mb-[3%] mt-[40vh]">
|
||||||
{name}
|
{name}
|
||||||
</p>
|
</p>
|
||||||
<p class = "mb-[5%]">
|
<p class = "mb-[5%]">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</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">
|
<Link href = {link} target="_blank" className="text-[1vw] border-white/70 border-[0.1vw] py-[3%] px-[15%] rounded-[0.7vw] hover:text-ieee-yellow hover:border-ieee-yellow duration-300">
|
||||||
{linktext}
|
Link
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue