Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added an attendance page #66

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/app/attendees/conference-selection.tsx
@@ -0,0 +1,33 @@
import { type FC } from "react"

import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { type ConferencePreview } from "@/contracts/conference/conference"
import { preventDefaultOnReference } from "@/lib/events/prevent-default-on-reference"

export type ConferenceSelectionProps = {
conferences: ConferencePreview[]
onSelect: (conferenceId: string) => void
}

export const ConferenceSelection: FC<ConferenceSelectionProps> = (props) => {
return (
<Select onValueChange={props.onSelect}>
<SelectTrigger>
<SelectValue placeholder={"Conferences you have tickets for"} />
</SelectTrigger>
<SelectContent ref={preventDefaultOnReference}>
{props.conferences.map((conference) => (
<SelectItem key={conference.id} value={conference.id}>
{conference.name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
38 changes: 38 additions & 0 deletions src/app/attendees/page.tsx
@@ -0,0 +1,38 @@
"use client"

import { useState } from "react"

import { ConferenceSelection } from "@/app/attendees/conference-selection"
import { ProfileList } from "@/app/attendees/profile-list.component"
import { NoticeText } from "@/components/ui/messages/notice-text"
import { PageTitle } from "@/components/ui/page-title"
import { api } from "@/trpc/react"

export default function AttendeesPage() {
const apiFetchAttendingConferences = api.attendance.myConferences.useQuery()

const [selectedConferenceId, setSelectedConferenceId] = useState<string>("")

return (
<div>
<PageTitle
title={"Attendees"}
subtitle={
"A list of all the people who have tickets for the selected conference"
}
/>
<div className={"mb-5"}>
<ConferenceSelection
conferences={apiFetchAttendingConferences.data ?? []}
onSelect={setSelectedConferenceId}
/>
</div>

{selectedConferenceId ? (
<ProfileList conferenceId={selectedConferenceId} />
) : (
<NoticeText text={"Select a conference to view attendees"} />
)}
</div>
)
}
30 changes: 30 additions & 0 deletions src/app/attendees/profile-list-item.component.tsx
@@ -0,0 +1,30 @@
import Link from "next/link"
import React, { type FC } from "react"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { type UserProfile } from "@/contracts/profile/user-profile"

export type ProfileListItemProps = {
profile: UserProfile
}

export const ProfileListItem: FC<ProfileListItemProps> = ({ profile }) => {
return (
<Link href={`/profiles/${profile.id}`}>
<li
key={profile.id}
className={"flex items-center space-x-3 border-b border-accent py-3"}>
<Avatar className={"h-12 w-12 flex-grow-0 rounded-full"}>
<AvatarImage src={profile.avatarUrl} alt={profile.displayName} />
<AvatarFallback className={"rounded"}>
{profile.initials}
</AvatarFallback>
</Avatar>
<div className={"flex-1"}>
<p className={"font-bold"}>{profile.displayName}</p>
<p className={"font-thin"}>{profile.title || "No Title"}</p>
</div>
</li>
</Link>
)
}
@@ -1,10 +1,8 @@
"use client"
import React, { type FC, useMemo } from "react"

import React, { useMemo } from "react"

import { ProfileList } from "@/app/profiles/profile-list.component"
import { ProfileListItem } from "@/app/attendees/profile-list-item.component"
import { SkeletonList } from "@/components/molecules/skeletons/skeleton-list"
import { PageTitle } from "@/components/ui/page-title"
import { MissingText } from "@/components/ui/messages/missing-text"
import {
Pagination,
PaginationContent,
Expand All @@ -16,15 +14,24 @@ import { api } from "@/trpc/react"

const PAGE_SIZE = 8

export default function ProfilePage() {
export type ProfileListProps = {
conferenceId: string
}

export const ProfileList: FC<ProfileListProps> = ({ conferenceId }) => {
const pager = usePagination()

const profileCountRequest = api.profile.count.useQuery()

const profilesRequest = api.profile.page.useQuery({
page: pager.page,
pageSize: PAGE_SIZE,
})
const profilesRequest = api.attendance.page.useQuery(
{
page: pager.page,
pageSize: PAGE_SIZE,
conferenceId,
},
{
enabled: !!conferenceId,
},
)

const pageNumbers = useMemo(() => {
if (!profileCountRequest.data) {
Expand All @@ -40,22 +47,30 @@ export default function ProfilePage() {
return [...previousPages, pager.page, ...nextPages]
}, [profileCountRequest.data, pager.page])

const profiles = useMemo(
() => profilesRequest.data?.items ?? [],
[profilesRequest.data],
)

// todo: encapsulate this into a trpc fetching component
if (profilesRequest.isLoading) {
return <SkeletonList count={PAGE_SIZE} />
}
if (profilesRequest.error) {
return <p>Failed to load profiles</p>
}

if (profiles.length < 1) {
return <MissingText text={"No profiles found"} />
}

return (
<div>
<PageTitle
title={"Participants"}
subtitle={
"A list of all the people who are partaking in this conference"
}
/>
{
// todo: create a trpc loading component (with built in error displaying e.t.c)
profilesRequest.isLoading ? (
<SkeletonList count={PAGE_SIZE} />
) : (
<ProfileList profiles={profilesRequest.data?.items ?? []} />
)
}
<ul className={"space-y-2"}>
{profiles.map((profile) => {
return <ProfileListItem key={profile.id} profile={profile} />
})}
</ul>
<div
className={
"absolute bottom-6 left-4 right-4 rounded-full bg-background"
Expand Down
46 changes: 0 additions & 46 deletions src/app/profiles/profile-list.component.tsx

This file was deleted.

16 changes: 15 additions & 1 deletion src/components/sidebar.tsx
@@ -1,4 +1,11 @@
import { BookmarkCheck, BookOpen, Home, Ticket, User } from "lucide-react"
import {
BookmarkCheck,
BookOpen,
Home,
Ticket,
User,
Users,
} from "lucide-react"
import Image from "next/image"
import Link from "next/link"

Expand Down Expand Up @@ -50,6 +57,13 @@ const Sidebar = ({ isSidebarOpen, setIsSidebarOpen }: SidebarProps) => {
superuserRequired: false,
icon: Ticket,
},
{
href: "/attendees",
title: "Attendees",
label: "",
superuserRequired: false,
icon: Users,
},
{
href: "/conferences",
title: "Conferences",
Expand Down
19 changes: 19 additions & 0 deletions src/components/ui/messages/notice-text.tsx
@@ -0,0 +1,19 @@
import { cn } from "@/lib/utils"

export type MissingTextProps = {
text: string
className?: string
}

/**
* Represents a component for displaying a default missing text with a sad emoji.
* @param text - The text to display.
* @param className - An optional class name to apply to the component.
*/
export const NoticeText = ({ text, className }: MissingTextProps) => {
return (
<div className={cn("text-center font-light", className)}>
<p>{text}</p>
</div>
)
}
7 changes: 7 additions & 0 deletions src/contracts/conference/conference.ts
Expand Up @@ -14,6 +14,11 @@ export const ConferenceProfileDto = z.object({
stripeId: z.string(),
})

export const ConferencePreviewDto = ConferenceProfileDto.pick({
id: true,
name: true,
})

export const CreateConferenceInputDto = ConferenceProfileDto.pick({
name: true,
description: true,
Expand All @@ -34,6 +39,8 @@ export const UpdateConferenceInputDto = ConferenceProfileDto.partial()
// Types
export type ConferenceProfile = z.infer<typeof ConferenceProfileDto>

export type ConferencePreview = z.infer<typeof ConferencePreviewDto>

export type CreateConferenceInput = z.infer<typeof CreateConferenceInputDto>

export type UpdateConferenceInput = z.infer<typeof UpdateConferenceInputDto>
21 changes: 21 additions & 0 deletions src/lib/events/prevent-default-on-reference.ts
@@ -0,0 +1,21 @@
/**
* Prevents default behavior on touchend and click events for the given HTML element reference.
*
* @param ref - The HTML element reference on which to prevent default behavior.
* @returns The modified HTML element reference with default behavior prevention on touchend and click events.
* @link https://github.com/radix-ui/primitives/issues/1658#issuecomment-1690666012
*/
export const preventDefaultOnReference = (ref: HTMLElement | null) => {
if (!ref) {
return
}

ref.ontouchend = (e) => {
e.preventDefault()
}
ref.onclick = (e) => {
e.preventDefault()
}

return ref
}
12 changes: 12 additions & 0 deletions src/lib/events/prevent-default.ts
@@ -0,0 +1,12 @@
import { type FormEvent, type TouchEvent } from "react"

/**
* A higher-order function that prevents the default behavior of the event and then calls the provided method.
* @param method - The method to call after preventing default behavior.
* @returns A function that takes an event and prevents its default behavior before calling the method.
*/
export const preventDefault =
(method: () => void) => (e: FormEvent | TouchEvent | MouseEvent) => {
e.preventDefault()
method()
}
12 changes: 12 additions & 0 deletions src/lib/events/stop-propagation.ts
@@ -0,0 +1,12 @@
import { type MouseEvent } from "react"

/**
* Stops the propagation of the event and executes the provided method.
* @param method - The method to execute.
* @returns A function that stops event propagation and executes the provided method.
*/
export const stopPropagation =
(method: () => void) => (e: MouseEvent | TouchEvent) => {
e.stopPropagation()
method()
}
2 changes: 2 additions & 0 deletions src/server/api/root.ts
@@ -1,3 +1,4 @@
import { attendanceRouter } from "@/server/api/routers/attendance/attendance.router"
import { companyRouter } from "@/server/api/routers/company/company.router"
import { conferenceRouter } from "@/server/api/routers/conference/conference.router"
import { profileRouter } from "@/server/api/routers/profile/profile.router"
Expand All @@ -16,6 +17,7 @@ export const appRouter = createTRPCRouter({
user: userRouter,
profile: profileRouter,
ticket: ticketRouter,
attendance: attendanceRouter,
})

// export type definition of API
Expand Down
@@ -0,0 +1,24 @@
import { desc, eq } from "drizzle-orm"

import { type ConferencePreview } from "@/contracts/conference/conference"
import { db } from "@/database"
import { conferences, tickets } from "@/database/schemas"
import { protectedProcedure } from "@/server/api/trpc"

export const attendanceMyConferencesProcedure = protectedProcedure.query(
async ({ ctx }): Promise<ConferencePreview[]> => {
const userId = ctx.user.id

const conferencesUserIsAttending = await db
.selectDistinct({ conference: conferences })
.from(conferences)
.leftJoin(tickets, eq(conferences.id, tickets.conferenceId))
.where(eq(tickets.userId, userId))
.orderBy(desc(conferences.startDate))

return conferencesUserIsAttending.map(({ conference }) => ({
id: conference.id,
name: conference.name,
}))
},
)