diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4864a83 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +Dockerfile +.dockerignore +node_modules +yarn-debug.log +README.md +.next +.git +.env*.local diff --git a/.env.local b/.env.local index e0eafe1..2c8f403 100644 --- a/.env.local +++ b/.env.local @@ -1,6 +1,9 @@ +EVENT_EDITION = mock-edition-id + WEBAPP_URL = http://localhost:3000 -CANNON_URL = https://cannon-staging-dev.sinfo.org/ +CANNON_URL = https://cannon-staging-dev.sinfo.org +NEXT_PUBLIC_CANNON_URL = https://cannon-staging-dev.sinfo.org NEXTAUTH_SECRET = ZlnjlEkgh4hRX41tGPc3glhUMKJd8+HxGWqVe+l7jtA= @@ -15,4 +18,4 @@ MICROSOFT_CLIENT_SECRET = G1.8Q~Xmy_ujVaWKSXBaxNADx9w02VjLV9T9RcA3 FENIX_URL = https://fenix.tecnico.ulisboa.pt FENIX_CLIENT_ID = 1977390058176863 -FENIX_CLIENT_SECRET = 2z4JU3SLLFqZTc2wpqjXIBcl6BCaGoREn+BzjyhZEHFxhQNAI+ypt/O5uuCTP2b+a7FBxuPTzNZXpdB4mv6cDA== \ No newline at end of file +FENIX_CLIENT_SECRET = 2z4JU3SLLFqZTc2wpqjXIBcl6BCaGoREn+BzjyhZEHFxhQNAI+ypt/O5uuCTP2b+a7FBxuPTzNZXpdB4mv6cDA== diff --git a/.github/workflows/build-production.yml b/.github/workflows/build-production.yml new file mode 100644 index 0000000..ee2e932 --- /dev/null +++ b/.github/workflows/build-production.yml @@ -0,0 +1,59 @@ +name: Production Build Workflow + +on: + push: + tags: + - "v*.*.*" + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set most recent tag + id: vars + run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} + + - uses: docker/setup-buildx-action@v3 + name: Set up Docker Buildx + + - uses: docker/login-action@v3 + name: Login to DockerHub + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + name: Build and Push WebApp Image + id: docker_build + with: + context: . + push: true + tags: orgsinfo/webapp:${{ step.vars.outputs.tag }} + file: Dockerfile.production + + - name: WebApp Image Digest + run: echo ${{ steps.docker_build.outputs.digest }} + + deploy: + needs: docker + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set most recent tag + id: vars + run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} + + - name: Deploy the app to the cluster + uses: nickgronow/kubectl@master + with: + config_data: ${{ secrets.KUBE_CONFIG_DATA }} + args: set image deployment/webapp-production webapp-prod-app=orgsinfo/webapp:${{ steps.vars.outputs.tag }} --namespace=production + + - name: Verify deployment + uses: nickgronow/kubectl@master + with: + config_data: ${{ secrets.KUBE_CONFIG_DATA }} + args: rollout status deployment/webapp-production --namespace=production diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml new file mode 100644 index 0000000..1f718c6 --- /dev/null +++ b/.github/workflows/build-staging.yml @@ -0,0 +1,45 @@ +name: Staging Build Workflow + +on: + push: + branches: + - "staging" + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + name: Set up Docker Buildx + + - uses: docker/login-action@v3 + name: Login to DockerHub + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + name: Build and Push WebApp Image + id: docker_build + with: + context: . + push: true + tags: orgsinfo/webapp:latest + file: Dockerfile.staging + + - name: WebApp Image Digest + run: echo ${{ steps.docker_build.outputs.digest }} + + deploy: + needs: docker + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Deploy the webapp to the cluster + uses: nickgronow/kubectl@master + with: + config_data: ${{ secrets.KUBE_CONFIG_DATA }} + args: delete pod --selector="app=webapp-staging-app" --namespace=staging diff --git a/.gitignore b/.gitignore index f392b6f..332ff87 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,10 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts +# env +.env*.local + # added /src/app/cannon /src/services/TokenService.ts -.vscode \ No newline at end of file +.vscode diff --git a/Dockerfile.production b/Dockerfile.production new file mode 100644 index 0000000..2459e13 --- /dev/null +++ b/Dockerfile.production @@ -0,0 +1,53 @@ +FROM node:20-alpine AS base + +# 1. Install dependencies only when needed +FROM base AS deps + +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn --frozen-lockfile + + +# 2. Build the application +FROM base AS builder + +ENV NEXT_PUBLIC_CANNON_URL="https://cannon.sinfo.org" + +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN yarn run build + + +# 3. Production image, copy all the files and run next +FROM base AS runner + +WORKDIR /app +ENV NODE_ENV=production + +# Disable Next.js telemetry during runtime +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup -g 1001 -S nodejs +RUN adduser -S nextjs -u 1001 + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["server.js"] + diff --git a/Dockerfile.staging b/Dockerfile.staging new file mode 100644 index 0000000..c529adc --- /dev/null +++ b/Dockerfile.staging @@ -0,0 +1,53 @@ +FROM node:20-alpine AS base + +# 1. Install dependencies only when needed +FROM base AS deps + +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn --frozen-lockfile + + +# 2. Build the application +FROM base AS builder + +ENV NEXT_PUBLIC_CANNON_URL="https://cannon-staging.sinfo.org" + +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN yarn run build + + +# 3. Production image, copy all the files and run next +FROM base AS runner + +WORKDIR /app +ENV NODE_ENV=production + +# Disable Next.js telemetry during runtime +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup -g 1001 -S nodejs +RUN adduser -S nextjs -u 1001 + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["server.js"] + diff --git a/next.config.js b/next.config.js index 201a6f4..6c3b2ad 100644 --- a/next.config.js +++ b/next.config.js @@ -1,8 +1,21 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - images: { - domains: ["static.sinfo.org", "sinfo.ams3.cdn.digitaloceanspaces.com", "sonaesierracms-v2.cdnpservers.net"] - } -} + output: "standalone", + images: { + remotePatterns: [ + { + protocol: "http", + hostname: "**", + }, + { + protocol: "https", + hostname: "**", + }, + ], + }, + experimental: { + instrumentationHook: true, + }, +}; -module.exports = nextConfig +module.exports = nextConfig; diff --git a/package.json b/package.json index b825816..f86c034 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,34 @@ "lint": "next lint" }, "dependencies": { + "@types/country-list": "^2.1.4", "@types/node": "20.5.0", "@types/react": "18.2.20", "@types/react-dom": "18.2.7", "autoprefixer": "10.4.15", + "country-list": "^2.3.0", "eslint": "8.47.0", - "eslint-config-next": "13.4.17", - "next": "13.4.17", - "next-auth": "^4.23.1", + "eslint-config-next": "^14.1.0", + "lucide-react": "^0.469.0", + "moment": "^2.30.1", + "msw": "^2.6.5", + "next": "^14.1.0", + "next-auth": "4.24.11", "postcss": "8.4.28", + "qr-scanner": "^1.4.2", "react": "18.2.0", + "react-confetti": "^6.2.2", "react-dom": "18.2.0", + "react-hook-form": "^7.54.2", + "react-image-file-resizer": "^0.4.8", "react-qr-code": "^2.0.12", - "tailwindcss": "3.3.3", + "react-social-icons": "^6.18.0", + "react-use": "^17.6.0", + "sharp": "^0.33.5", + "tailwindcss": "3.4.17", "typescript": "5.1.6" + }, + "devDependencies": { + "prettier": "^3.4.2" } } diff --git a/src/app/(authenticated)/companies/CompaniesList.tsx b/src/app/(authenticated)/companies/CompaniesList.tsx new file mode 100644 index 0000000..013acc5 --- /dev/null +++ b/src/app/(authenticated)/companies/CompaniesList.tsx @@ -0,0 +1,51 @@ +"use client"; + +import GridList from "@/components/GridList"; +import { CompanyTile } from "@/components/company"; +import { useState } from "react"; + +interface CompaniesListProps { + companies: Company[]; +} + +export default function CompaniesList({ companies }: CompaniesListProps) { + const [filteredCompanies, setFilteredCompanies] = + useState(companies); + let debounce: NodeJS.Timeout; + + function handleSearch(text: string) { + debounce && clearTimeout(debounce); + debounce = setTimeout(() => { + if (text === "") { + setFilteredCompanies(companies); + } else { + setFilteredCompanies( + companies.filter((company) => + company.name.toLowerCase().includes(text.toLowerCase()), + ), + ); + } + }, 300); + } + + return ( + <> +
+ { + handleSearch(e.target.value); + }} + /> +
+ + {filteredCompanies.length === 0 &&
No companies found
} + {filteredCompanies.map((c) => ( + + ))} +
+ + ); +} diff --git a/src/app/(authenticated)/companies/[id]/StandDetails.tsx b/src/app/(authenticated)/companies/[id]/StandDetails.tsx new file mode 100644 index 0000000..e4067d7 --- /dev/null +++ b/src/app/(authenticated)/companies/[id]/StandDetails.tsx @@ -0,0 +1,42 @@ +import GridList from "@/components/GridList"; +import { Armchair, Construction, Dock } from "lucide-react"; + +interface StandDetailsProps { + standDetails: StandDetails; +} + +export default function StandDetails({ standDetails }: StandDetailsProps) { + return ( + + {standDetails ? ( + <> +
+ 0 ? 2 : 1} /> + {standDetails.chairs} Chairs +
+
+ + {standDetails.table ? "Want table" : "No table"} +
+
+ + + {standDetails.lettering ? "Want lettering" : "No lettering"} + +
+ + ) : ( +
Stand details not found
+ )} +
+ ); +} diff --git a/src/app/(authenticated)/companies/[id]/connections/page.tsx b/src/app/(authenticated)/companies/[id]/connections/page.tsx new file mode 100644 index 0000000..a9adec7 --- /dev/null +++ b/src/app/(authenticated)/companies/[id]/connections/page.tsx @@ -0,0 +1,56 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; +import List from "@/components/List"; +import ConnectionTile from "@/components/user/ConnectionTile"; +import { CompanyService } from "@/services/CompanyService"; +import { UserService } from "@/services/UserService"; +import { getServerSession } from "next-auth"; + +interface CompanyConnectionsParams { + id: string; +} + +export default async function CompanyConnections({ + params, +}: { + params: CompanyConnectionsParams; +}) { + const { id: companyID } = params; + + const session = (await getServerSession(authOptions))!; + + const connections = await CompanyService.getConnections( + session.cannonToken, + companyID, + ); + if (!connections) { + return ; + } + + const connectionsByUser = connections.reduce( + (acc, c) => ({ ...acc, [c.from]: [...(acc[c.from] ?? []), c] }), + {} as Record, + ); + const users = ( + await Promise.all( + Object.keys(connectionsByUser).map((id) => + UserService.getUser(session.cannonToken, id), + ), + ) + ).sort((a, b) => a!.name.localeCompare(b!.name)); + + return ( +
+ {users.map( + (u) => + u && ( + + {connectionsByUser[u.id].map((c) => ( + + ))} + + ), + )} +
+ ); +} diff --git a/src/app/(authenticated)/companies/[id]/page.tsx b/src/app/(authenticated)/companies/[id]/page.tsx new file mode 100644 index 0000000..0fc8990 --- /dev/null +++ b/src/app/(authenticated)/companies/[id]/page.tsx @@ -0,0 +1,140 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import GridList from "@/components/GridList"; +import List from "@/components/List"; +import { CompanyService } from "@/services/CompanyService"; +import { UserService } from "@/services/UserService"; +import { isHereToday } from "@/utils/company"; +import { isCompany, isMember } from "@/utils/utils"; +import { getServerSession } from "next-auth"; +import Image from "next/image"; +import StandDetails from "./StandDetails"; +import { UserTile } from "@/components/user/UserTile"; +import { SessionTile } from "@/components/session"; +import EventDayButton from "@/components/EventDayButton"; +import { Scan } from "lucide-react"; +import Link from "next/link"; +import { SocialNetwork } from "@/components/SocialNetwork"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; +import ConnectionTile from "@/components/user/ConnectionTile"; + +interface CompanyParams { + id: string; +} + +const N_CONNECTIONS = 3; + +export default async function Company({ params }: { params: CompanyParams }) { + const { id: companyID } = params; + + const company = await CompanyService.getCompany(companyID); + + if (!company) { + return ; + } + + const companySessions = company.sessions?.sort((a, b) => + a.date.localeCompare(b.date) + ); + const companyMembers = company.members?.sort((a, b) => + a.name.localeCompare(b.name) + ); + const companyStands = company.stands?.sort((a, b) => + a.date.localeCompare(b.date) + ); + const hereToday = isHereToday(company); + + const session = (await getServerSession(authOptions))!; + const user: User | null = await UserService.getMe(session!.cannonToken); + + const companyConnections = await CompanyService.getConnections( + session.cannonToken, + companyID, + ); + + return ( +
+
+

{company.name}

+ {`${company.name} + {hereToday && ( + + Here Today + + )} + {company.site && } +
+ {/* Members section */} + {user && isMember(user.role) && ( +
+ + + Promote + +
+ )} + {/* Days at the event */} + {companyStands?.length ? ( + + {companyStands.map((s) => ( + + + + ))} + + ) : ( + <> + )} + {/* Company Sessions */} + {companySessions?.length ? ( + + {companySessions.map((s) => ( + + ))} + + ) : ( + <> + )} + {/* Company Members */} + {companyMembers?.length ? ( + + {companyMembers.map((u) => ( + + ))} + + ) : ( + <> + )} + {/* Stand Details */} + {user && isMember(user.role) && company.standDetails && ( + + )} + {/* Connections */} + {user && isCompany(user.role) && !!companyConnections?.length && ( + + {companyConnections.slice(0, N_CONNECTIONS).map((c) => ( + + ))} + + )} +
+ ); +} diff --git a/src/app/(authenticated)/companies/[id]/promote/CompanyPromoteScanner.tsx b/src/app/(authenticated)/companies/[id]/promote/CompanyPromoteScanner.tsx new file mode 100644 index 0000000..07b7bd9 --- /dev/null +++ b/src/app/(authenticated)/companies/[id]/promote/CompanyPromoteScanner.tsx @@ -0,0 +1,84 @@ +"use client"; + +import ListCard from "@/components/ListCard"; +import MessageCard from "@/components/MessageCard"; +import QRCodeScanner from "@/components/QRCodeScanner"; +import { UserTile } from "@/components/user/UserTile"; +import { UserService } from "@/services/UserService"; +import { getUserFromQRCode } from "@/utils/utils"; +import { ReactNode, useEffect, useState } from "react"; + +interface CompanyPromoteScannerProps { + cannonToken: string; + company: Company; +} + +export default function CompanyPromoteScanner({ + cannonToken, + company, +}: CompanyPromoteScannerProps) { + const [topCard, setTopCard] = useState(); + const [bottomCard, setBottomCard] = useState(); + const [statusCard, setStatusCard] = useState(); + let cardsTimeout: NodeJS.Timeout; + + async function handleQRCodeScanned(data: string) { + const scannedUser = getUserFromQRCode(data); + cardsTimeout && clearTimeout(cardsTimeout); + + if (scannedUser) { + setBottomCard(); + if ( + await UserService.promote(cannonToken, scannedUser.id, { + role: "company", + company: { company: company.id }, + }) + ) { + setStatusCard( + , + ); + } else { + setStatusCard( + , + ); + } + } else { + setBottomCard(); + } + + cardsTimeout = setTimeout(() => { + setBottomCard(null); + setStatusCard(null); + }, 10 * 1000); // 10 seconds + } + + useEffect(() => { + setBottomCard((card) => ( +
+ {statusCard} + {card} +
+ )); + }, [statusCard]); + + useEffect(() => { + setTopCard( + , + ); + }, [company]); + + return ( + + ); +} diff --git a/src/app/(authenticated)/companies/[id]/promote/page.tsx b/src/app/(authenticated)/companies/[id]/promote/page.tsx new file mode 100644 index 0000000..f845ef3 --- /dev/null +++ b/src/app/(authenticated)/companies/[id]/promote/page.tsx @@ -0,0 +1,34 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import { CompanyService } from "@/services/CompanyService"; +import { getServerSession } from "next-auth"; +import CompanyPromoteScanner from "./CompanyPromoteScanner"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; + +interface CompanyPromoteParams { + id: string; +} + +export default async function CompanyPromote({ + params, +}: { + params: CompanyPromoteParams; +}) { + const { id: companyID } = params; + + const company = await CompanyService.getCompany(companyID); + + if (!company) { + return ; + } + + const session = await getServerSession(authOptions); + + return ( +
+ +
+ ); +} diff --git a/src/app/(authenticated)/companies/page.tsx b/src/app/(authenticated)/companies/page.tsx new file mode 100644 index 0000000..5d2ef2c --- /dev/null +++ b/src/app/(authenticated)/companies/page.tsx @@ -0,0 +1,20 @@ +import { CompanyService } from "@/services/CompanyService"; +import CompaniesList from "./CompaniesList"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; + +export default async function Companies() { + let companies = await CompanyService.getCompanies(); + + if (!companies) { + return ; + } + + // Sort companies by name + companies = companies.sort((a, b) => a.name.localeCompare(b.name)); + + return ( +
+ +
+ ); +} diff --git a/src/app/(authenticated)/home/page.tsx b/src/app/(authenticated)/home/page.tsx new file mode 100644 index 0000000..6cb2252 --- /dev/null +++ b/src/app/(authenticated)/home/page.tsx @@ -0,0 +1,114 @@ +import { SessionService } from "@/services/SessionService"; +import { CompanyService } from "@/services/CompanyService"; +import { SpeakerService } from "@/services/SpeakerService"; +import { CompanyTile } from "@/components/company"; +import { SpeakerTile } from "@/components/speaker"; +import { SessionTile } from "@/components/session"; +import ListCard from "@/components/ListCard"; +import List from "@/components/List"; +import GridList from "@/components/GridList"; +import ProgressBar from "@/components/ProgressBar"; +import Link from "next/link"; +import { getServerSession } from "next-auth"; +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import { UserService } from "@/services/UserService"; +import { isCompany, isToday } from "@/utils/utils"; +import UserSignOut from "@/components/UserSignOut"; +import { SPIN_WHEEL_MAXIMUM } from "@/constants"; + +const N_SESSION_TILES = 3; +const N_COMPANY_TILES = 6; +const N_SPEAKER_TILES = 6; + +export default async function Home() { + const session = (await getServerSession(authOptions))!; + const user = await UserService.getMe(session.cannonToken); + if (!user) return ; + + const eventSessions = await SessionService.getSessions(); + const companies = await CompanyService.getCompanies(); + const speakers = await SpeakerService.getSpeakers(); + + // choose upcoming sessions + const upcomingSessions: SINFOSession[] = eventSessions + ? eventSessions + .filter((s) => new Date(s.date) >= new Date()) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + .slice(0, N_SESSION_TILES) + : []; + + // choose random companies + const highlightedCompanies: Company[] = companies + ? companies.sort(() => Math.random() - 0.5).slice(0, N_COMPANY_TILES) + : []; + + // choose random speakers + const highlightedSpeakers: Speaker[] = speakers + ? speakers.sort(() => Math.random() - 0.5).slice(0, N_SPEAKER_TILES) + : []; + + const spinWheelData = user.signatures?.find( + (s) => s.edition === process.env.EVENT_EDITION && isToday(s.day) + ); + const showSpinWheelSection = + !isCompany(user.role) && !spinWheelData?.redeemed; + + return ( +
+ {/* Spin the Wheel Section */} + {showSpinWheelSection && ( + +
+ Visit {SPIN_WHEEL_MAXIMUM} companies for a chance to spin the wheel + and win exciting prizes!  + + See more + +
+
+ )} + + {/* Upcoming Sessions */} + + {upcomingSessions.length > 0 ? ( + upcomingSessions.map((s) => ) + ) : ( + + )} + + + {/* Highlighted Companies */} + + {highlightedCompanies.length > 0 ? ( + highlightedCompanies.map((c) => ( + + )) + ) : ( + + )} + + + {/* Highlighted Speakers */} + + {highlightedSpeakers.length > 0 ? ( + highlightedSpeakers.map((s) => ) + ) : ( + + )} + +
+ ); +} diff --git a/src/app/(authenticated)/layout.tsx b/src/app/(authenticated)/layout.tsx new file mode 100644 index 0000000..e6d28c9 --- /dev/null +++ b/src/app/(authenticated)/layout.tsx @@ -0,0 +1,22 @@ +import { getServerSession } from "next-auth"; +import authOptions from "../api/auth/[...nextauth]/authOptions"; +import { redirect } from "next/navigation"; +import Toolbar from "@/components/Toolbar"; +import BottomNavbar from "@/components/BottomNavbar"; + +export default async function AuthenticatedLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getServerSession(authOptions); + if (!session) redirect("/login"); + + return ( +
+ +
{children}
+ +
+ ); +} diff --git a/src/app/(authenticated)/loading.tsx b/src/app/(authenticated)/loading.tsx new file mode 100644 index 0000000..1b1ab56 --- /dev/null +++ b/src/app/(authenticated)/loading.tsx @@ -0,0 +1,5 @@ +import BlankPageWithMessage from "@/components/BlankPageMessage"; + +export default function Loading() { + ; +} diff --git a/src/app/(authenticated)/prizes/DailyPrizesTable.tsx b/src/app/(authenticated)/prizes/DailyPrizesTable.tsx new file mode 100644 index 0000000..32fb9bc --- /dev/null +++ b/src/app/(authenticated)/prizes/DailyPrizesTable.tsx @@ -0,0 +1,64 @@ +"use client"; + +import EventDayButton from "@/components/EventDayButton"; +import GridList from "@/components/GridList"; +import List from "@/components/List"; +import { PrizeTile } from "@/components/prize"; +import { getEventFullDate } from "@/utils/utils"; +import { useEffect, useMemo, useState } from "react"; + +interface DailyPrizesTable { + prizes: Prize[]; +} + +export default function DailyPrizesTable({ prizes }: DailyPrizesTable) { + const [showingDay, setShowingDay] = useState(null); + + const prizesByDay = useMemo(() => { + const sortedPrizes = prizes.sort((a, b) => a.name.localeCompare(b.name)); + return sortedPrizes.reduce( + (acc, sp) => { + const days = sp.days!.map((d) => d.split("T")[0]); + return days.reduce( + (a, d) => ({ ...a, [d]: [...(a[d] || []), sp] }), + acc, + ); + }, + {} as Record, + ); + }, [prizes]); + + const sortedDays = useMemo( + () => Object.keys(prizesByDay).sort(), + [prizesByDay], + ); + + useEffect(() => { + const today = new Date().toISOString().split("T")[0]; + setShowingDay(sortedDays.find((d) => d === today) || sortedDays[0]); + }, [sortedDays]); + + return ( + <> + + {sortedDays.map((d) => ( + setShowingDay(d)} + selected={showingDay === d} + /> + ))} + + {sortedDays + .filter((d) => !showingDay || d === showingDay) + .map((d) => ( + + {prizesByDay[d].map((p) => ( + + ))} + + ))} + + ); +} diff --git a/src/app/(authenticated)/prizes/PrizeSessions.tsx b/src/app/(authenticated)/prizes/PrizeSessions.tsx new file mode 100644 index 0000000..af1ebae --- /dev/null +++ b/src/app/(authenticated)/prizes/PrizeSessions.tsx @@ -0,0 +1,37 @@ +"use client"; + +import List from "@/components/List"; +import { SessionTile } from "@/components/session"; +import { useState } from "react"; + +interface PrizeSessionsProps { + sessions: SINFOSession[]; + compressedSize?: number; +} + +export default function PrizeSessions({ + sessions, + compressedSize = 3, +}: PrizeSessionsProps) { + const [expandList, setExpandList] = useState(false); + + return ( +
+ + To win the prize above, go to a session below: + + {sessions.slice(0, expandList ? undefined : compressedSize).map((s) => ( + + ))} + {sessions.length > compressedSize && ( + + )} +
+ ); +} diff --git a/src/app/(authenticated)/prizes/page.tsx b/src/app/(authenticated)/prizes/page.tsx new file mode 100644 index 0000000..ba476e3 --- /dev/null +++ b/src/app/(authenticated)/prizes/page.tsx @@ -0,0 +1,96 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import List from "@/components/List"; +import { PrizeTile } from "@/components/prize"; +import { AchievementService } from "@/services/AchievementService"; +import { PrizeService } from "@/services/PrizeService"; +import { SessionService } from "@/services/SessionService"; +import { UserService } from "@/services/UserService"; +import { isCompany, isMember } from "@/utils/utils"; +import { getServerSession } from "next-auth"; +import PrizeSessions from "./PrizeSessions"; +import MessageCard from "@/components/MessageCard"; +import DailyPrizesTable from "./DailyPrizesTable"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; + +export default async function Prizes() { + const prizes = await PrizeService.getPrizes(); + + if (!prizes) { + return ; + } + + const session = await getServerSession(authOptions); + const user = await UserService.getMe(session!.cannonToken); + + // Kinds of prizes + const cvPrize = prizes.find((p) => p.cv); + const dailyPrizes = prizes.filter((p) => p.days?.length); + const sessionPrizes = prizes.filter((p) => p.sessions?.length); + + const sinfoSessions = (await SessionService.getSessions()) || []; + // TODO: Sort sessions and put at first the next ones + + async function getCVPrizeParticipants() { + const achievements = await AchievementService.getAchievements(); + const cvPrizeUsers = achievements?.find((a) => a.kind === "cv")?.users; + + return cvPrizeUsers?.map((u) => ({ userId: u })) || []; + } + + return ( +
+
+

Prizes

+ {user && isCompany(user.role) && ( + + )} +
+ + {/* CV */} + {cvPrize && ( + + + + )} + + {/* Daily Prizes */} +
+
+

Daily prizes

+ + Visit SINFO and get the chance to win + +
+ +
+ + {/* Sessions Prizes */} + + {sessionPrizes.map((sessionPrize) => ( +
+ + + sessionPrize.sessions?.includes(s.id) + )} + /> +
+ ))} +
+
+ ); +} diff --git a/src/app/(authenticated)/profile/achievements/page.tsx b/src/app/(authenticated)/profile/achievements/page.tsx new file mode 100644 index 0000000..540b43d --- /dev/null +++ b/src/app/(authenticated)/profile/achievements/page.tsx @@ -0,0 +1,56 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; +import GridList from "@/components/GridList"; +import AchievementTile from "@/components/user/AchievementTile"; +import { AchievementService } from "@/services/AchievementService"; +import { UserService } from "@/services/UserService"; +import { formatAchievementKind } from "@/utils/utils"; +import { getServerSession } from "next-auth"; + +export default async function Achievements() { + const achievements = await AchievementService.getAchievements(); + + if (!achievements) { + return ; + } + + const session = await getServerSession(authOptions); + const user: User | null = await UserService.getMe(session!.cannonToken); + + const userAchievements = + user && achievements?.filter((a) => a.users?.includes(user.id)); + + const achievementsByKind = achievements.reduce( + (acc, a) => { + const kindAchievements = [...(acc[a.kind] || []), a]; + return { ...acc, [a.kind]: kindAchievements }; + }, + {} as Record + ); + const sortedKinds = Object.keys( + achievementsByKind + ).sort() as AchievementKind[]; + + return ( +
+
+

Achievements

+ + Total points:{" "} + {userAchievements?.reduce((acc, a) => acc + a.value, 0) || 0} + +
+ {sortedKinds.map((k) => ( + + {achievementsByKind[k].slice(0, 30).map((a) => ( + + ))} + + ))} +
+ ); +} diff --git a/src/app/(authenticated)/profile/connections/page.tsx b/src/app/(authenticated)/profile/connections/page.tsx new file mode 100644 index 0000000..0c05e57 --- /dev/null +++ b/src/app/(authenticated)/profile/connections/page.tsx @@ -0,0 +1,33 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; +import List from "@/components/List"; +import ConnectionTile from "@/components/user/ConnectionTile"; +import { UserService } from "@/services/UserService"; +import { getServerSession } from "next-auth"; + +export default async function Connections() { + const session = (await getServerSession(authOptions))!; + + const connections = await UserService.getConnections(session.cannonToken); + if (!connections) { + return ; + } + + return ( +
+ + {connections.length > 0 ? ( + connections.map((c) => ) + ) : ( +

+ 1. Scan the QR code of another user.
+ 2. Visit their profile page. +
+ 3. Click on the "Connect" button. +
+

+ )} +
+
+ ); +} diff --git a/src/app/(authenticated)/profile/edit/EditProfileForm.tsx b/src/app/(authenticated)/profile/edit/EditProfileForm.tsx new file mode 100644 index 0000000..9edf6cd --- /dev/null +++ b/src/app/(authenticated)/profile/edit/EditProfileForm.tsx @@ -0,0 +1,601 @@ +"use client"; + +import { useForm, useFieldArray } from "react-hook-form"; +import Image from "next/image"; +import { Plus, Save, Upload, X } from "lucide-react"; +import CountryList from "country-list"; +import { useEffect, useState } from "react"; +import Resizer from "react-image-file-resizer"; + +interface EditProfileFormProps { + user: User; + updateUser(user: User): Promise; +} + +type FormData = { + name: string; + title: string; + imgFile: FileList; + nationality: string; + contacts: { + linkedin: string; + email: string; + github: string; + }; + academicInformation: { + school: string; + degree: string; + field: string; + start: string; + end: string; + }[]; + skill: string; + skills: String[]; + interest: string; + interestedIn: String[]; + lookingFor: { + internship: boolean; + fullTime: boolean; + partTime: boolean; + }; +}; + +export default function EditProfileForm({ + user, + updateUser, +}: EditProfileFormProps) { + const [profilePicturePreview, setProfilePicturePreview] = useState( + user.img, + ); + const { + control, + formState: { errors }, + handleSubmit, + register, + setValue, + getValues, + watch, + } = useForm({ + defaultValues: { + name: user.name, + title: user.title, + nationality: user.nationality, + contacts: user.contacts, + skills: user.skills, + interestedIn: user.interestedIn, + lookingFor: { + internship: user.lookingFor?.indexOf("Internship") !== -1, + fullTime: user.lookingFor?.indexOf("Full-time") !== -1, + partTime: user.lookingFor?.indexOf("Part-time") !== -1, + }, + academicInformation: user.academicInformation?.map((info) => ({ + ...info, + start: info.start?.split("T")[0].slice(0, 7), + end: info.end?.split("T")[0].slice(0, 7), + })), + }, + }); + const { append: appendSkill, remove: removeSkill } = useFieldArray({ + name: "skills", + control, + rules: { + maxLength: 10, + }, + }); + const skills = watch("skills"); + const { append: appendInterest, remove: removeInterest } = useFieldArray({ + name: "interestedIn", + control, + rules: { + maxLength: 10, + }, + }); + const interestedIn = watch("interestedIn"); + const { + fields: academicInformationFields, + append: appendAcademicInformation, + remove: removeAcademicInformation, + } = useFieldArray({ + name: "academicInformation", + control, + rules: { + maxLength: 3, + }, + }); + const imgFile: FileList | undefined = watch("imgFile"); + + const onSubmit = handleSubmit(async (formData) => { + const lookingFor = []; + if (formData.lookingFor.internship) lookingFor.push("Internship"); + if (formData.lookingFor.partTime) lookingFor.push("Part-time"); + if (formData.lookingFor.fullTime) lookingFor.push("Full-time"); + + const imgFile = formData.imgFile.item(0); + + async function resizeImageAndConvert(file: File): Promise { + return new Promise((resolve) => + Resizer.imageFileResizer( + file, + 300, // Max width + 300, // Max height + "JPEG", // Output compression + 100, // Quality + 0, // Rotation + (uri) => resolve(uri as string), + "base64", // Output format + ), + ); + } + + await updateUser({ + id: user.id, + role: user.role, + img: (imgFile && (await resizeImageAndConvert(imgFile))) || user.img, + name: formData.name, + title: formData.title, + nationality: formData.nationality, + contacts: formData.contacts, + academicInformation: formData.academicInformation.map((info) => ({ + ...info, + start: new Date(info.start).toISOString(), + end: new Date(info.end).toISOString(), + })), + skills: formData.skills.map((s) => s.toString()), + interestedIn: formData.interestedIn.map((s) => s.toString()), + lookingFor, + }); + }); + + useEffect(() => { + const file = imgFile?.item(0); + if (file) { + const urlImage = URL.createObjectURL(file); + setProfilePicturePreview(urlImage); + } + }, [imgFile]); + + return ( +
+ {/* Image */} +
+ Profile picture + + + {errors.imgFile?.message} +
+ +

Profile information

+ + {/* Name */} +
+ + + {errors.name?.message} +
+ + {/* Title */} +
+ + + {errors.title?.message} +
+ + {/* Nationality */} +
+ + + + {errors.nationality?.message} + +
+ +

Contacts

+ + {/* Email */} +
+ + +
+ + {/* Linkedin */} +
+ + + + {errors.contacts?.linkedin?.message} + + + Don't know how to find it?  + + Click here + + +
+ + {/* GitHub */} +
+ + + + {errors.contacts?.github?.message} + +
+ + {/* Academic Information */} +

Academic information

+
+ {academicInformationFields.map((_, idx) => ( +
+ {/* School */} +
+
+ +
+ + + + {errors.academicInformation?.[idx]?.school?.message} + +
+ {/* Field of study */} +
+ + + + {errors.academicInformation?.[idx]?.field?.message} + +
+ {/* Degree */} +
+ + + + {errors.academicInformation?.[idx]?.degree?.message} + +
+ {/* Dates */} +
+
+ + + + {errors.academicInformation?.[idx]?.start?.message} + +
+
+ + + getValues(`academicInformation.${idx}.start`) < v, + })} + /> + + {errors.academicInformation?.[idx]?.end?.message} + +
+
+
+ ))} + +
+ + {/* Skills */} +

Skills

+
+
+ + +
+ {errors.skill?.message} +
+ {skills.map((skill, idx) => ( + + ))} +
+
+ + {/* Interests */} +

Interested in

+
+
+ + +
+
+ {interestedIn.map((interest, idx) => ( + + ))} +
+
+ + {/* Looking for */} +

Looking for

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ ); +} diff --git a/src/app/(authenticated)/profile/edit/page.tsx b/src/app/(authenticated)/profile/edit/page.tsx new file mode 100644 index 0000000..35dd951 --- /dev/null +++ b/src/app/(authenticated)/profile/edit/page.tsx @@ -0,0 +1,29 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import { UserService } from "@/services/UserService"; +import { getServerSession } from "next-auth"; +import EditProfileForm from "./EditProfileForm"; +import { redirect } from "next/navigation"; +import UserSignOut from "@/components/UserSignOut"; + +export default async function EditProfile() { + const session = (await getServerSession(authOptions))!; + const user = await UserService.getMe(session.cannonToken); + if (!user) return ; + + async function updateUser(newUser: User) { + "use server"; + if (await UserService.updateMe(session.cannonToken, newUser)) { + redirect("/profile"); + } + } + + return ( +
+
+

Edit profile

+
+ + +
+ ); +} diff --git a/src/app/(authenticated)/profile/page.tsx b/src/app/(authenticated)/profile/page.tsx new file mode 100644 index 0000000..c5d5e8b --- /dev/null +++ b/src/app/(authenticated)/profile/page.tsx @@ -0,0 +1,95 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import GridList from "@/components/GridList"; +import List from "@/components/List"; +import ListCard from "@/components/ListCard"; +import AchievementTile from "@/components/user/AchievementTile"; +import ConnectionTile from "@/components/user/ConnectionTile"; +import CurriculumVitae from "@/components/user/CurriculumVitae"; +import ProfileHeader from "@/components/user/ProfileHeader"; +import ProfileInformations from "@/components/user/ProfileInformations"; +import UserSignOut from "@/components/UserSignOut"; +import { AchievementService } from "@/services/AchievementService"; +import { UserService } from "@/services/UserService"; +import { isCompany } from "@/utils/utils"; +import { Award, UserPen } from "lucide-react"; +import { getServerSession } from "next-auth"; +import Link from "next/link"; + +const N_ACHIEVEMENTS = 5; +const N_CONNECTIONS = 3; + +export default async function Profile() { + const session = (await getServerSession(authOptions))!; + const user = await UserService.getMe(session.cannonToken); + if (!user) return ; + + const achievements = await AchievementService.getAchievements(); + const userAchievements = achievements?.filter((a) => + a.users?.includes(user.id), + ); + + const userConnections = await UserService.getConnections(session.cannonToken); + + return ( +
+ +
+ + + Edit profile + +
+ + {/* CV */} + {!isCompany(user.role) && ( + + + + )} + + {/* User informations */} + + + {/* Achievements */} + acc + a.value, 0) || 0}`} + link="/profile/achievements" + linkText="See all" + > + {userAchievements?.length ? ( + userAchievements + ?.slice(0, N_ACHIEVEMENTS) + .map((a) => ) + ) : ( + + )} + + + {/* Connections */} + {!!userConnections?.length && ( + + {userConnections.slice(0, N_CONNECTIONS).map((c) => ( + + ))} + + )} +
+ ); +} diff --git a/src/app/(authenticated)/qr/page.tsx b/src/app/(authenticated)/qr/page.tsx new file mode 100644 index 0000000..af34192 --- /dev/null +++ b/src/app/(authenticated)/qr/page.tsx @@ -0,0 +1,96 @@ +import QRCode from "react-qr-code"; +import Image from "next/image"; +import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth/next"; +import { revalidateTag } from "next/cache"; +import UserSignOut from "@/components/UserSignOut"; +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import { UserService } from "@/services/UserService"; +import { CompanyService } from "@/services/CompanyService"; +import { convertToAppRole, isCompany } from "@/utils/utils"; +import Link from "next/link"; +import { ScanQrCode } from "lucide-react"; + +export default async function QR() { + const session = await getServerSession(authOptions); + + const user: User | null = await UserService.getMe(session?.cannonToken ?? ""); + if (!user) return ; + + const userQRCode: string | null = await UserService.getQRCode( + session!.cannonToken + ); + if (!userQRCode) return
Unable to get QR-Code
; + + let company: Company | null = null; + if (isCompany(user.role)) { + // assumes that cannon api only provides the company associated with the current edition + if (user.company?.length) { + company = await CompanyService.getCompany(user.company[0].company); + } else { + await demoteMe(session!.cannonToken); + } + } + + // this is undesirable as hex codes are hard to maintain, but the border color sometimes + // doesn't work if you use tailwind colors like "pink-light" instead + const borderColor = (() => { + switch (convertToAppRole(user.role)) { + case "Member": + case "Admin": + return "#A73939"; // SINFO Secondary + case "Company": + return "#DB836E"; // SINFO Tertiary + case "Attendee": + default: + return "#323363"; // SINFO Primary + } + })(); + + return ( +
+
+
+ +
+
+

{user.name}

+

+ {convertToAppRole(user.role)} +

+
+ {company && ( + + {`${company.name} + + )} + + + Scan + +
+
+ ); +} + +const demoteMe = async (cannonToken: string) => { + const success = await UserService.demoteMe(cannonToken); + if (success) { + revalidateTag("modified-me"); + redirect("/"); + } +}; diff --git a/src/app/(authenticated)/qr/scan/QRScanner.tsx b/src/app/(authenticated)/qr/scan/QRScanner.tsx new file mode 100644 index 0000000..bb5f591 --- /dev/null +++ b/src/app/(authenticated)/qr/scan/QRScanner.tsx @@ -0,0 +1,101 @@ +"use client"; + +import MessageCard from "@/components/MessageCard"; +import QRCodeScanner from "@/components/QRCodeScanner"; +import { UserTile } from "@/components/user/UserTile"; +import { AchievementService } from "@/services/AchievementService"; +import { CompanyService } from "@/services/CompanyService"; +import { + getAchievementFromQRCode, + getUserFromQRCode, + isCompany, +} from "@/utils/utils"; +import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; + +interface QRScannerProps { + user: User; + cannonToken: string; +} + +export default function QRScanner({ user, cannonToken }: QRScannerProps) { + const [bottomCard, setBottomCard] = useState(); + const [statusCard, setStatusCard] = useState(); + const cardsTimeout = useRef(); + + const handleQRCodeScanned = useCallback( + async (data: string) => { + cardsTimeout.current && clearTimeout(cardsTimeout.current); + + const scannedUser = getUserFromQRCode(data); + const scannedAchievement = getAchievementFromQRCode(data); + + if (scannedUser) { + setBottomCard(); + if (isCompany(user.role) && user.company?.length) { + const signedUser = await CompanyService.sign( + cannonToken, + user.company[0].company, + scannedUser.id, + ); + if (signedUser) { + setStatusCard( + , + ); + } else { + setStatusCard( + , + ); + } + } + } else if (scannedAchievement) { + const redeemedAchievement = await AchievementService.redeemSecretAchievement( + cannonToken, + scannedAchievement + ) + + if (redeemedAchievement) { + setBottomCard(, + ); + } else { + setBottomCard(, + ); + } + + + } else { + setBottomCard(); + } + + cardsTimeout.current = setTimeout(() => { + setBottomCard(null); + setStatusCard(null); + }, 10 * 1000); // 10 seconds + }, + [cannonToken, user.role, user.company], + ); + + useEffect(() => { + setBottomCard((card) => ( +
+ {statusCard} + {card} +
+ )); + }, [statusCard]); + + return ( + + ); +} diff --git a/src/app/(authenticated)/qr/scan/page.tsx b/src/app/(authenticated)/qr/scan/page.tsx new file mode 100644 index 0000000..b45c333 --- /dev/null +++ b/src/app/(authenticated)/qr/scan/page.tsx @@ -0,0 +1,18 @@ +import { getServerSession } from "next-auth"; +import QRScanner from "./QRScanner"; +import UserSignOut from "@/components/UserSignOut"; +import { UserService } from "@/services/UserService"; +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; + +export default async function ScanQRCode() { + const session = await getServerSession(authOptions); + + const user: User | null = await UserService.getMe(session?.cannonToken ?? ""); + if (!user) return ; + + return ( +
+ +
+ ); +} diff --git a/src/app/(authenticated)/schedule/ScheduleTable.tsx b/src/app/(authenticated)/schedule/ScheduleTable.tsx new file mode 100644 index 0000000..3e6849f --- /dev/null +++ b/src/app/(authenticated)/schedule/ScheduleTable.tsx @@ -0,0 +1,115 @@ +"use client"; + +import EventDayButton from "@/components/EventDayButton"; +import GridList from "@/components/GridList"; +import List from "@/components/List"; +import { SessionTile } from "@/components/session"; +import { getEventFullDate } from "@/utils/utils"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +function getPageHeading(kind: string | null) { + switch (kind) { + case "keynote": + return "Keynotes"; + case "presentation": + return "Presentations"; + case "workshop": + return "Workshops"; + default: + return "Schedule"; + } +} + +interface ScheduleTableProps { + sessions: SINFOSession[]; +} + +export default function ScheduleTable({ sessions }: ScheduleTableProps) { + const searchParams = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); + const [showingDay, setShowingDay] = useState(null); + + const dayParam = searchParams.get("day"); + const kindParam = searchParams.get("kind"); + const placeParam = searchParams.get("place"); + + const sessionsByDay = useMemo(() => { + const sessionsCleaned = sessions + .filter( + (s) => + (!kindParam || s.kind.toLowerCase() === kindParam) && + (!placeParam || s.place.toLowerCase() === placeParam), + ) + .sort((a, b) => a.date.localeCompare(b.date)); + + return sessionsCleaned.reduce( + (acc, s) => { + const day = s.date.split("T")[0]; + const daySessions = [...(acc[day] || []), s]; + return { ...acc, [day]: daySessions }; + }, + {} as Record, + ); + }, [sessions, kindParam, placeParam]); + + const sortedDays = useMemo( + () => Object.keys(sessionsByDay).sort(), + [sessionsByDay], + ); + + useEffect(() => { + const today = new Date().toISOString().split("T")[0]; + const searchParamDay = searchParams.get("day"); + const day = searchParamDay === "today" ? today : searchParamDay; + setShowingDay(sortedDays.find((d) => day === d) || null); + }, [sortedDays, searchParams]); + + const updateSearchParam = (newDay: string) => { + const params = new URLSearchParams(searchParams.toString()); + + if (newDay === dayParam) { + params.delete("day"); + } else { + params.set("day", newDay); + } + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }; + + useEffect(() => { + if (dayParam && sortedDays.includes(dayParam)) { + setShowingDay(dayParam); + } + }, [dayParam, sortedDays]); + + return ( + <> +
+

{getPageHeading(kindParam)}

+

+ Checkout all the available sessions. +

+
+ + {sortedDays.map((d) => ( + updateSearchParam(d)} + selected={showingDay === d} + /> + ))} + + {sortedDays + .filter((d) => !showingDay || d === showingDay) + .map((d) => ( + + {sessionsByDay[d].map((s) => ( + + ))} + + ))} + + ); +} diff --git a/src/app/(authenticated)/schedule/page.tsx b/src/app/(authenticated)/schedule/page.tsx new file mode 100644 index 0000000..5a61d7e --- /dev/null +++ b/src/app/(authenticated)/schedule/page.tsx @@ -0,0 +1,17 @@ +import { SessionService } from "@/services/SessionService"; +import ScheduleTable from "./ScheduleTable"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; + +export default async function Schedule() { + const sessions = await SessionService.getSessions(); + + if (!sessions) { + return ; + } + + return ( +
+ +
+ ); +} diff --git a/src/app/(authenticated)/sessions/[id]/check-in/SessionCheckInScanner.tsx b/src/app/(authenticated)/sessions/[id]/check-in/SessionCheckInScanner.tsx new file mode 100644 index 0000000..5900da6 --- /dev/null +++ b/src/app/(authenticated)/sessions/[id]/check-in/SessionCheckInScanner.tsx @@ -0,0 +1,184 @@ +"use client"; + +import ListCard from "@/components/ListCard"; +import MessageCard from "@/components/MessageCard"; +import QRCodeScanner from "@/components/QRCodeScanner"; +import { SessionTile } from "@/components/session"; +import { SessionService } from "@/services/SessionService"; +import { getUserFromQRCode } from "@/utils/utils"; +import { Ghost, UserMinus, UserPlus, Users } from "lucide-react"; +import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; + +interface SessionCheckInScannerProps { + cannonToken: string; + sinfoSession: SINFOSession; +} + +export default function SessionCheckInScanner({ + cannonToken, + sinfoSession, +}: SessionCheckInScannerProps) { + const [status, setStatus] = useState({ + participants: [] as string[], + unregisteredParticipants: 0, + }); + const [topCard, setTopCard] = useState(); + const [bottomCard, setBottomCard] = useState(); + const [statusCard, setStatusCard] = useState(); + const [ + unregisteredUsersSubmittedCounter, + setUnregisteredUsersSubmittedCounter, + ] = useState(0); + const cardsTimeout = useRef(); + const updateSessionStatusTimeout = useRef(); + + const sessionUpdate = useCallback( + async (data?: { users?: string[]; unregisteredUsers?: number }) => { + const sessionStatus = await SessionService.checkInUser( + cannonToken, + sinfoSession.id, + data || {}, + ); + if (sessionStatus) { + updateSessionStatusTimeout.current && + clearTimeout(updateSessionStatusTimeout.current); + updateSessionStatusTimeout.current = setTimeout( + () => sessionUpdate(), + 5 * 1000, + ); // Update every 5 seconds + setStatus({ + participants: sessionStatus.participants, + unregisteredParticipants: sessionStatus.unregisteredParticipants, + }); + } + return sessionStatus; + }, + [cannonToken, sinfoSession.id], + ); + + const handleQRCodeScanned = useCallback( + async (data: string) => { + const scannedUser = getUserFromQRCode(data); + cardsTimeout.current && clearTimeout(cardsTimeout.current); + + if (scannedUser) { + setBottomCard( + , + ); + const sessionStatus = await sessionUpdate({ users: [scannedUser.id] }); + if (!sessionStatus) + setStatusCard( + , + ); + else { + if (sessionStatus.status === "already") + setStatusCard( + , + ); + else if (sessionStatus.status === "success") + setStatusCard( + , + ); + else + setStatusCard( + , + ); + } + } else { + setBottomCard(); + } + + cardsTimeout.current = setTimeout(() => { + setBottomCard(null); + setStatusCard(null); + }, 10 * 1000); // 10 seconds + }, + [sessionUpdate], + ); + + const handleUnregisteredUser = useCallback( + async (count: number) => { + const sessionStatus = await sessionUpdate({ unregisteredUsers: count }); + if (!sessionStatus) { + setBottomCard( + , + ); + } else { + setBottomCard( + 0 + ? `Added ${count} unregistered user` + : `Removed ${count} unregistered user` + } + />, + ); + setUnregisteredUsersSubmittedCounter((c) => c + count); + } + }, + [sessionUpdate], + ); + + useEffect(() => { + setBottomCard((card) => ( +
+ {statusCard} + {card} +
+ )); + }, [statusCard]); + + useEffect(() => { + setTopCard( +
+ +
+ +
+ + {status.participants?.length ?? 0} + / + + {status.unregisteredParticipants} +
+ +
+
, + ); + }, [ + status, + sinfoSession, + handleUnregisteredUser, + unregisteredUsersSubmittedCounter, + ]); + + useEffect(() => { + sessionUpdate(); + }, [sessionUpdate]); + + return ( + + ); +} diff --git a/src/app/(authenticated)/sessions/[id]/check-in/page.tsx b/src/app/(authenticated)/sessions/[id]/check-in/page.tsx new file mode 100644 index 0000000..aee013d --- /dev/null +++ b/src/app/(authenticated)/sessions/[id]/check-in/page.tsx @@ -0,0 +1,34 @@ +import { SessionService } from "@/services/SessionService"; +import SessionCheckInScanner from "./SessionCheckInScanner"; +import { getServerSession } from "next-auth"; +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; + +interface SessionCheckInParams { + id: string; +} + +export default async function SessionCheckIn({ + params, +}: { + params: SessionCheckInParams; +}) { + const { id: sessionID } = params; + + const sinfoSession = await SessionService.getSession(sessionID); + + if (!sinfoSession) { + return ; + } + + const session = await getServerSession(authOptions); + + return ( +
+ +
+ ); +} diff --git a/src/app/(authenticated)/sessions/[id]/page.tsx b/src/app/(authenticated)/sessions/[id]/page.tsx new file mode 100644 index 0000000..1464af2 --- /dev/null +++ b/src/app/(authenticated)/sessions/[id]/page.tsx @@ -0,0 +1,149 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import { hackyPeeking } from "@/assets/images"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; +import List from "@/components/List"; +import ListCard from "@/components/ListCard"; +import MessageCard from "@/components/MessageCard"; +import { ShowMore } from "@/components/ShowMore"; +import { PrizeTile } from "@/components/prize"; +import AddToCalendarButton from "@/components/session/AddToCalendarButton"; +import { SessionService } from "@/services/SessionService"; +import { UserService } from "@/services/UserService"; +import { generateTimeInterval, getSessionColor, isMember } from "@/utils/utils"; +import { CalendarClock, MapPin, Scan, Users } from "lucide-react"; +import { getServerSession } from "next-auth"; +import Image from "next/image"; +import Link from "next/link"; + +interface SessionParams { + id: string; +} + +export default async function Session({ params }: { params: SessionParams }) { + const { id: sessionID } = params; + + const sinfoSession = await SessionService.getSession(sessionID); + + if (!sinfoSession) { + return ; + } + + const session = await getServerSession(authOptions); + const user: User | null = await UserService.getMe(session!.cannonToken); + + return ( +
+
+ {sinfoSession.company && ( + + {sinfoSession.company.name} + + )} + Session image. +

{sinfoSession.name}

+
+ + + {generateTimeInterval(sinfoSession.date, sinfoSession.duration)} + + | + + + {sinfoSession.place} + +
+ + {sinfoSession.kind} + + + {sinfoSession.description} + +
+ {/* Members section */} + {user && isMember(user.role) && ( +
+ + + Participants + + + + Check-in + +
+ )} +
+ +
+ {/* ExtraInformation */} + {sinfoSession.extraInformation?.length ? ( + + {sinfoSession.extraInformation.map((e, idx) => ( + + ))} + + ) : ( + <> + )} + {/* Speakers */} + {sinfoSession.speakers && ( + + {sinfoSession.speakers.map((s) => ( + + ))} + + )} + {/* Company */} + {sinfoSession.company && ( + + + + )} + {/* Prize */} + {sinfoSession.prize && ( + + ({ userId: u }))} + pickWinner={!!user && isMember(user.role)} + cannonToken={session?.cannonToken} + /> + + )} +
+ ); +} diff --git a/src/app/(authenticated)/sessions/[id]/participants/page.tsx b/src/app/(authenticated)/sessions/[id]/participants/page.tsx new file mode 100644 index 0000000..2aa797e --- /dev/null +++ b/src/app/(authenticated)/sessions/[id]/participants/page.tsx @@ -0,0 +1,63 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; +import List from "@/components/List"; +import { UserTile } from "@/components/user/UserTile"; +import { AchievementService } from "@/services/AchievementService"; +import { SessionService } from "@/services/SessionService"; +import { UserService } from "@/services/UserService"; +import { getServerSession } from "next-auth"; + +interface SessionParticipantsParams { + id: string; +} + +export default async function SessionParticipants({ + params, +}: { + params: SessionParticipantsParams; +}) { + const { id: sessionID } = params; + + const sinfoSession = await SessionService.getSession(sessionID); + + if (!sinfoSession) { + return ; + } + + const session = await getServerSession(authOptions); + + const sessionAchievement = await AchievementService.getAchievementBySession( + session!.cannonToken, + sessionID + ); + if (!sessionAchievement) { + return ; + } + + async function getUserTile(userId: string) { + const user = await UserService.getUser(session!.cannonToken, userId); + if (!user) return undefined; + return ; + } + + const unregisteredUsers = sessionAchievement.unregisteredUsers || 0; + const totalUsers = + (sessionAchievement.users?.length ?? 0) + unregisteredUsers; + + return ( +
+ + {sessionAchievement.users?.length ? ( + await Promise.all( + sessionAchievement.users.map((id) => getUserTile(id)) + ) + ) : ( +
There are no registered users at this session
+ )} +
+
+ ); +} diff --git a/src/app/(authenticated)/speakers/SpeakersList.tsx b/src/app/(authenticated)/speakers/SpeakersList.tsx new file mode 100644 index 0000000..6c7c233 --- /dev/null +++ b/src/app/(authenticated)/speakers/SpeakersList.tsx @@ -0,0 +1,51 @@ +"use client"; +import GridList from "@/components/GridList"; +import { SpeakerTile } from "@/components/speaker"; +import { useState } from "react"; + +interface SpeakersListProps { + speakers: Speaker[]; +} + +export default function SpeakersList({ speakers }: SpeakersListProps) { + const [filteredSpeakers, setFilteredSpeakers] = useState(speakers); + let debounce: NodeJS.Timeout; + + function handleSearch(text: string) { + debounce && clearTimeout(debounce); + debounce = setTimeout(() => { + if (text === "") { + setFilteredSpeakers(speakers); + } else { + setFilteredSpeakers( + speakers.filter( + (speaker) => + speaker.name.toLowerCase().includes(text.toLowerCase()) || + speaker.company?.name?.toLowerCase().includes(text.toLowerCase()), + ), + ); + } + }, 300); + } + + return ( + <> +
+ { + handleSearch(e.target.value); + }} + /> +
+ + {filteredSpeakers.length === 0 &&
No speakers found
} + {filteredSpeakers.map((c) => ( + + ))} +
+ + ); +} diff --git a/src/app/(authenticated)/speakers/[id]/page.tsx b/src/app/(authenticated)/speakers/[id]/page.tsx new file mode 100644 index 0000000..6709cd0 --- /dev/null +++ b/src/app/(authenticated)/speakers/[id]/page.tsx @@ -0,0 +1,62 @@ +import BlankPageWithMessage from "@/components/BlankPageMessage"; +import List from "@/components/List"; +import { ShowMore } from "@/components/ShowMore"; +import { SessionTile } from "@/components/session"; +import { SpeakerService } from "@/services/SpeakerService"; +import Image from "next/image"; + +interface SpeakerParams { + id: string; +} + +export default async function Speaker({ params }: { params: SpeakerParams }) { + const { id: speakerID } = params; + + const speaker = await SpeakerService.getSpeaker(speakerID); + + if (!speaker) { + return ; + } + + const speakerSessions = speaker.sessions?.sort((a, b) => + a.date.localeCompare(b.date) + ); + + return ( +
+
+

{speaker.name}

+ {`${speaker.name} +

{speaker.title}

+ {speaker.company?.img && ( + {`${speaker.company.name} + )} + + {speaker.description} + +
+ {/* Company Sessions */} + {speakerSessions?.length ? ( + + {speakerSessions.map((s) => ( + + ))} + + ) : ( + <> + )} +
+ ); +} diff --git a/src/app/(authenticated)/speakers/page.tsx b/src/app/(authenticated)/speakers/page.tsx new file mode 100644 index 0000000..093be0a --- /dev/null +++ b/src/app/(authenticated)/speakers/page.tsx @@ -0,0 +1,20 @@ +import { SpeakerService } from "@/services/SpeakerService"; +import SpeakersList from "./SpeakersList"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; + +export default async function Speakers() { + let speakers = await SpeakerService.getSpeakers(); + + if (!speakers) { + return ; + } + + // Sort speakers by name + speakers = speakers.sort((a, b) => a.name.localeCompare(b.name)); + + return ( +
+ +
+ ); +} diff --git a/src/app/(authenticated)/spin/page.tsx b/src/app/(authenticated)/spin/page.tsx new file mode 100644 index 0000000..28281e0 --- /dev/null +++ b/src/app/(authenticated)/spin/page.tsx @@ -0,0 +1,69 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; +import { CompanyTile } from "@/components/company"; +import GridList from "@/components/GridList"; +import ProgressBar from "@/components/ProgressBar"; +import UserSignOut from "@/components/UserSignOut"; +import { SPIN_WHEEL_MAXIMUM } from "@/constants"; +import { CompanyService } from "@/services/CompanyService"; +import { UserService } from "@/services/UserService"; +import { isCompany, isToday } from "@/utils/utils"; +import { getServerSession } from "next-auth"; + +export default async function Spin() { + const session = (await getServerSession(authOptions))!; + const user = await UserService.getMe(session.cannonToken); + if (!user) return ; + + if (isCompany(user.role)) { + return ( + + ); + } + + const spinWheelData = user.signatures?.find( + (s) => s.edition === process.env.EVENT_EDITION && isToday(s.day) + ); + + if (spinWheelData?.redeemed) { + return ( + + ); + } + + const allCompanies = (await CompanyService.getCompanies()) ?? []; + const companyMap = new Map(allCompanies.map((c) => [c.id, c])); + + const spinWheelCompanies = + spinWheelData?.signatures + .sort((a, b) => a.date.localeCompare(b.date)) + .map((s) => companyMap.get(s.companyId)) + .filter((c) => c !== undefined) + .slice(0, SPIN_WHEEL_MAXIMUM) ?? []; + + return ( +
+
+

Spin the Wheel

+

+ 1. Visit a company and learn about them.
+ 2. Ask the company to scan your QR code.
+ 3. After visiting 10 companies, go to the SINFO desk to spin the wheel + and win prizes! +

+
+ + + {spinWheelCompanies.length === 0 &&
No companies to show
} + {spinWheelCompanies.map( + (c) => c && + )} +
+
+ ); +} diff --git a/src/app/(authenticated)/terms-and-conditions/cv/page.tsx b/src/app/(authenticated)/terms-and-conditions/cv/page.tsx new file mode 100644 index 0000000..2c6870e --- /dev/null +++ b/src/app/(authenticated)/terms-and-conditions/cv/page.tsx @@ -0,0 +1,27 @@ +export default function TermsAndConditionsCV() { + return ( + <> +

+ By submitting your Curriculum Vitae on our platform, you are agreeing + to: +

+
    +
  • It being distributed to our sponsors;
  • +
  • + Potentially being contacted by our sponsors regarding your + information; +
  • +
  • Your data being processed by the SINFO staff.
  • +
+

+ We are compliant with the GDPR. Your data will be securely stored during + the event and deleted after being distributed to our sponsors. If you + have any questions please contact us at{" "} + + geral@sinfo.org + + . +

+ + ); +} diff --git a/src/app/(authenticated)/terms-and-conditions/layout.tsx b/src/app/(authenticated)/terms-and-conditions/layout.tsx new file mode 100644 index 0000000..47be103 --- /dev/null +++ b/src/app/(authenticated)/terms-and-conditions/layout.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from "react"; + +export default function TermsAndConditionsLayout({ + children, +}: { + children: ReactNode; +}) { + return ( +
+
+

Terms and conditions

+
+ {children} +
+
+
+ ); +} diff --git a/src/app/(authenticated)/users/[id]/buttons/ConnectButton.tsx b/src/app/(authenticated)/users/[id]/buttons/ConnectButton.tsx new file mode 100644 index 0000000..6b10c3f --- /dev/null +++ b/src/app/(authenticated)/users/[id]/buttons/ConnectButton.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { UserMinus, UserPlus } from "lucide-react"; +import { ProfileButtonProps } from "."; +import { UserService } from "@/services/UserService"; + +export default function ConnectButton({ + cannonToken, + user, + otherUser, + connections, +}: ProfileButtonProps) { + if (user.id === otherUser.id) return <>; + + const connection = connections.find((c) => c.to === otherUser.id); + + async function handleConnect() { + const connection = await UserService.connect(cannonToken, otherUser.id); + if (!connection) { + alert("Failed to connect"); + } + } + + async function handleRemoveConnection() { + const connection = await UserService.removeConnection( + cannonToken, + otherUser.id, + ); + if (!connection) { + alert("Failed to connect"); + } + } + + if (connection) { + return ( + + ); + } else { + return ( + + ); + } +} diff --git a/src/app/(authenticated)/users/[id]/buttons/DemoteButton.tsx b/src/app/(authenticated)/users/[id]/buttons/DemoteButton.tsx new file mode 100644 index 0000000..a69ac98 --- /dev/null +++ b/src/app/(authenticated)/users/[id]/buttons/DemoteButton.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { UserService } from "@/services/UserService"; +import { isCompany, isMember } from "@/utils/utils"; +import { ArrowBigDown } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { ProfileButtonProps } from "."; + +export default function DemoteButton({ + cannonToken, + user, + otherUser, +}: ProfileButtonProps) { + const router = useRouter(); + + if ( + user.id === otherUser.id || + !isMember(user.role) || + (!isCompany(otherUser.role) && !isMember(otherUser.role)) + ) + return <>; + + async function handleDemote() { + if (await UserService.demote(cannonToken, otherUser.id)) router.refresh(); + else alert("Failed to demote!"); + } + + return ( + + ); +} diff --git a/src/app/(authenticated)/users/[id]/buttons/ValidateSpinButton.tsx b/src/app/(authenticated)/users/[id]/buttons/ValidateSpinButton.tsx new file mode 100644 index 0000000..07b82d0 --- /dev/null +++ b/src/app/(authenticated)/users/[id]/buttons/ValidateSpinButton.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { isCompany, isMember, isToday } from "@/utils/utils"; +import { FerrisWheel } from "lucide-react"; +import { ProfileButtonProps } from "."; +import { UserService } from "@/services/UserService"; +import { useRouter } from "next/navigation"; +import { SPIN_WHEEL_MAXIMUM } from "@/constants"; + +export default function ValidateSpinButton({ + cannonToken, + user, + otherUser, +}: ProfileButtonProps) { + const router = useRouter(); + + if ( + user.id === otherUser.id || + !isMember(user.role) || + isCompany(otherUser.role) + ) { + return <>; + } + + const spinWheelData = otherUser.signatures?.find( + (s) => s.edition === process.env.EVENT_EDITION && isToday(s.day) + ); + + const isEligible = + (spinWheelData && + !spinWheelData.redeemed && + spinWheelData.signatures.length >= SPIN_WHEEL_MAXIMUM) ?? + false; + + async function validateSpinWheel() { + const success = await UserService.validateSpinWheel( + cannonToken, + otherUser.id + ); + if (success) router.refresh(); + else alert("Could not validate!"); + } + + return ( + + ); +} diff --git a/src/app/(authenticated)/users/[id]/buttons/index.tsx b/src/app/(authenticated)/users/[id]/buttons/index.tsx new file mode 100644 index 0000000..5cd4e05 --- /dev/null +++ b/src/app/(authenticated)/users/[id]/buttons/index.tsx @@ -0,0 +1,20 @@ +import ConnectButton from "./ConnectButton"; +import DemoteButton from "./DemoteButton"; +import ValidateSpinButton from "./ValidateSpinButton"; + +export interface ProfileButtonProps { + cannonToken: string; + user: User; + otherUser: User; + connections: Connection[]; +} + +export default function ProfileButtons(buttonProps: ProfileButtonProps) { + return ( +
+ + + +
+ ); +} diff --git a/src/app/(authenticated)/users/[id]/page.tsx b/src/app/(authenticated)/users/[id]/page.tsx new file mode 100644 index 0000000..48ddfe0 --- /dev/null +++ b/src/app/(authenticated)/users/[id]/page.tsx @@ -0,0 +1,101 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import GridList from "@/components/GridList"; +import List from "@/components/List"; +import AchievementTile from "@/components/user/AchievementTile"; +import CurriculumVitae from "@/components/user/CurriculumVitae"; +import ProfileHeader from "@/components/user/ProfileHeader"; +import ProfileInformations from "@/components/user/ProfileInformations"; +import { AchievementService } from "@/services/AchievementService"; +import { UserService } from "@/services/UserService"; +import { isCompany, isMember } from "@/utils/utils"; +import { getServerSession } from "next-auth"; +import UserSignOut from "@/components/UserSignOut"; +import BlankPageWithMessage from "@/components/BlankPageMessage"; +import ProfileButtons from "./buttons"; +import Notes from "@/components/user/Notes"; + +interface UserProfileParams { + id: string; +} + +export default async function UserProfile({ + params, +}: { + params: UserProfileParams; +}) { + const { id: userID } = params; + + const session = (await getServerSession(authOptions))!; + const user = await UserService.getMe(session.cannonToken); + if (!user) return ; + + const userProfile = await UserService.getUser(session.cannonToken, userID); + if (!userProfile) { + return ; + } + + const achievements = await AchievementService.getAchievements(); + const userAchievements = achievements?.filter((a) => + a.users?.includes(userProfile.id), + ); + + const connections = await UserService.getConnections(session.cannonToken); + const connection = connections?.find((c) => c.to === userProfile.id); + + async function handleNotesUpdate(notes: string) { + "use server"; + if (userProfile) + await UserService.updateConnection( + session.cannonToken, + userProfile.id, + notes, + ); + } + + return ( +
+ + + + + {/* Notes */} + {connection && ( + + )} + + {!isCompany(userProfile.role) && + (isCompany(user.role) || isMember(user.role)) && ( + + + + )} + + {/* User information */} + + + {/* Achievements */} + {userAchievements?.length ? ( + acc + a.value, 0) || 0}`} + scrollable + > + {userAchievements.map((a) => ( + + ))} + + ) : ( + <> + )} +
+ ); +} diff --git a/src/app/(authenticated)/venue/VenueStands.tsx b/src/app/(authenticated)/venue/VenueStands.tsx new file mode 100644 index 0000000..2606169 --- /dev/null +++ b/src/app/(authenticated)/venue/VenueStands.tsx @@ -0,0 +1,166 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { useSearchParams, usePathname, useRouter } from "next/navigation"; +import EventDayButton from "@/components/EventDayButton"; +import GridList from "@/components/GridList"; +import ZoomSvg from "@/components/svg/ZoomableSvg"; +import CompanyStand from "./stands/CompanyStand"; +import Entrances from "./stands/Entrances"; +import CoworkingZone from "./stands/CoworkingZone"; +import SessionsStands from "./stands/SessionsZone"; +import FoodZone from "./stands/FoodZone"; + +interface VenueStandsProps { + companies: Company[]; +} + +const VenueStands: React.FC = ({ companies }) => { + const searchParams = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); + + // Extract the 'day' parameter from the URL search parameters + const dayParam = searchParams.get("day"); + const companyParam = searchParams.get("company"); + const [showingDay, setShowingDay] = useState(null); + + const standPositions: Record = { + 1: { x: 118, y: 188 }, + 2: { x: 118, y: 218 }, + 3: { x: 180, y: 142 }, + 4: { x: 209, y: 142 }, + 5: { x: 180, y: 171 }, + 6: { x: 209, y: 171 }, + 7: { x: 180, y: 218 }, + 8: { x: 209, y: 218 }, + 9: { x: 276, y: 142 }, + 10: { x: 305, y: 142 }, + 11: { x: 276, y: 189 }, + 12: { x: 305, y: 189 }, + 13: { x: 276, y: 219 }, + 14: { x: 305, y: 219 }, + 15: { x: 367, y: 142 }, + 16: { x: 396, y: 142 }, + 17: { x: 367, y: 171 }, + 18: { x: 396, y: 171 }, + 19: { x: 367, y: 218 }, + 20: { x: 396, y: 218 }, + 21: { x: 453, y: 137 }, + 22: { x: 453, y: 165 }, + 23: { x: 453, y: 193 }, + 24: { x: 453, y: 222 }, + }; + + const getAllUniqueDates = (companies: Company[]): string[] => { + return Array.from( + new Set( + companies + .flatMap((company) => company.stands || []) + .map((stand) => stand.date.split("T")[0]) + ) + ).sort(); + }; + + const sortedDays = getAllUniqueDates(companies); + + const companiesForSelectedDay = useMemo(() => { + if (!showingDay) return []; + return companies.filter((company) => + company.stands?.some((stand) => stand.date.split("T")[0] === showingDay) + ); + }, [companies, showingDay]); + + const getCompanyAtPosition = (standId: string): Company | null => { + if (!showingDay) return null; + + return ( + companiesForSelectedDay.find((company) => + company.stands?.some( + (stand) => + stand.date.split("T")[0] === showingDay && stand.standId === standId + ) + ) || null + ); + }; + + const standsForSelectedDay = useMemo(() => { + if (!showingDay) return []; + return companies.flatMap( + (company) => + company.stands?.filter( + (stand) => stand.date.split("T")[0] === showingDay + ) || [] + ); + }, [companies, showingDay]); + + useEffect(() => { + const today = new Date().toISOString().split("T")[0]; + const defaultDay = sortedDays.includes(today) ? today : sortedDays[0]; + + if (!showingDay) { + setShowingDay(dayParam || defaultDay); + } + }, [dayParam, sortedDays, showingDay]); + + const updateSearchParam = (newDay: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("day", newDay); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }; + + useEffect(() => { + if (dayParam && sortedDays.includes(dayParam)) { + setShowingDay(dayParam); + } + }, [dayParam, sortedDays]); + + const isStandHighlighted = (company: Company | null) => { + return company?.id === companyParam; + }; + + return ( +
+ + {sortedDays.map((day) => ( + { + setShowingDay(day); + updateSearchParam(day); + }} + selected={showingDay === day} + /> + ))} + + + + + + + + + + {/* Company Stands */} + + {standsForSelectedDay.map((stand) => { + const company = getCompanyAtPosition(stand.standId); + return ( + + ); + })} + + + +
+ ); +}; + +export default VenueStands; diff --git a/src/app/(authenticated)/venue/page.tsx b/src/app/(authenticated)/venue/page.tsx new file mode 100644 index 0000000..3b746ca --- /dev/null +++ b/src/app/(authenticated)/venue/page.tsx @@ -0,0 +1,44 @@ +import { CompanyService } from "@/services/CompanyService"; +import VenueStands from "./VenueStands"; + +export default async function VenuePage() { + const all_companies = await CompanyService.getCompanies(); + + function companiesNotFetched() { + return ( +
+
No companies found!
+
+ ); + } + + if (!all_companies || all_companies.length === 0) { + return companiesNotFetched(); + } + + const companies = ( + await Promise.all( + all_companies.map(async (company) => { + const detailedCompany = await CompanyService.getCompany(company.id); + return detailedCompany; + }) + ) + ).filter(Boolean); + + if (!companies || companies.length === 0) { + return companiesNotFetched(); + } + + const uniqueCompanies = Array.from( + new Map(companies.map((company) => [company?.id, company])).values() + ).filter((company): company is Company => company !== null); // Remove nulls + + return ( +
+
+

Venue

+
+ +
+ ); +} diff --git a/src/app/(authenticated)/venue/stands/CompanyStand.tsx b/src/app/(authenticated)/venue/stands/CompanyStand.tsx new file mode 100644 index 0000000..38f4874 --- /dev/null +++ b/src/app/(authenticated)/venue/stands/CompanyStand.tsx @@ -0,0 +1,90 @@ +"use client"; + +import React from "react"; + +interface StandProps { + stand: Stand; + company: Company | null; + standPositions: Record; + isSelected?: boolean; + className?: string; +} + +const CompanyStand: React.FC = ({ + stand, + company, + standPositions, + isSelected = false, + className = "", +}) => { + const standNumber = parseInt(stand.standId); + const position = standPositions[standNumber]; + + if (!position) return null; + + return ( + + + {/* Base stand rectangle */} + + + {/* Company logo or stand number */} + {company?.img ? ( + + ) : company?.name ? ( + + {company.name} + + ) : ( + + {standNumber} + + )} + + + ); +}; + +export default CompanyStand; diff --git a/src/app/(authenticated)/venue/stands/CoworkingZone.tsx b/src/app/(authenticated)/venue/stands/CoworkingZone.tsx new file mode 100644 index 0000000..1abb742 --- /dev/null +++ b/src/app/(authenticated)/venue/stands/CoworkingZone.tsx @@ -0,0 +1,64 @@ +const CoworkingZone = () => { + return ( + + {/* Startup Zone */} + + + + Startup + + + zone + + + + {/* Gaming Zone */} + + + + Gaming + + + zone + + + + {/* Lounge Zone */} + + + + Lounge + + + + ); +}; + +export default CoworkingZone; diff --git a/src/app/(authenticated)/venue/stands/Entrances.tsx b/src/app/(authenticated)/venue/stands/Entrances.tsx new file mode 100644 index 0000000..a7aa884 --- /dev/null +++ b/src/app/(authenticated)/venue/stands/Entrances.tsx @@ -0,0 +1,44 @@ +const Entrances = () => { + return ( + + + + + + Entrance + + + Entrance + + + Entrance + + + ); +}; + +export default Entrances; diff --git a/src/app/(authenticated)/venue/stands/FoodZone.tsx b/src/app/(authenticated)/venue/stands/FoodZone.tsx new file mode 100644 index 0000000..51cfffe --- /dev/null +++ b/src/app/(authenticated)/venue/stands/FoodZone.tsx @@ -0,0 +1,30 @@ +const FoodZone = () => { + return ( + + {/* Breakfast Zone */} + + + Breakfast Zone + + + + {/* Coffee Break */} + + + + Coffee-break + + + + ); +}; + +export default FoodZone; diff --git a/src/app/(authenticated)/venue/stands/SessionsZone.tsx b/src/app/(authenticated)/venue/stands/SessionsZone.tsx new file mode 100644 index 0000000..6c2a14d --- /dev/null +++ b/src/app/(authenticated)/venue/stands/SessionsZone.tsx @@ -0,0 +1,83 @@ +const SessionsStands = () => { + return ( + + {/* Main Rooms */} + + {/* Auditorium */} + + + + + Auditorium + + + + + {/* Room 1 */} + + + + + Room 1 + + + + + {/* Room 2 */} + + + + + Room 2 + + + + + + {/* 2nd Stage */} + + + + 2nd + + + stage + + + + ); +}; + +export default SessionsStands; diff --git a/src/app/(root)/page.tsx b/src/app/(root)/page.tsx index 9c7dc9f..3d34f32 100644 --- a/src/app/(root)/page.tsx +++ b/src/app/(root)/page.tsx @@ -1,87 +1,5 @@ -import QRCode from "react-qr-code"; - -import Image from "next/image"; import { redirect } from "next/navigation"; -import { getServerSession } from "next-auth/next"; -import { revalidateTag } from "next/cache"; - -import hacky from "@/assets/images/hacky-peeking.png"; -import UserSignOut from "@/components/UserSignOut"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { UserService } from "@/services/UserService"; -import { CompanyService } from "@/services/CompanyService"; - -export default async function Home() { - const session = await getServerSession(authOptions); - if (!session) redirect("/login"); - - const user: User = await UserService.getMe(session.cannonToken); - if (!user) return ; - let company!: Company | null; - if (user.role === "company") { - // assumes that cannon api only provides the company associated with the current edition - if (user.company.length == 0) { - await demoteMe(session.cannonToken); - } else { - company = await CompanyService.getCompany(user.company[0].company); - } - } - - return ( -
- Hacky Peaking - -

{getDisplayRole(user.role)}

- {company && ( - {company.name - )} -
{user.name}
-
- ); +export default function Root() { + redirect("/home"); } - -const demoteMe = async (cannonToken: string) => { - const success = await UserService.demoteMe(cannonToken); - if (success) { - revalidateTag("modified-me"); - redirect("/"); - } -}; - -const getBorderColor = (role: string) => { - switch (role) { - case "team": - case "admin": - return "#296CB2"; // blue - case "company": - return "#B17EC9"; // pink - case "attendee": - default: - return "#74C48A"; // green - } -}; - -const getDisplayRole = (role: string) => { - switch (role) { - case "company": - return "Company"; - case "team": - return "Member"; - case "admin": - return "Admin"; - case "user": - default: - return "Attendee"; - } -}; diff --git a/src/app/api/auth/[...nextauth]/authOptions.ts b/src/app/api/auth/[...nextauth]/authOptions.ts new file mode 100644 index 0000000..a44f168 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/authOptions.ts @@ -0,0 +1,125 @@ +import { NextAuthOptions } from "next-auth"; +import GoogleProvider from "next-auth/providers/google"; +import LinkedInProvider from "next-auth/providers/linkedin"; + +const CANNON_AUTH_ENDPOINT = process.env.CANNON_URL + "/auth"; +const FENIX_AUTH_URL = process.env.FENIX_URL + "/oauth/userdialog"; +const FENIX_TOKEN_URL = process.env.FENIX_URL + "/oauth/access_token"; +const FENIX_PROFILE_URL = process.env.FENIX_URL + "/api/fenix/v1/person"; +const FENIX_CALLBACK_URI = process.env.WEBAPP_URL + "/api/auth/callback/fenix"; + +export default { + secret: process.env.NEXTAUTH_SECRET, + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + }), + LinkedInProvider({ + clientId: process.env.LINKEDIN_CLIENT_ID as string, + clientSecret: process.env.LINKEDIN_CLIENT_SECRET as string, + issuer: "https://www.linkedin.com/oauth", + jwks_endpoint: "https://www.linkedin.com/oauth/openid/jwks", + async profile(profile) { + return { + id: profile.sub, + name: profile.name, + firstname: profile.given_name, + lastname: profile.family_name, + email: profile.email, + }; + }, + }), + { + id: "microsoft", + name: "Microsoft", + type: "oauth", + idToken: true, + wellKnown: + "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration", + authorization: { + params: { scope: "openid email" }, + }, + async profile(profile) { + return { + id: profile.sub, + email: profile.email, + }; + }, + clientId: process.env.MICROSOFT_CLIENT_ID as string, + clientSecret: process.env.MICROSOFT_CLIENT_SECRET as string, + }, + { + id: "fenix", + name: "Fenix", + type: "oauth", + authorization: { + url: FENIX_AUTH_URL, + params: { scope: "" }, + }, + token: { + async request({ params }) { + if (params.code) { + const url = + FENIX_TOKEN_URL + + "?" + + new URLSearchParams({ + client_id: process.env.FENIX_CLIENT_ID as string, + client_secret: process.env.FENIX_CLIENT_SECRET as string, + redirect_uri: FENIX_CALLBACK_URI, + grant_type: "authorization_code", + code: params.code, + }); + const resp = await fetch(url, { + method: "POST", + }); + if (resp.ok) { + return { tokens: await resp.json() }; + } + } + return { tokens: {} }; + }, + }, + userinfo: FENIX_PROFILE_URL, + async profile(profile) { + return { + id: profile.username, + name: profile.name, + email: profile.email, + image: `https://fenix.tecnico.ulisboa.pt/user/photo/${profile.username}`, + }; + }, + clientId: process.env.FENIX_CLIENT_ID as string, + clientSecret: process.env.FENIX_CLIENT_SECRET as string, + }, + ], + callbacks: { + async redirect() { + return "/"; + }, + async jwt({ token, user, account }) { + // The arguments user, account and profile are only passed the first time this callback is called + // on a new session, after the user signs in. In subsequent calls, only token will be available. + if (user) { + const url = CANNON_AUTH_ENDPOINT + "/" + account?.provider; + const resp = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ accessToken: account?.access_token }), + }); + if (resp.ok) { + token.cannonToken = (await resp.json()).token; + token.loginWith = account?.provider ?? ""; + } + } + return token; + }, + async session({ token, session }) { + session.cannonToken = token.cannonToken; + session.loginWith = token.loginWith; + return session; + }, + }, +} as NextAuthOptions; diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 68c8501..f9f5c47 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,117 +1,5 @@ -import NextAuth, { NextAuthOptions } from "next-auth"; -import GoogleProvider from "next-auth/providers/google"; -import LinkedInProvider from "next-auth/providers/linkedin"; - -const CANNON_AUTH_ENDPOINT = process.env.CANNON_URL + "/auth"; -const FENIX_AUTH_URL = process.env.FENIX_URL + "/oauth/userdialog"; -const FENIX_TOKEN_URL = process.env.FENIX_URL + "/oauth/access_token"; -const FENIX_PROFILE_URL = process.env.FENIX_URL + "/api/fenix/v1/person"; -const FENIX_CALLBACK_URI = process.env.WEBAPP_URL + "/api/auth/callback/fenix"; - -export const authOptions: NextAuthOptions = { - secret: process.env.NEXTAUTH_SECRET, - providers: [ - GoogleProvider({ - clientId: process.env.GOOGLE_CLIENT_ID as string, - clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, - }), - LinkedInProvider({ - clientId: process.env.LINKEDIN_CLIENT_ID as string, - clientSecret: process.env.LINKEDIN_CLIENT_SECRET as string, - }), - { - id: "microsoft", - name: "Microsoft", - type: "oauth", - idToken: true, - wellKnown: - "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration", - authorization: { - params: { scope: "openid email" }, - }, - async profile(profile) { - return { - id: profile.sub, - email: profile.email, - }; - }, - clientId: process.env.MICROSOFT_CLIENT_ID as string, - clientSecret: process.env.MICROSOFT_CLIENT_SECRET as string, - }, - { - id: "fenix", - name: "Fenix", - type: "oauth", - authorization: { - url: FENIX_AUTH_URL, - params: { scope: "" }, - }, - token: { - async request({ params }) { - if (params.code) { - const url = - FENIX_TOKEN_URL + - "?" + - new URLSearchParams({ - client_id: process.env.FENIX_CLIENT_ID as string, - client_secret: process.env.FENIX_CLIENT_SECRET as string, - redirect_uri: FENIX_CALLBACK_URI, - grant_type: "authorization_code", - code: params.code, - }); - const resp = await fetch(url, { - method: "POST", - }); - if (resp.ok) { - return { tokens: await resp.json() }; - } - } - return { tokens: {} }; - }, - }, - userinfo: FENIX_PROFILE_URL, - async profile(profile) { - return { - id: profile.username, - name: profile.name, - email: profile.email, - image: `https://fenix.tecnico.ulisboa.pt/user/photo/${profile.username}`, - }; - }, - clientId: process.env.FENIX_CLIENT_ID as string, - clientSecret: process.env.FENIX_CLIENT_SECRET as string, - }, - ], - callbacks: { - async redirect() { - return "/"; - }, - async jwt({ token, user, account }) { - // The arguments user, account and profile are only passed the first time this callback is called - // on a new session, after the user signs in. In subsequent calls, only token will be available. - if (user) { - const url = CANNON_AUTH_ENDPOINT + "/" + account?.provider; - const resp = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ accessToken: account?.access_token }), - }); - if (resp.ok) { - token.cannonToken = (await resp.json()).token; - token.loginWith = account?.provider ?? ""; - } - } - return token; - }, - async session({ token, session }) { - session.cannonToken = token.cannonToken; - session.loginWith = token.loginWith; - return session; - }, - }, -}; +import NextAuth from "next-auth"; +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 36a0670..b2c2e2c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,13 +1,7 @@ import type { Metadata } from "next"; import { Montserrat } from "next/font/google"; -import { getServerSession } from "next-auth"; - import "@/styles/globals.css"; - -import Navbar from "@/components/Navbar"; -import BottomNavbar from "@/components/BottomNavbar"; import AuthProvider from "@/context/AuthProvider"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; const montserrat = Montserrat({ subsets: ["latin"] }); @@ -21,27 +15,11 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - const session = await getServerSession(authOptions); - return ( - - -
-
- -
-
- {children} -
- {session && ( -
- -
- )} -
-
- + + {children} + ); } diff --git a/src/app/login/AuthProviderButton.tsx b/src/app/login/AuthProviderButton.tsx new file mode 100644 index 0000000..828a430 --- /dev/null +++ b/src/app/login/AuthProviderButton.tsx @@ -0,0 +1,24 @@ +import { StaticImageData } from "next/image"; +import { Image } from "next/dist/client/image-component"; + +interface AuthProviderButtonProps { + icon: StaticImageData; + name: String; + onClick: () => {}; +} + +export default function AuthProviderButton({ + icon, + name, + onClick, +}: AuthProviderButtonProps) { + return ( + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 8d6886f..810889e 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,79 +1,57 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { signIn, useSession } from "next-auth/react"; -import { Image } from "next/dist/client/image-component"; -import { useRouter } from "next/navigation"; - -import welcomeImage from "@/assets/images/login-welcome.png"; -import webappLogo from "@/assets/images/sinfo-webapp-logo.png"; -import googleIcon from "@/assets/icons/google.png"; -import istIcon from "@/assets/icons/ist.png"; -import linkedinIcon from "@/assets/icons/linkedin.png"; -import microsoftIcon from "@/assets/icons/microsoft.png"; - -export default function Login() { - const { status } = useSession(); - const { push } = useRouter(); - - useEffect(() => { - if (status === "authenticated") push("/"); - }); - - const [loginExpanded, setLoginExpanded] = useState(false); - - return ( -
- {loginExpanded ? ( - <> - SINFO WebApp logo -

Login

-
- - - - -
- - ) : ( - <> - Welcome Image - - - )} -
- ); -} +"use client"; + +import { useEffect } from "react"; +import { signIn, useSession } from "next-auth/react"; +import { Image } from "next/dist/client/image-component"; +import { useRouter } from "next/navigation"; +import { + googleIcon, + istIcon, + linkedinIcon, + microsoftIcon, +} from "@/assets/icons"; +import { sinfoLogo } from "@/assets/images"; +import AuthProviderButton from "./AuthProviderButton"; + +export default function Login() { + const { status } = useSession(); + const { push } = useRouter(); + + useEffect(() => { + if (status === "authenticated") push("/"); + }); + + return ( +
+ SINFO logo +
+ signIn("google")} + /> + signIn("linkedin")} + /> + signIn("microsoft")} + /> + signIn("fenix")} + /> +
+

SINFO - Website

+
+ ); +} diff --git a/src/assets/icons/apple-calendar.svg b/src/assets/icons/apple-calendar.svg new file mode 100644 index 0000000..b5c73af --- /dev/null +++ b/src/assets/icons/apple-calendar.svg @@ -0,0 +1,60 @@ + + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/blueCamera.png b/src/assets/icons/blueCamera.png deleted file mode 100644 index 10c5f98..0000000 Binary files a/src/assets/icons/blueCamera.png and /dev/null differ diff --git a/src/assets/icons/camera.png b/src/assets/icons/camera.png deleted file mode 100644 index c200eaf..0000000 Binary files a/src/assets/icons/camera.png and /dev/null differ diff --git a/src/assets/icons/check-in.png b/src/assets/icons/check-in.png deleted file mode 100644 index 8433d4c..0000000 Binary files a/src/assets/icons/check-in.png and /dev/null differ diff --git a/src/assets/icons/confetti1.png b/src/assets/icons/confetti1.png deleted file mode 100644 index bb10bae..0000000 Binary files a/src/assets/icons/confetti1.png and /dev/null differ diff --git a/src/assets/icons/confetti2.png b/src/assets/icons/confetti2.png deleted file mode 100644 index 8377d09..0000000 Binary files a/src/assets/icons/confetti2.png and /dev/null differ diff --git a/src/assets/icons/exit.png b/src/assets/icons/exit.png deleted file mode 100644 index 35ae444..0000000 Binary files a/src/assets/icons/exit.png and /dev/null differ diff --git a/src/assets/icons/gift-box.png b/src/assets/icons/gift-box.png deleted file mode 100644 index 2e5db4e..0000000 Binary files a/src/assets/icons/gift-box.png and /dev/null differ diff --git a/src/assets/icons/google-calendar.svg b/src/assets/icons/google-calendar.svg new file mode 100644 index 0000000..8598e2e --- /dev/null +++ b/src/assets/icons/google-calendar.svg @@ -0,0 +1,44 @@ + diff --git a/src/assets/icons/happy.png b/src/assets/icons/happy.png deleted file mode 100644 index 56b8098..0000000 Binary files a/src/assets/icons/happy.png and /dev/null differ diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts new file mode 100644 index 0000000..35a2828 --- /dev/null +++ b/src/assets/icons/index.ts @@ -0,0 +1,6 @@ +export { default as googleIcon } from "./google.png"; +export { default as istIcon } from "./ist.png"; +export { default as linkedinIcon } from "./linkedin.png"; +export { default as microsoftIcon } from "./microsoft.png"; +export { default as googleCalendarIcon } from "./google-calendar.svg"; +export { default as appleCalendarIcon } from "./apple-calendar.svg"; diff --git a/src/assets/icons/link_card_attendee.png b/src/assets/icons/link_card_attendee.png deleted file mode 100644 index 0f7e67d..0000000 Binary files a/src/assets/icons/link_card_attendee.png and /dev/null differ diff --git a/src/assets/icons/link_card_company.png b/src/assets/icons/link_card_company.png deleted file mode 100644 index 1ba1628..0000000 Binary files a/src/assets/icons/link_card_company.png and /dev/null differ diff --git a/src/assets/icons/menu.png b/src/assets/icons/menu.png deleted file mode 100644 index 423e891..0000000 Binary files a/src/assets/icons/menu.png and /dev/null differ diff --git a/src/assets/icons/menuButton.png b/src/assets/icons/menuButton.png deleted file mode 100644 index 0b2e5d8..0000000 Binary files a/src/assets/icons/menuButton.png and /dev/null differ diff --git a/src/assets/icons/qr-code.png b/src/assets/icons/qr-code.png deleted file mode 100644 index de7031e..0000000 Binary files a/src/assets/icons/qr-code.png and /dev/null differ diff --git a/src/assets/icons/question_mark.png b/src/assets/icons/question_mark.png deleted file mode 100644 index e03f8a3..0000000 Binary files a/src/assets/icons/question_mark.png and /dev/null differ diff --git a/src/assets/icons/unhappy.png b/src/assets/icons/unhappy.png deleted file mode 100644 index 987db16..0000000 Binary files a/src/assets/icons/unhappy.png and /dev/null differ diff --git a/src/assets/images/bg-cloudy.png b/src/assets/images/bg-cloudy.png deleted file mode 100644 index 817bb63..0000000 Binary files a/src/assets/images/bg-cloudy.png and /dev/null differ diff --git a/src/assets/images/index.ts b/src/assets/images/index.ts new file mode 100644 index 0000000..def11cf --- /dev/null +++ b/src/assets/images/index.ts @@ -0,0 +1,2 @@ +export { default as hackyPeeking } from "./hacky-peeking.png"; +export { default as sinfoLogo } from "./sinfo-logo.svg"; diff --git a/src/assets/images/login-welcome.png b/src/assets/images/login-welcome.png deleted file mode 100644 index db7af89..0000000 Binary files a/src/assets/images/login-welcome.png and /dev/null differ diff --git a/src/assets/images/sinfo-logo.svg b/src/assets/images/sinfo-logo.svg new file mode 100644 index 0000000..9e2ff66 --- /dev/null +++ b/src/assets/images/sinfo-logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/images/sinfo-webapp-logo.png b/src/assets/images/sinfo-webapp-logo.png deleted file mode 100644 index 338c7eb..0000000 Binary files a/src/assets/images/sinfo-webapp-logo.png and /dev/null differ diff --git a/src/components/ActionCard.tsx b/src/components/ActionCard.tsx deleted file mode 100644 index e43a848..0000000 --- a/src/components/ActionCard.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Image from "next/image"; - -import check from "@/assets/icons/check-in.png"; -import happy from "@/assets/icons/happy.png"; -import unhappy from "@/assets/icons/unhappy.png"; - -export default function ActionCard( - {action="", role="Undefined", bool=false} - : {action?: string, role?:string, bool?: Boolean}) { - let background = "bg-greenAC" - let image = check - let imageAlt = "Check" - let text = "" - let b1Text = "" - let b1 = false - - switch (action) { - case "promotion": - text = `Promoted to ${role} successfully!` - break; - case "checkIn": - text = "Checked-In successfully" - break; - case "achievement": - text = "Secret Achievement added successfully!" - b1 = true - b1Text = "Go to Achievements" - break; - case "validateCard": - if(bool) { - image = happy - text = "Great! Spin the wheel of fortune!" - } else { - image = unhappy - text = "Oops... Not this time!" - background = "bg-red"; - } - break; - case "workshop": - text = `Workshop ${bool ? "" : "dis"}enrollment successfull!` - b1 = true - b1Text = "Your Enrollments" - break; - case "linkCard": - text = "Card Linked successfully!" - b1 = true - b1Text = "My Links" - break; - default: - return (
Wrong input for action field in ActionCard component
); - } - - return( -
- {imageAlt} -
{text}
- {b1 ? : null} - -
- ) -} \ No newline at end of file diff --git a/src/components/BlankPageMessage.tsx b/src/components/BlankPageMessage.tsx new file mode 100644 index 0000000..4876a96 --- /dev/null +++ b/src/components/BlankPageMessage.tsx @@ -0,0 +1,7 @@ +export default function BlankPageWithMessage({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} diff --git a/src/components/BottomNavbar.tsx b/src/components/BottomNavbar.tsx deleted file mode 100644 index 3435189..0000000 --- a/src/components/BottomNavbar.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import Link from "next/link"; -import Image from "next/image"; -import { redirect } from "next/navigation"; -import { getServerSession } from "next-auth/next"; -import { headers } from 'next/headers'; - -import UserSignOut from "@/components/UserSignOut"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { UserService } from "@/services/UserService"; - -import qrCodeIcon from "@/assets/icons/qr-code.png"; -import cameraIconWhite from "@/assets/icons/camera.png"; -import cameraIconBlue from "@/assets/icons/blueCamera.png"; - -import giftBoxIcon from "@/assets/icons/gift-box.png"; -import attendeeLink from "@/assets/icons/link_card_attendee.png" -import companyLink from "@/assets/icons/link_card_company.png" - -//TODO: add redirects - -function RightIcon({ role } : { role: string}) { - console.log(role) - switch (role) { - case "team": - case "admin": - return( - - ); - - case "user": - return( - - ); - - case "company": - return( - - ); - - default: - break; - } -} - -export default async function BottomNavbar() { - const session = await getServerSession(authOptions); - if (!session) redirect("/login"); - - const user: User = await UserService.getMe(session.cannonToken); - if (!user) return ; - - let headersList = headers(); - let fullUrl = headersList.get('referer') || ""; - let urlObject = new URL(fullUrl); - let path = urlObject.pathname; - console.log(path) - - return ( -
-
- - Camera Icon - -
-
-
- - QR Code Icon - - -
-
-
- - ); -} diff --git a/src/components/BottomNavbar/NavbarItem.tsx b/src/components/BottomNavbar/NavbarItem.tsx new file mode 100644 index 0000000..6f0eab8 --- /dev/null +++ b/src/components/BottomNavbar/NavbarItem.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Building, Calendar, Home, User, Users } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export type NavbarItemKey = keyof typeof navbarItems; + +interface NavbarIconProps { + name: NavbarItemKey; +} + +const navbarItems = { + home: { + title: "Home", + icon: Home, + route: "/home", + }, + schedule: { + title: "Schedule", + icon: Calendar, + route: "/schedule", + }, + profile: { + title: "Profile", + icon: User, + route: "/profile", + }, + connections: { + title: "Connections", + icon: Users, + route: "/profile/connections", + }, + companies: { + title: "Companies", + icon: Building, + route: "/companies", + }, +}; + +export default function NavbarItem({ name }: NavbarIconProps) { + const { title, icon: Icon, route } = navbarItems[name]; + + const currPath = usePathname(); + const selected = route === currPath; + + return ( + + + {title} + + ); +} diff --git a/src/components/BottomNavbar/QRCodeButton.tsx b/src/components/BottomNavbar/QRCodeButton.tsx new file mode 100644 index 0000000..99980fc --- /dev/null +++ b/src/components/BottomNavbar/QRCodeButton.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { QrCode } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export default function QRCodeButton() { + const currPath = usePathname(); + + // Using ReactContexts might be better than this + if ( + currPath === "/qr" || + currPath.endsWith("/check-in") || + currPath.endsWith("/promote") || + currPath.endsWith("/profile/edit") || + currPath.endsWith("/scan") + ) + return <>; + + return ( + + + + ); +} diff --git a/src/components/BottomNavbar/index.tsx b/src/components/BottomNavbar/index.tsx new file mode 100644 index 0000000..9801eb6 --- /dev/null +++ b/src/components/BottomNavbar/index.tsx @@ -0,0 +1,34 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import { UserService } from "@/services/UserService"; +import { getServerSession } from "next-auth"; +import { convertToAppRole } from "@/utils/utils"; +import NavbarItem, { NavbarItemKey } from "./NavbarItem"; +import QRCodeButton from "./QRCodeButton"; + +// to add a new bottom navbar item, just add the item details to "navbarItems" +// and add the item key to the appropriate roles in "navbarItemKeysByRole" + +const navbarItemKeysByRole: Record = { + Attendee: ["home", "schedule", "profile"], + Company: ["home", "connections", "profile"], + Member: ["home", "schedule", "companies", "profile"], + Admin: ["home", "schedule", "companies", "profile"], +}; + +export default async function BottomNavbar() { + const session = await getServerSession(authOptions); + + const user: User | null = await UserService.getMe(session!.cannonToken); + if (!user) return <>; + + return ( +
+
+ {navbarItemKeysByRole[convertToAppRole(user.role)].map((k) => ( + + ))} + +
+
+ ); +} diff --git a/src/components/EventDayButton.tsx b/src/components/EventDayButton.tsx new file mode 100644 index 0000000..ece8322 --- /dev/null +++ b/src/components/EventDayButton.tsx @@ -0,0 +1,41 @@ +import { + getEventDay, + getEventFullDate, + getEventMonth, + getEventWeekday, +} from "@/utils/utils"; + +interface EventDayButtonProps { + date: string; + onClick?: () => void; + selected?: boolean; + disabled?: boolean; +} + +export default function EventDayButton({ + date, + onClick, + selected = false, + disabled = false, +}: EventDayButtonProps) { + return ( + + ); +} diff --git a/src/components/GridCard.tsx b/src/components/GridCard.tsx new file mode 100644 index 0000000..3144494 --- /dev/null +++ b/src/components/GridCard.tsx @@ -0,0 +1,57 @@ +import Image from "next/image"; +import Link, { LinkProps } from "next/link"; + +interface GridCardProps { + title: string; + img: string; + imgAltText?: string; + extraImage?: string; + extraImageAltText?: string; + label?: string; + link?: string; + linkProps?: LinkProps; +} + +export default function GridCard({ + title, + img, + imgAltText = "No alt text.", + extraImage, + extraImageAltText = "No alt text.", + label, + link, + linkProps, +}: GridCardProps) { + return ( + +
+ + {title} + + {imgAltText} +
+ {extraImage && ( + {extraImageAltText} + )} + {label && ( + + {label} + + )} +
+
+ + ); +} diff --git a/src/components/GridList.tsx b/src/components/GridList.tsx new file mode 100644 index 0000000..ead2183 --- /dev/null +++ b/src/components/GridList.tsx @@ -0,0 +1,23 @@ +import List, { ListProps } from "@/components/List"; + +interface GridListProps extends ListProps { + scrollable?: boolean; + className?: string; +} + +export default function GridList({ + children, + scrollable, + className = ``, + ...props +}: GridListProps) { + return ( + +
+ {children} +
+
+ ); +} diff --git a/src/components/List.tsx b/src/components/List.tsx new file mode 100644 index 0000000..412d762 --- /dev/null +++ b/src/components/List.tsx @@ -0,0 +1,55 @@ +import Link, { LinkProps } from "next/link"; + +export interface ListProps { + id?: string; + title?: string; + description?: string; + link?: string; + linkText?: string; + linkProps?: Partial; + bottomLink?: string; + bottomLinkText?: string; + bottomLinkProps?: LinkProps; + children?: React.ReactNode; +} + +export default function List({ + id, + title, + description, + link, + linkText, + linkProps, + bottomLink, + bottomLinkText, + bottomLinkProps, + children, +}: ListProps) { + return ( +
+
+
+ {title && {title}} + {description && ( + {description} + )} +
+ {linkText && ( + + {linkText} + + )} +
+
{children}
+ {bottomLinkText && ( + + {bottomLinkText} + + )} +
+ ); +} diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx new file mode 100644 index 0000000..bd3ab74 --- /dev/null +++ b/src/components/ListCard.tsx @@ -0,0 +1,97 @@ +import { LucideIcon, LucideProps } from "lucide-react"; +import Image, { ImageProps } from "next/image"; +import Link, { LinkProps } from "next/link"; +import { ReactNode } from "react"; + +interface ListCardProps { + img?: string; + imgAltText?: string; + imgProps?: ImageProps; + icon?: LucideIcon; + iconProps?: LucideProps; + title: string; + subtitle?: string; + headtext?: string; + label?: string; + labelColor?: string; + link?: string; + linkProps?: LinkProps; + extraClassName?: string; + extraComponent?: ReactNode; +} + +interface ConditionalLinkProps extends Partial { + children: ReactNode; +} + +function ConditionalLink({ children, href, ...props }: ConditionalLinkProps) { + if (!href) return children; + return ( + + {children} + + ); +} + +export default function ListCard({ + title, + img, + imgAltText = "No alt text.", + icon: Icon, + subtitle, + headtext, + label, + labelColor, + link, + extraComponent, + imgProps, + iconProps, + linkProps, + extraClassName, +}: ListCardProps) { + return ( + +
+ {img && ( + {imgAltText} + )} + {Icon && } +
+
+ {headtext && ( + + {headtext} + + )} + {label && ( + + {label} + + )} +
+ + {title} + + {subtitle && ( + + {subtitle} + + )} +
+ {extraComponent} +
+
+ ); +} diff --git a/src/components/MessageCard.tsx b/src/components/MessageCard.tsx new file mode 100644 index 0000000..98b2d40 --- /dev/null +++ b/src/components/MessageCard.tsx @@ -0,0 +1,62 @@ +import { + Check, + Info, + LucideIcon, + OctagonAlert, + TriangleAlert, +} from "lucide-react"; + +type MessageType = "success" | "info" | "warning" | "danger"; + +interface MessageCardProps { + type: MessageType; + title?: string; + content?: string; + onClick?: () => void; +} + +interface MessageCustomization { + class: string; + icon: LucideIcon; +} + +const customizationByType: Record = { + success: { + class: "border-green-500 text-green-500", + icon: Check, + }, + info: { + class: "border-blue-500 text-blue-500", + icon: Info, + }, + warning: { + class: "border-yellow-500 text-yellow-500", + icon: TriangleAlert, + }, + danger: { + class: "border-red-500 text-red-500", + icon: OctagonAlert, + }, +}; + +export default function MessageCard({ + type, + title, + content, + onClick, +}: MessageCardProps) { + const Icon = customizationByType[type].icon; + + return ( +
+ +
+ {title && {title}} + {content &&

{content}

} +
+
+ ); +} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..bb59739 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,34 @@ +import { X } from "lucide-react"; +import { ReactNode } from "react"; + +interface ModalProps { + children?: ReactNode; + title?: string; + open: boolean; + onClose: () => void; +} + +export default function ModalElement({ + children, + title, + open, + onClose, +}: ModalProps) { + if (!open) return <>; + + return ( +
+
+
+
+ {title &&

{title}

} +
+ +
+
{children}
+
+
+ ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx deleted file mode 100644 index 68f80c2..0000000 --- a/src/components/Navbar.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { signOut, useSession } from "next-auth/react"; -import { Image } from "next/dist/client/image-component"; - -import exitIcon from "@/assets/icons/exit.png"; -import menuIcon from "@/assets/icons/menu.png"; - -export default function Navbar() { - const { status } = useSession(); - - async function handleExit() { - await signOut(); - } - - return ( - - ); -} diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 0000000..b4c34b0 --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -0,0 +1,33 @@ +interface ProgressBarProps { + title: string; + current: number; + maximum: number; + className?: string; + children?: React.ReactNode; +} + +export default function ProgressBar({ + title, + current, + maximum, + className = "", + children, +}: ProgressBarProps) { + return ( +
+
+ {title} + {`${current} / ${maximum}`} +
+
+
+
+ {children} +
+ ); +} diff --git a/src/components/QRCodeScanner.tsx b/src/components/QRCodeScanner.tsx new file mode 100644 index 0000000..06ee7a6 --- /dev/null +++ b/src/components/QRCodeScanner.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { ReactNode, useEffect, useRef, useState } from "react"; +import QrScanner from "qr-scanner"; + +interface QRCodeScannerProps { + onQRCodeScanned(qrCode: string): void; + topCard?: ReactNode; + bottomCard?: ReactNode; +} + +const TIMEOUT_SCAN: number = 2000; // Number of miliseconds before read next QR-Code + +export default function QRCodeScanner({ + onQRCodeScanned, + topCard, + bottomCard, +}: QRCodeScannerProps) { + const scannerRef = useRef(); + const videoRef = useRef(null); + const [failedToRender, setFailedToRender] = useState(false); + const scanning = useRef(true); + + useEffect(() => { + if (videoRef.current && !scannerRef.current) { + scannerRef.current = new QrScanner( + videoRef.current, + (result) => { + if (result.data && scanning.current) { + scanning.current = false; + setTimeout(() => (scanning.current = true), TIMEOUT_SCAN); + onQRCodeScanned(result.data); + } + }, + { + preferredCamera: "environment", + highlightScanRegion: true, + }, + ); + + scannerRef.current + .start() + .then(() => { + console.log("QR Code scanner started"); + }) + .catch((error) => { + console.error("QR Code scanner failed to start", error); + setFailedToRender(true); + }); + } + + return () => { + if (!videoRef.current) scannerRef.current?.stop(); + }; + }, [scanning, onQRCodeScanned]); + + if (failedToRender) { + return ( +
+

Failed to render scanner! Please refresh the page and try again.

+ +
+ ); + } + + return ( +
+ {topCard && ( +
{topCard}
+ )} +
+ ); +} diff --git a/src/components/ShowMore.tsx b/src/components/ShowMore.tsx new file mode 100644 index 0000000..7f36959 --- /dev/null +++ b/src/components/ShowMore.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { ReactNode, useEffect, useRef, useState } from "react"; +import { useWindowSize } from "react-use"; + +interface ShowMoreProps { + lines: 1 | 2 | 3 | 4 | 5 | 6; + children: ReactNode; + className?: string; +} + +const lineClamps = [ + "line-clamp-1", + "line-clamp-2", + "line-clamp-3", + "line-clamp-4", + "line-clamp-5", + "line-clamp-6", +]; + +export function ShowMore({ lines, children, className }: ShowMoreProps) { + const textRef = useRef(null); + const [showButton, setShowButton] = useState(true); + const [showMore, setShowMore] = useState(false); + const { width: windowWidth } = useWindowSize(); + + useEffect(() => { + setShowButton( + (textRef.current?.clientHeight ?? 0) < + (textRef.current?.scrollHeight ?? 1), + ); + }, [lines, windowWidth]); + + return ( + +

+ {children} +

+ {showButton && ( + + )} +
+ ); +} diff --git a/src/components/SocialNetwork.tsx b/src/components/SocialNetwork.tsx new file mode 100644 index 0000000..90cdf3a --- /dev/null +++ b/src/components/SocialNetwork.tsx @@ -0,0 +1,49 @@ +import { SocialIcon, SocialIconProps } from "react-social-icons"; + +type SocialNetworkType = + | "linkedin" + | "linkedinCompany" + | "email" + | "github" + | "website"; + +interface SocialNetworkProps { + text: string; + type: SocialNetworkType; +} + +type SocialNetwork = { + baseHref: string; + extraProps?: SocialIconProps; + transform?(t: string, baseHref: string): string; +}; + +const socialNetworks: Record = { + linkedin: { baseHref: "https://linkedin.com/in/" }, + linkedinCompany: { baseHref: "https://linkdein.com/company/" }, + email: { baseHref: "mailto:" }, + github: { baseHref: "https://github.com/" }, + website: { + baseHref: "", + transform: (text) => { + if (!text.startsWith("http")) return `https://${text}`; + return text; + }, + extraProps: { bgColor: "#323363" }, + }, +}; + +export function SocialNetwork({ text, type }: SocialNetworkProps) { + const socialNetwork = socialNetworks[type]; + const url = socialNetwork.transform + ? socialNetwork.transform(text, socialNetwork.baseHref) + : `${socialNetwork.baseHref}${text}`; + return ( + + ); +} diff --git a/src/components/Toolbar/Sidebar.tsx b/src/components/Toolbar/Sidebar.tsx new file mode 100644 index 0000000..d4d26ba --- /dev/null +++ b/src/components/Toolbar/Sidebar.tsx @@ -0,0 +1,99 @@ +import { ExternalLink, LogOut, X } from "lucide-react"; +import { signOut } from "next-auth/react"; +import Link from "next/link"; + +interface SidebarProps { + show: boolean; + onClose: () => void; +} + +export default function Sidebar({ show, onClose }: SidebarProps) { + async function handleLogout() { + await signOut(); + } + + return ( +
+
+ +
+ + {/* Primary Options (grouped) */} +
+
+ + Profile + + + Connections + +
+
+ + Keynotes + + + Presentations + + + Workshops + +
+
+ + Speakers + + + Companies + + + Venue + +
+
+
+
+ + {/* Secondary Options */} +
+ + Website + + + + Report a Bug + + + + Code of Conduct + + + +
+
+ ); +} diff --git a/src/components/Toolbar/index.tsx b/src/components/Toolbar/index.tsx new file mode 100644 index 0000000..ed27c0e --- /dev/null +++ b/src/components/Toolbar/index.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Image } from "next/dist/client/image-component"; +import { sinfoLogo } from "@/assets/images"; +import { useSession } from "next-auth/react"; +import { usePathname, useRouter } from "next/navigation"; +import { ArrowLeft, Menu, RefreshCcw } from "lucide-react"; +import { useState } from "react"; +import Sidebar from "./Sidebar"; + +export default function Toolbar() { + const { data: session } = useSession(); + const router = useRouter(); + const currPath = usePathname(); + + const [isExpanded, setIsExpanded] = useState(false); + + const showMenu = ["/home", "/profile"].includes(currPath); + + return ( + <> + setIsExpanded(false)} /> +
+
+ + {currPath === "/home" && session?.user?.name && ( +
+ Welcome,{" "} + {session.user.name}! +
+ )} +
+
+ + ); +} diff --git a/src/components/UserSignOut.tsx b/src/components/UserSignOut.tsx index 0966abb..4dc9a7c 100644 --- a/src/components/UserSignOut.tsx +++ b/src/components/UserSignOut.tsx @@ -1,11 +1,11 @@ -"use client"; - -import { signOut } from "next-auth/react"; -import { useEffect } from "react"; - -export default function UserSignOut() { - useEffect(() => { - signOut(); - }); - return <>; -} +"use client"; + +import { signOut } from "next-auth/react"; +import { useEffect } from "react"; + +export default function UserSignOut() { + useEffect(() => { + signOut(); + }); + return <>; +} diff --git a/src/components/company/CompanyTile.tsx b/src/components/company/CompanyTile.tsx new file mode 100644 index 0000000..018de87 --- /dev/null +++ b/src/components/company/CompanyTile.tsx @@ -0,0 +1,18 @@ +import GridCard from "@/components/GridCard"; +import { isHereToday } from "@/utils/company"; + +interface CompanyTileProps { + company: Company; +} + +export function CompanyTile({ company }: CompanyTileProps) { + return ( + + ); +} diff --git a/src/components/company/index.ts b/src/components/company/index.ts new file mode 100644 index 0000000..57844a2 --- /dev/null +++ b/src/components/company/index.ts @@ -0,0 +1 @@ +export * from "./CompanyTile"; diff --git a/src/components/prize/PrizeTile.tsx b/src/components/prize/PrizeTile.tsx new file mode 100644 index 0000000..ecffca6 --- /dev/null +++ b/src/components/prize/PrizeTile.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { UserService } from "@/services/UserService"; +import { Mail, Trophy } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useState } from "react"; +import Confetti from "react-confetti"; +import { useWindowSize } from "react-use"; + +type PrizeParticipant = { + userId: string; + entries?: number; +}; + +interface PrizeTileProps { + prize: Prize; + participants?: PrizeParticipant[]; + pickWinner?: boolean; + cannonToken?: string; +} + +export function PrizeTile({ + prize, + participants, + cannonToken, + pickWinner = false, +}: PrizeTileProps) { + const [winner, setWinner] = useState(); + const { width: windowWidth, height: windowHeight } = useWindowSize(); + + async function handlePickWinner() { + if (participants?.length && cannonToken) { + // All the participants should at least have one entry + const totalEntries = participants.reduce( + (acc, p) => acc + (p.entries || 1), + 0, + ); + let selectedEntry = Math.floor(Math.random() * totalEntries); + + // This function finds the winner by summing the entries + // of each participant until the random number belongs + // to the selected participant. + const winner = participants.find((p) => { + const participantEntries = p.entries || 1; + selectedEntry -= participantEntries; + return selectedEntry < 0; + })!; + + const user = await UserService.getUser(cannonToken, winner.userId); + if (!user) { + console.error("Failed to get prize winner", winner.userId); + return; + } + + setWinner(user); + } + } + + return ( +
+ {prize.name} + {`${prize.name} + {winner && ( +
+ + {winner.name} + {winner.name} + + + Send email + +
+ )} + {pickWinner && participants && !winner && ( + <> + + + Participants available to the prize: {participants.length} + + + )} +
+ ); +} diff --git a/src/components/prize/index.ts b/src/components/prize/index.ts new file mode 100644 index 0000000..54b680c --- /dev/null +++ b/src/components/prize/index.ts @@ -0,0 +1 @@ +export * from "./PrizeTile"; diff --git a/src/components/session/AddToCalendarButton.tsx b/src/components/session/AddToCalendarButton.tsx new file mode 100644 index 0000000..996b151 --- /dev/null +++ b/src/components/session/AddToCalendarButton.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { ArrowDown, ArrowUp, Calendar } from "lucide-react"; +import { useState } from "react"; +import { CalendarButton, CalendarButtonProps } from "./CalendarButton"; + +interface AddToCalendarButton extends Omit {} + +export default function AddToCalendarButton({ ...props }: AddToCalendarButton) { + const [isOpen, setOpen] = useState(false); + + return ( +
+
+ +
+ +
+
+ + +
+
+
+ ); +} diff --git a/src/components/session/CalendarButton.tsx b/src/components/session/CalendarButton.tsx new file mode 100644 index 0000000..be8a179 --- /dev/null +++ b/src/components/session/CalendarButton.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { appleCalendarIcon, googleCalendarIcon } from "@/assets/icons"; +import moment from "moment"; +import Link from "next/link"; +import Image from "next/image"; +import { useEffect, useState } from "react"; + +export interface CalendarButtonProps { + name: string; + type: "google" | "apple"; + startDate: string; + duration: number; // in minutes + location: string; + description?: string; +} + +function formatTime(date: string): string { + return moment.utc(date).format("YYYYMMDDTHHmmssZ").replace("+00:00", "Z"); +} + +function formatDate(date: string, duration: number): string { + const startDate = formatTime(date); + const endDate = formatTime( + moment.utc(date).add(duration, "minutes").toISOString(), + ); + return `${startDate}%2F${endDate}`; +} + +export function CalendarButton({ + name, + type, + startDate, + duration, + location, + description, +}: CalendarButtonProps) { + const [URL, setURL] = useState(); + + useEffect(() => { + let ICSFile = "BEGIN:VCALENDAR"; + ICSFile += "\nVERSION:2.0"; + ICSFile += "\nBEGIN:VEVENT"; + ICSFile += `\nURL:${window.location}`; + ICSFile += `\nDESCRIPTION:${description}`; + ICSFile += `\nDTSTART:${formatTime(startDate)}`; + ICSFile += `\nDTEND:${formatTime(moment.utc(startDate).add(duration, "minutes").toISOString())}`; + ICSFile += `\nSUMMARY:${name}`; + ICSFile += `\nLOCATION:${location}`; + ICSFile += "\nEND:VEVENT"; + ICSFile += "\nEND:VCALENDAR"; + + setURL( + { + google: `https://calendar.google.com/calendar/render?action=TEMPLATE&dates=${formatDate(startDate, duration)}&details=${encodeURIComponent(description ?? "")}&location=${encodeURIComponent(location)}&text=${encodeURIComponent(name)}`, + apple: "data:text/calendar;charset=utf8," + encodeURIComponent(ICSFile), + }[type], + ); + }, [name, type, startDate, duration, location, description]); + + if (!URL) return <>; + + return ( + + {type === "google" && ( + + Google Calendar Icon + Google Calendar + + )} + {type === "apple" && ( + + Apple Calendar Icon + ICS File (Apple) + + )} + + ); +} diff --git a/src/components/session/SessionTile.tsx b/src/components/session/SessionTile.tsx new file mode 100644 index 0000000..36c2a3d --- /dev/null +++ b/src/components/session/SessionTile.tsx @@ -0,0 +1,39 @@ +import ListCard from "@/components/ListCard"; +import { generateTimeInterval, getSessionColor } from "@/utils/utils"; + +interface SesionTileProps { + session: SINFOSession; + onlyShowHours?: boolean; +} + +export function SessionTile({ + session, + onlyShowHours = false, +}: SesionTileProps) { + const speakersNames = session.speakers + ?.map((s) => s.name) + .sort() + .join(", "); + + const startDate = new Date(session.date); + const endDate = new Date(startDate.getTime() + session.duration * 60000); + + const pastSession = new Date() > endDate; + + return ( + + ); +} diff --git a/src/components/session/index.ts b/src/components/session/index.ts new file mode 100644 index 0000000..75d6029 --- /dev/null +++ b/src/components/session/index.ts @@ -0,0 +1 @@ +export * from "./SessionTile"; diff --git a/src/components/speaker/SpeakerTile.tsx b/src/components/speaker/SpeakerTile.tsx new file mode 100644 index 0000000..b6bc2c9 --- /dev/null +++ b/src/components/speaker/SpeakerTile.tsx @@ -0,0 +1,17 @@ +import GridCard from "@/components/GridCard"; + +interface SpeakerTileProps { + speaker: Speaker; +} + +export function SpeakerTile({ speaker }: SpeakerTileProps) { + return ( + + ); +} diff --git a/src/components/speaker/index.ts b/src/components/speaker/index.ts new file mode 100644 index 0000000..6544028 --- /dev/null +++ b/src/components/speaker/index.ts @@ -0,0 +1 @@ +export * from "./SpeakerTile"; diff --git a/src/components/svg/ZoomableSvg.tsx b/src/components/svg/ZoomableSvg.tsx new file mode 100644 index 0000000..ea69c46 --- /dev/null +++ b/src/components/svg/ZoomableSvg.tsx @@ -0,0 +1,154 @@ +import React, { useState, useRef, ReactNode, useEffect } from "react"; + +interface ZoomableWrapperProps { + children: ReactNode; + minZoom?: number; + maxZoom?: number; + className?: string; + resetViewButton?: boolean; +} + +const ZoomableWrapper: React.FC = ({ + children, + minZoom = 0.5, + maxZoom = 5, + className = "w-full h-96", + resetViewButton = false, +}) => { + const [scale, setScale] = 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(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = container.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const scaleFactor = e.deltaY > 0 ? 0.9 : 1.1; + const newScale = Math.min( + Math.max(scale * scaleFactor, minZoom), + maxZoom + ); + + // Adjust position to zoom towards cursor + const dx = x - position.x; + const dy = y - position.y; + const newX = position.x - dx * (scaleFactor - 1); + const newY = position.y - dy * (scaleFactor - 1); + + setScale(newScale); + setPosition({ x: newX, y: newY }); + }; + + container.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + container.removeEventListener("wheel", handleWheel); + }; + }, [scale, position, minZoom, maxZoom]); + + const handleMouseDown = (e: React.MouseEvent) => { + if (e.button === 0) { + // Left click only + setIsDragging(true); + setDragStart({ + x: e.clientX - position.x, + y: e.clientY - position.y, + }); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (isDragging) { + const newX = e.clientX - dragStart.x; + const newY = e.clientY - dragStart.y; + + // Optional: Add bounds checking here if needed + setPosition({ x: newX, y: newY }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const zoomIn = () => { + setScale((prevScale) => { + const newScale = Math.min(prevScale * 1.2, maxZoom); + return newScale; + }); + }; + + const zoomOut = () => { + setScale((prevScale) => { + const newScale = Math.max(prevScale * 0.8, minZoom); + return newScale; + }); + }; + + const resetView = () => { + setScale(1); + setPosition({ x: 0, y: 0 }); + }; + + return ( +
+ {/* Zoom controls */} +
+ + + {resetViewButton && ( + + )} +
+ + {/* SVG Container */} +
+
+ {children} +
+
+
+ ); +}; + +export default ZoomableWrapper; diff --git a/src/components/svg/index.ts b/src/components/svg/index.ts new file mode 100644 index 0000000..989f4e3 --- /dev/null +++ b/src/components/svg/index.ts @@ -0,0 +1 @@ +export * from "./ZoomableSvg"; diff --git a/src/components/user/AcademicTile.tsx b/src/components/user/AcademicTile.tsx new file mode 100644 index 0000000..0f32ad7 --- /dev/null +++ b/src/components/user/AcademicTile.tsx @@ -0,0 +1,34 @@ +import ListCard from "../ListCard"; + +interface AcademicTileProps { + school: string; + degree: string; + field: string; + grade?: string; + start: string; + end: string; +} + +export default function AcademicTile({ + school, + degree, + field, + start, + end, +}: AcademicTileProps) { + const startDate = new Date(start); + const endDate = new Date(end); + + function formatDate(date: Date) { + return date.toLocaleDateString("en-GB", { + month: "2-digit", + year: "numeric", + }); + } + + const label = `${formatDate(startDate)} - ${formatDate(endDate)}`; + + return ( + + ); +} diff --git a/src/components/user/AchievementTile.tsx b/src/components/user/AchievementTile.tsx new file mode 100644 index 0000000..91b0ff3 --- /dev/null +++ b/src/components/user/AchievementTile.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Modal from "@/components/Modal"; +import Image from "next/image"; +import { useState } from "react"; + +interface AchievementTileProps { + achievement: Achievement; + achieved?: boolean; +} + +export default function AchievementTile({ + achievement, + achieved = false, +}: AchievementTileProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> +
setIsOpen(true)} + > + {`${achievement.name} +
+ {/* Modal Information */} + setIsOpen(false)}> +
+ {`${achievement.name} + {achievement.name} + {achievement.description && ( +

{achievement.description}

+ )} + Points: {achievement.value} + + {achievement.users?.length ?? 0} users have this achievement + +
+
+ + ); +} diff --git a/src/components/user/ConnectionTile.tsx b/src/components/user/ConnectionTile.tsx new file mode 100644 index 0000000..c9fee24 --- /dev/null +++ b/src/components/user/ConnectionTile.tsx @@ -0,0 +1,22 @@ +import authOptions from "@/app/api/auth/[...nextauth]/authOptions"; +import { UserService } from "@/services/UserService"; +import { getServerSession } from "next-auth"; +import { UserTile } from "./UserTile"; + +interface ConnectionTileProps { + connection: Connection; +} + +export default async function ConnectionTile({ + connection, +}: ConnectionTileProps) { + const session = (await getServerSession(authOptions))!; + + const connectedUser = await UserService.getUser( + session.cannonToken, + connection.to, + ); + + if (!connectedUser) return <>; + return ; +} diff --git a/src/components/user/CurriculumVitae.tsx b/src/components/user/CurriculumVitae.tsx new file mode 100644 index 0000000..fbff76d --- /dev/null +++ b/src/components/user/CurriculumVitae.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { UserService } from "@/services/UserService"; +import { Download, FileUser, Trash, Upload } from "lucide-react"; +import { Session } from "next-auth"; +import Link from "next/link"; +import { ChangeEvent, useEffect, useMemo, useState } from "react"; +import MessageCard from "../MessageCard"; +import { useRouter } from "next/navigation"; + +interface CurriculumVitaeProps { + user: User; + session: Session; + currentUser?: boolean; +} + +export default function CurriculumVitae({ + user, + session, + currentUser = false, +}: CurriculumVitaeProps) { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [file, setFile] = useState(null); + const [downloadURL, setDownloadURL] = useState(null); + + const getCV = useMemo( + () => + async function getCV() { + const cvInfo = await UserService.getCVInfo( + session.cannonToken, + currentUser ? undefined : user.id + ); + setFile( + cvInfo && Object.keys(cvInfo).length > 0 + ? (cvInfo as SINFOFile) + : null + ); + if (loading) setLoading(false); + }, + [session.cannonToken, user.id, currentUser, loading] + ); + + const getDownloadURL = useMemo( + () => + async function getDownloadURL() { + if (file) { + const url = await UserService.getDownloadURL( + session.cannonToken, + currentUser ? undefined : file.id + ); + setDownloadURL(url); + } else { + setDownloadURL(null); + } + }, + [session.cannonToken, currentUser, file] + ); + + useEffect(() => { + getDownloadURL(); + }, [getDownloadURL, file]); + + useEffect(() => { + getCV(); + }, [getCV, user, session, currentUser]); + + async function handleUploadCV(e: ChangeEvent) { + if (!e.target.files?.length) return; + setLoading(true); + await UserService.uploadCV(session.cannonToken, e.target.files[0]); + await getCV(); + } + + async function handleDeleteCV() { + await UserService.deleteCV(session.cannonToken); + setFile(null); + } + + if (loading) { + return ( +
+
+
+
+ ); + } + + // If there is no file and is not supo + if (!file && !currentUser) { + return ( +
+ + No CV has been uploaded by this user +
+ ); + } + + if (file) { + return ( +
+ {downloadURL && ( + + + Download + + )} + {currentUser && ( + + )} + + Updated at: {new Date(file.updated).toLocaleString()} + +
+ ); + } + + return ( + <> + router.push("/terms-and-conditions/cv")} + /> +
+ + + +
+ + Upload +
+
+ + ); +} diff --git a/src/components/user/Notes.tsx b/src/components/user/Notes.tsx new file mode 100644 index 0000000..6a3946b --- /dev/null +++ b/src/components/user/Notes.tsx @@ -0,0 +1,34 @@ +"use client"; + +import List from "@/components/List"; +import { Save } from "lucide-react"; +import { useRef } from "react"; + +interface NotesProps { + notes?: string; + onNotesUpdate(notes: string): Promise; +} + +export default function Notes({ notes, onNotesUpdate }: NotesProps) { + const inputRef = useRef(null); + + return ( + +