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
9 changes: 0 additions & 9 deletions public/logos/esn.svg

This file was deleted.

16 changes: 11 additions & 5 deletions src/app/associations/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next"
import { AssociationsList } from "@/components/associations/associations-list"
import { CONTAINER, PAGE_HERO, PAGE_MAIN } from "@/components/layout/page"
import { Hero } from "@/components/ui/hero"
import { getAllAssociations } from "@/queries/associations"

Expand All @@ -14,12 +15,17 @@ export default async function AssociationsPage() {
const associations = await getAllAssociations()

return (
<main className="mx-auto flex min-h-screen w-full flex-col items-center justify-center gap-20 px-4 py-49 md:gap-39">
<Hero title="Associazioni" description="Scopri le associazioni studentesche del Politecnico" />
<main className={PAGE_MAIN}>
<section className={`${CONTAINER} ${PAGE_HERO}`}>
<Hero title="Associazioni" description="Scopri le associazioni studentesche del Politecnico" />
</section>

<div className="w-full max-w-300">
<AssociationsList associations={associations} />
</div>
{/* Figma's list column: 1200px at 1728 */}
<section aria-label="Elenco delle associazioni" className={CONTAINER}>
<div className="mx-auto w-full max-w-300">
<AssociationsList associations={associations} />
</div>
</section>
</main>
)
}
14 changes: 3 additions & 11 deletions src/components/accordion-association/accordion-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,11 @@ export function AccordionContent({
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="group overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div
className={cn(
"typo-body-large pt-0 transition-all duration-500",
"group-data-[state=closed]:px-6 group-data-[state=open]:px-16",
"group-data-[state=closed]:pb-0 group-data-[state=open]:pb-16",
className
)}
>
{children}
</div>
{/* From md the text lines up with the name: trigger padding (32) + logo (64) + gap (24) */}
<div className={cn("typo-body-large px-4 pb-6 sm:px-5 md:pr-16 md:pb-8 md:pl-30", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
8 changes: 5 additions & 3 deletions src/components/accordion-association/accordion-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import { AccordionBackground } from "./accordion-background"

export function AccordionItem({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item data-slot="accordion-item" className="group/accordion relative overflow-hidden" {...props}>
<AccordionPrimitive.Item data-slot="accordion-item" className="group/accordion relative" {...props}>
<Glass
className={cn(
"relative overflow-hidden rounded-3xl border-white/50 bg-background-blur p-0 text-card-foreground",
"relative overflow-hidden rounded-3xl border-white/50 bg-background-blur p-0 text-card-foreground transition-colors",
// The header is the clickable part: it brightens on hover and rings the item on keyboard focus
"has-[[data-slot=accordion-trigger]:hover]:border-white has-[[data-slot=accordion-trigger]:hover]:bg-white/50 has-[[data-slot=accordion-trigger]:focus-visible]:ring-2 has-[[data-slot=accordion-trigger]:focus-visible]:ring-blue-secondary",
className
)}
>
{<AccordionBackground />}
<AccordionBackground />
<div className="relative z-10">{children}</div>
</Glass>
</AccordionPrimitive.Item>
Expand Down
7 changes: 3 additions & 4 deletions src/components/accordion-association/accordion-trigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@ export function AccordionTrigger({
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex w-full items-center justify-between gap-2 text-left md:gap-4",
"typo-title-large md:typo-display-medium outline-none",
"p-4 transition-none",
"md:p-0 md:transition-all md:duration-500 md:data-[state=closed]:p-6 md:data-[state=open]:px-16 md:data-[state=open]:pt-16 md:data-[state=open]:pb-8",
// Same padding open or closed: the name doesn't jump when the item expands
"flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-left outline-none sm:gap-4 sm:p-5 md:px-8 md:py-6",
"typo-headline-extrasmall sm:typo-title-large md:typo-headline-medium",
"[&[data-state=open]>svg]:rotate-180",
className
)}
Expand Down
8 changes: 7 additions & 1 deletion src/components/accordion-association/accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@ import type * as React from "react"
import { cn } from "@/lib/utils"

export function Accordion({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" className={cn("flex flex-col gap-4", className)} {...props} />
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex flex-col gap-4 md:gap-5", className)}
{...props}
/>
)
}
86 changes: 59 additions & 27 deletions src/components/accordion-association/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,75 @@ import { Accordion } from "./accordion"
import { AccordionContent } from "./accordion-content"
import { AccordionItem } from "./accordion-item"
import { AccordionTrigger } from "./accordion-trigger"
import type { AccordionAssociationProps } from "./types"
import type { AccordionAssociationItem, AccordionAssociationProps } from "./types"

const LOGO_CLASS = "size-10 shrink-0 rounded-full bg-white sm:size-12 md:size-16"

/** First letter of the first two words: "Lista Aperta - Per il diritto…" → "LA" */
function initials(name: string) {
return name
.split(/\s+/)
.filter((word) => /\p{L}/u.test(word))
.slice(0, 2)
.map((word) => word.match(/\p{L}/u)?.[0] ?? "")
.join("")
.toUpperCase()
}

function AssociationLogo({ name, logo }: Pick<AccordionAssociationItem, "name" | "logo">) {
// The name sits right next to the logo, so both are decorative for screen readers
if (!logo) {
return (
<span
aria-hidden
className={`${LOGO_CLASS} typo-title-medium md:typo-title-large flex items-center justify-center text-blue-secondary`}
>
{initials(name)}
</span>
)
}
return <Image src={logo} alt="" width={64} height={64} className={`${LOGO_CLASS} object-contain`} />
}

export default function AccordionAssociation({ accordionItems, defaultValue }: AccordionAssociationProps) {
return (
<Accordion type="single" collapsible defaultValue={defaultValue}>
{accordionItems.map((item) => (
<AccordionItem key={item.value} value={item.value}>
<AccordionTrigger>
<div className="flex items-center gap-4 md:gap-10">
<Image
src={item.logo}
alt={item.name}
width={80}
height={80}
className="size-14 rounded-full md:size-20"
/>
<span>{item.name}</span>
</div>
<span className="flex min-w-0 items-center gap-3 sm:gap-4 md:gap-6">
<AssociationLogo name={item.name} logo={item.logo} />
{/* Long single words may still break on the narrowest phones rather than run under the chevron */}
<span className="wrap-break-word min-w-0">{item.name}</span>
</span>
</AccordionTrigger>
<AccordionContent>
{item.content}
<div className="flex flex-col items-start gap-5 pt-14">
<span className="typo-body-large md:typo-title-large bg-linear-to-b from-blue-secondary to-blue-primary bg-clip-text text-transparent">
Segui l'associazione
</span>
<div className="flex flex-wrap gap-4 md:gap-7">
{item.links.map((link) => (
<a key={link.key} href={link.href} target="_blank" className="group/icon relative">
<link.icon className="size-6 transition-opacity duration-200 group-hover/icon:opacity-0" />
<GradientIcon
icon={link.icon}
className="absolute inset-0 size-6 opacity-0 transition-opacity duration-200 group-hover/icon:opacity-100"
/>
</a>
))}
{item.content && <p className="max-w-3xl text-pretty">{item.content}</p>}
{item.links.length > 0 && (
<div className="flex flex-col items-start gap-3 pt-8">
<span className="typo-title-small text-text-primary">Segui l'associazione</span>
<ul className="-ml-2 flex flex-wrap gap-1">
{item.links.map((link) => (
<li key={link.key}>
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
aria-label={link.key}
title={link.key}
className="group/icon relative flex size-10 items-center justify-center rounded-full outline-none transition-colors hover:bg-white/60 focus-visible:ring-2 focus-visible:ring-blue-secondary"
>
<link.icon className="size-6 transition-opacity duration-200 group-hover/icon:opacity-0" />
<GradientIcon
icon={link.icon}
className="absolute size-6 opacity-0 transition-opacity duration-200 group-hover/icon:opacity-100"
/>
</a>
</li>
))}
</ul>
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
))}
Expand Down
3 changes: 2 additions & 1 deletion src/components/accordion-association/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import type { GradientIconType } from "../gradient-icon"
export type AccordionAssociationItem = {
value: string
name: string
logo: StaticImageData | string
/** `null` when the association has no logo: its initials are shown instead */
logo: StaticImageData | string | null
content: React.ReactNode
links: {
key: string
Expand Down
17 changes: 9 additions & 8 deletions src/components/associations/associations-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
import AccordionAssociation from "@/components/accordion-association"
import type { GradientIconType } from "@/components/gradient-icon"
import type { ApiOutput } from "@/types"
import esnLogo from "../../../public/logos/esn.svg"

type Association = ApiOutput["web"]["associations"]["getAllAssociations"][number]
type AssociationLinks = Association["links"]
Expand All @@ -33,23 +32,25 @@ const LINK_CONFIG: { key: keyof AssociationLinks; label: string; icon: GradientI

export function AssociationsList({ associations }: { associations: Association[] }) {
if (associations.length === 0) {
return <p className="typo-body-large text-center">Nessuna associazione disponibile al momento.</p>
// The query returns an empty list on backend errors too, so the message covers both cases
return (
<p className="typo-body-large rounded-rectangles border border-slate-300 border-dashed px-6 py-10 text-center text-slate-600">
Le associazioni non sono disponibili al momento. Riprova più tardi.
</p>
)
}

const accordionItems = associations.map((association) => ({
value: String(association.id),
name: association.name,
logo: association.logo ?? esnLogo,
// No placeholder logo: an association without one gets its initials (see AccordionAssociation)
logo: association.logo,
content: association.descriptionIt,
links: LINK_CONFIG.filter(({ key }) => association.links[key]).map(({ key, label, icon, mailto }) => {
const value = association.links[key] as string
return { key: label, href: mailto ? `mailto:${value}` : value, icon }
}),
}))

return (
<div className="w-full max-w-300">
<AccordionAssociation accordionItems={accordionItems} defaultValue={accordionItems[0]?.value} />
</div>
)
return <AccordionAssociation accordionItems={accordionItems} defaultValue={accordionItems[0]?.value} />
}
6 changes: 2 additions & 4 deletions src/components/structure/association-bodies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@ export function AssociationBodies() {
<Accordion type="single" collapsible className="md:hidden">
{associationBodies.map((body) => (
<AccordionItem key={body.id} value={body.id} className="rounded-rectangles">
<AccordionTrigger className="p-6 focus-visible:ring-2 focus-visible:ring-blue-secondary focus-visible:ring-inset">
<AccordionTrigger className="typo-title-large p-6">
<span className="min-w-0">{body.title}</span>
</AccordionTrigger>
<AccordionContent className="group-data-[state=open]:px-6 group-data-[state=open]:pb-6">
{body.description}
</AccordionContent>
<AccordionContent className="px-6 pb-6">{body.description}</AccordionContent>
</AccordionItem>
))}
</Accordion>
Expand Down
14 changes: 13 additions & 1 deletion src/components/ui/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ const descriptionGradientStyle = {
"--hero-gradient-desktop": DESCRIPTION_GRADIENT_DESKTOP,
} as CSSProperties

function longestWord(text: string) {
return Math.max(...text.split(/\s+/).map((word) => word.length))
}

export function Hero({
title,
description,
Expand All @@ -33,7 +37,15 @@ export function Hero({
{/* Figma "lightheme-XLarge text effect": #020618 → #6e6e6e at 82.69% */}
{/* The gradient is drawn per line (box-decoration-clone), so a title that wraps keeps the full effect on each line */}
<h2 className="typo-display-large sm:typo-display-extralarge w-full self-stretch text-balance py-4 text-center sm:pt-14 sm:pb-0">
<span className="bg-linear-to-b from-text-primary to-83% to-[#6e6e6e] box-decoration-clone bg-clip-text text-transparent">
{/*
A word can't wrap, so a long one ("Associazioni") overflowed narrow phones at 57px and tablets at 120px.
The size is capped so the longest word fits the content width (DM Sans ≈ 0.55em per letter, measured on "Contattaci"); titles whose
words already fit keep the Figma sizes.
*/}
<span
className="bg-linear-to-b from-text-primary to-83% to-[#6e6e6e] box-decoration-clone bg-clip-text text-[min(3.5625rem,calc((100vw-3rem)/var(--hero-fit)))] text-transparent sm:text-[min(7.5rem,calc((100vw-5rem)/var(--hero-fit)))]"
style={{ "--hero-fit": longestWord(title) * 0.55 } as CSSProperties}
>
{title}
</span>
</h2>
Expand Down
Loading