Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/app/groups/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ReactNode } from "react"
import { ReportFab } from "@/components/groups/report/fab"

export default function GroupsLayout({ children }: { children: ReactNode }) {
return (
<>
{children}
<ReportFab />
</>
)
}
2 changes: 1 addition & 1 deletion src/components/card-course-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function CardCourseGroup({
<CardTitle gradient={false} className="typo-body-medium md:typo-headline-small min-w-0 grow">
{groupName}
</CardTitle>
<div className={cn("flex items-center gap-3", stacked ? "md:contents" : "contents")}>
<div className={cn("flex items-center gap-1.5", stacked ? "md:contents" : "contents")}>
{waLink && (
<a href={waLink} target="_blank" rel="noopener noreferrer" aria-label={`${groupName} su WhatsApp`}>
<CardAction gradient={false} className={actionClassName} icon={IconWhatsApp} iconSize="sm" />
Expand Down
11 changes: 11 additions & 0 deletions src/components/groups/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FiBox } from "react-icons/fi"
import type { GradientIconType } from "@/components/gradient-icon"
import type { Level, School } from "@/components/groups/types"

export const SCHOOLS: School[] = [
Expand All @@ -7,6 +9,15 @@ export const SCHOOLS: School[] = [
{ slug: "design", name: "Scuola di Design" },
]

export const SCHOOL_ICONS: Record<string, GradientIconType | string> = {
"ingegneria-industriale-informazione": "/icons/ingegneria.png",
auic: "/icons/architettura.png",
"ingegneria-civile-ambientale-territoriale": "/icons/civile.png",
design: "/icons/design.png",
}

export const DEFAULT_SCHOOL_ICON: GradientIconType = FiBox

export const LEVELS: Level[] = [
{ slug: "triennale", name: "Triennale" },
{ slug: "magistrale", name: "Magistrale" },
Expand Down
3 changes: 2 additions & 1 deletion src/components/groups/groups-result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { notFound } from "next/navigation"
import { FiArrowLeft, FiX } from "react-icons/fi"
import { CardCourseGroup } from "@/components/card-course-group"
import { getLevel, getSchool } from "@/components/groups/constants"
import { getVisibleGroups, type VisibleGroup } from "@/queries/groups"
import { getVisibleGroups } from "@/queries/groups"
import type { VisibleGroup } from "@/queries/types"
import {
cohortLabelText,
courseCohortFromLabels,
Expand Down
32 changes: 32 additions & 0 deletions src/components/groups/report/broken-link-flow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { GroupSearch, type SearchGroup } from "@/components/home/group-search"
import { DialogDescription, DialogHeader } from "@/components/ui/dialog"
import type { BrokenGroupLinkReportInput } from "@/queries/types"

export function ReportBrokenLinkFlow({
onBack,
onSelect,
}: {
onBack: () => void
onSelect: (report: BrokenGroupLinkReportInput, groupName: string) => void
}) {
function selectGroup(group: SearchGroup) {
onSelect(
{
groupId: group.telegramId,
type: group.type,
reportType: "broken_link",
reportedLink: group.link,
},
group.title
)
}

return (
<>
<DialogHeader title="Segnala link non funzionante" onBack={onBack}>
<DialogDescription>Cerca il gruppo a cui si riferisce la segnalazione.</DialogDescription>
</DialogHeader>
<GroupSearch onSelect={selectGroup} />
</>
)
}
21 changes: 21 additions & 0 deletions src/components/groups/report/fab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client"

import { FiFlag } from "react-icons/fi"
import { ButtonIcon } from "@/components/button-icon"
import { ReportFinderDialog } from "@/components/groups/report/finder-dialog"

export function ReportFab() {
return (
<ReportFinderDialog
trigger={
<ButtonIcon
icon={FiFlag}
variant="tertiary"
size="icon-lg"
aria-label="Segnala un gruppo o un link"
className="fixed right-5 bottom-[max(1.25rem,env(safe-area-inset-bottom))] z-40 touch-manipulation rounded-full shadow-lg sm:right-8"
/>
}
/>
)
}
139 changes: 139 additions & 0 deletions src/components/groups/report/finder-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"use client"

import { type ReactNode, useRef, useState } from "react"
import { FiAlertCircle, FiCheckCircle, FiLink } from "react-icons/fi"
import { ButtonIcon } from "@/components/button-icon"
import { ReportBrokenLinkFlow } from "@/components/groups/report/broken-link-flow"
import { ReportMissingLinkFlow } from "@/components/groups/report/missing-link"
import { SelectableCardIcon } from "@/components/groups/report/selectable-card-icon"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "@/components/ui/dialog"
import { reportGroupLink } from "@/queries/groups"
import type { BrokenGroupLinkReportInput, MissingGroupLinkReportInput } from "@/queries/types"

type View = "problem" | "broken" | "missing" | "confirmation" | "done"

export function ReportFinderDialog({ trigger }: { trigger: ReactNode }) {
const [open, setOpen] = useState(false)
const [view, setView] = useState<View>("problem")
const [report, setReport] = useState<BrokenGroupLinkReportInput | null>(null)
const [groupName, setGroupName] = useState("")
const [status, setStatus] = useState<"idle" | "submitting" | "error">("idle")
const inFlightRef = useRef(false)
const requestIdRef = useRef(0)

function reset() {
requestIdRef.current++
inFlightRef.current = false
setView("problem")
setReport(null)
setGroupName("")
setStatus("idle")
}

function handleOpenChange(next: boolean) {
setOpen(next)
if (!next) reset()
}

async function submit(input: BrokenGroupLinkReportInput | MissingGroupLinkReportInput) {
if (inFlightRef.current) return
inFlightRef.current = true
const requestId = ++requestIdRef.current

setStatus("submitting")
const result = await reportGroupLink(input)
if (requestId !== requestIdRef.current) return // dialog was reset/closed meanwhile

inFlightRef.current = false
if (result.ok) {
setView("done")
setStatus("idle")
return
}

setStatus("error")
}

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className={view === "broken" ? "overflow-visible" : undefined}>
{view === "done" ? (
<>
<DialogHeader title="Segnalazione inviata">
<DialogDescription>Grazie, la verificheremo al più presto.</DialogDescription>
</DialogHeader>
<DialogFooter className="w-full flex-row justify-start sm:justify-start">
<Button variant="primary" onClick={() => handleOpenChange(false)}>
Chiudi
</Button>
</DialogFooter>
</>
) : view === "confirmation" && report ? (
<>
<DialogHeader
title="Conferma segnalazione"
onBack={() => {
setView("broken")
setStatus("idle")
}}
>
<DialogDescription>Controlla i dettagli prima di inviarla.</DialogDescription>
</DialogHeader>
<Card className="h-auto w-full flex-row items-center gap-3 px-5 py-4">
<CardAction icon={FiAlertCircle} iconSize="sm" gradient={false} className="static shrink-0" />
<CardContent className="typo-body-medium min-w-0 text-text-primary">
Il link {report.type === "tg" ? "Telegram" : "WhatsApp"} di “{groupName}” non funziona.
</CardContent>
</Card>
{status === "error" && (
<p aria-live="polite" className="typo-body-small text-red-600">
Non è stato possibile inviare la segnalazione. Riprova tra qualche minuto.
</p>
)}
<DialogFooter>
<ButtonIcon
icon={FiCheckCircle}
text={status === "submitting" ? "Invio…" : "Invia segnalazione"}
disabled={status === "submitting"}
onClick={() => void submit(report)}
/>
</DialogFooter>
</>
) : view === "broken" ? (
<ReportBrokenLinkFlow
onBack={reset}
onSelect={(selectedReport, selectedGroupName) => {
setReport(selectedReport)
setGroupName(selectedGroupName)
setView("confirmation")
}}
/>
) : view === "missing" ? (
<ReportMissingLinkFlow
onBack={reset}
onSubmit={(missingReport) => void submit(missingReport)}
status={status}
/>
) : (
<>
<DialogHeader title="Quale problema vuoi segnalare?" titleClassName="typo-title-large" />
<div className="grid grid-cols-2 gap-3">
<SelectableCardIcon title="Link non funzionante" icon={FiLink} onClick={() => setView("broken")} />
<SelectableCardIcon title="Link mancante" icon={FiAlertCircle} onClick={() => setView("missing")} />
</div>
</>
)}
</DialogContent>
</Dialog>
)
}
24 changes: 24 additions & 0 deletions src/components/groups/report/missing-link/category-step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { FiBook, FiStar } from "react-icons/fi"
import { SelectableCardIcon } from "@/components/groups/report/selectable-card-icon"
import { DialogDescription, DialogHeader } from "@/components/ui/dialog"
import type { ReportMissingLinkCategory } from "./types"

export function CategoryStep({
onBack,
onSelect,
}: {
onBack: () => void
onSelect: (category: ReportMissingLinkCategory) => void
}) {
return (
<>
<DialogHeader title="Segnala link mancante" onBack={onBack}>
<DialogDescription>Che tipo di gruppo manca?</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<SelectableCardIcon title="Didattica" icon={FiBook} onClick={() => onSelect("didattica")} />
<SelectableCardIcon title="Extra" icon={FiStar} onClick={() => onSelect("extra")} />
</div>
</>
)
}
54 changes: 54 additions & 0 deletions src/components/groups/report/missing-link/course-step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { CardCourse } from "@/components/card-course"
import { Spinner } from "@/components/spinner"
import { Button } from "@/components/ui/button"
import { DialogDescription, DialogFooter, DialogHeader } from "@/components/ui/dialog"
import { humanizeSlug } from "@/utils/labels"

export function CourseStep({
path,
courses,
onBack,
onSelectCourse,
onSpecifyMissing,
}: {
path: string
courses: string[] | null
onBack: () => void
onSelectCourse: (course: string) => void
onSpecifyMissing: () => void
}) {
return (
<>
<DialogHeader title="Segnala link mancante" onBack={onBack}>
<DialogDescription>{path}</DialogDescription>
</DialogHeader>
<div className="flex max-h-68 flex-col gap-3 overflow-y-auto pr-1">
{courses ? (
courses.length > 0 ? (
courses.map((course) => (
<button
key={course}
type="button"
className="rounded-rectangles text-left focus-visible:ring-2 focus-visible:ring-blue-primary"
onClick={() => onSelectCourse(course)}
>
<CardCourse courseName={humanizeSlug(course)} />
</button>
))
) : (
<p className="typo-body-medium text-text-secondary">Nessun corso disponibile al momento.</p>
)
) : (
<div className="flex justify-center py-8">
<Spinner className="fill-blue-primary text-blue-primary/20" />
</div>
)}
</div>
<DialogFooter>
<Button variant="primary" onClick={onSpecifyMissing}>
Specifica il gruppo mancante
</Button>
</DialogFooter>
</>
)
}
61 changes: 61 additions & 0 deletions src/components/groups/report/missing-link/details-step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { FiCheckCircle } from "react-icons/fi"
import { ButtonIcon } from "@/components/button-icon"
import { Glass } from "@/components/glass"
import { DialogDescription, DialogFooter, DialogHeader } from "@/components/ui/dialog"
import type { ReportMissingLinkCategory } from "./types"

export function DetailsStep({
path,
category,
details,
status,
onBack,
onChangeDetails,
onSubmit,
}: {
path: string
category: ReportMissingLinkCategory | null
details: string
status: "idle" | "submitting" | "error"
onBack: () => void
onChangeDetails: (value: string) => void
onSubmit: () => void
}) {
return (
<>
<DialogHeader title="Cosa manca?" onBack={onBack}>
<DialogDescription>{path}</DialogDescription>
</DialogHeader>
<label className="flex flex-col gap-2" htmlFor="missing-link-details">
<span className="typo-body-medium text-text-primary">Descrivi il gruppo o il link mancante</span>
<Glass className="border-white/50 bg-background-blur p-0">
<textarea
id="missing-link-details"
className="typo-body-medium min-h-28 w-full resize-y bg-transparent p-4 text-text-primary outline-none placeholder:text-text-secondary"
value={details}
maxLength={500}
placeholder={
category === "extra"
? "Ad esempio: manca il gruppo Telegram del mercatino dell'usato..."
: "Ad esempio: manca il gruppo Telegram del corso..."
}
onChange={(event) => onChangeDetails(event.target.value)}
/>
</Glass>
</label>
{status === "error" && (
<p aria-live="polite" className="typo-body-small text-red-600">
Non è stato possibile inviare la segnalazione. Riprova tra qualche minuto.
</p>
)}
<DialogFooter>
<ButtonIcon
icon={FiCheckCircle}
text={status === "submitting" ? "Invio…" : "Invia segnalazione"}
disabled={!details.trim() || status === "submitting"}
onClick={onSubmit}
/>
</DialogFooter>
</>
)
}
Loading
Loading