From 80b0a30db7fc00b8b1a48fd946fa69b0fc1b286d Mon Sep 17 00:00:00 2001 From: DankaMarci Date: Wed, 8 Jul 2026 10:46:09 +0200 Subject: [PATCH 1/3] feat: create reusable organization card component --- .../[lang]/kozelet/ontevekenykorok/page.tsx | 98 +---- .../[lang]/kozelet/szakkollegiumok/page.tsx | 184 ++------- .../[lang]/kozelet/versenycsapatok/page.tsx | 161 ++------ src/components/common/OrganizationCard.tsx | 381 ++++++++++++++++++ src/lib/social-utils.tsx | 8 +- 5 files changed, 469 insertions(+), 363 deletions(-) create mode 100644 src/components/common/OrganizationCard.tsx diff --git a/src/app/(app)/[lang]/kozelet/ontevekenykorok/page.tsx b/src/app/(app)/[lang]/kozelet/ontevekenykorok/page.tsx index 469c6ed..4728895 100644 --- a/src/app/(app)/[lang]/kozelet/ontevekenykorok/page.tsx +++ b/src/app/(app)/[lang]/kozelet/ontevekenykorok/page.tsx @@ -1,13 +1,9 @@ +import { OrganizationCard } from "@/components/common/OrganizationCard"; import { PageHeader } from "@/components/common/PageHeader"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { Button } from "@/components/ui/button"; import { getDictionary } from "@/get-dictionary"; import { Locale } from "@/i18n-config"; -import Image from "next/image"; -import { getSocialIcon, getSocialPriority, getSocialName } from "@/lib/social-utils"; -type Kor = { +type Organization = { id: string; title: string; description: string[]; @@ -16,13 +12,17 @@ type Kor = { images?: string[]; }; +function getContactLabel(label: string) { + return label.replace(/:$/, ""); +} + export default async function OntevekenyKorokPage({ params, }: { params: Promise<{ lang: Locale }>; }) { const { lang } = await params; - const dictionary = await getDictionary(lang, 'ontevekeny_korok'); + const dictionary = await getDictionary(lang, "ontevekeny_korok"); const data = dictionary.ontevekeny_korok; if (!data) { @@ -31,81 +31,21 @@ export default async function OntevekenyKorokPage({ return (
-
+
- {data.korok.map((kor: Kor) => ( - - - - {kor.title} - - - -
- {kor.description.map((para: string, pIdx: number) => ( -

{para}

- ))} -
- - {/* Image Grid / Gallery */} - {kor.images && kor.images.length > 0 && ( -
- {kor.images.map((img: string, imgIdx: number) => ( -
- {kor.images!.length === 1 ? ( - /* eslint-disable-next-line @next/next/no-img-element */ - {`${kor.title} - ) : ( - {`${kor.title} - )} -
- ))} -
- )} - - - -
-

{kor.social_title}

-
- {[...kor.social_links] - .sort((a: {label: string, url: string}, b: {label: string, url: string}) => getSocialPriority(a.label) - getSocialPriority(b.label)) - .map((link: {label: string, url: string}, lIdx: number) => ( - - ))} -
-
-
-
+ {data.korok.map((kor: Organization) => ( + ))}
diff --git a/src/app/(app)/[lang]/kozelet/szakkollegiumok/page.tsx b/src/app/(app)/[lang]/kozelet/szakkollegiumok/page.tsx index 70e5f1b..7a8aca8 100644 --- a/src/app/(app)/[lang]/kozelet/szakkollegiumok/page.tsx +++ b/src/app/(app)/[lang]/kozelet/szakkollegiumok/page.tsx @@ -1,13 +1,10 @@ +import { OrganizationCard } from "@/components/common/OrganizationCard"; import { PageHeader } from "@/components/common/PageHeader"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { getDictionary } from "@/get-dictionary"; import { Locale } from "@/i18n-config"; -import Image from "next/image"; -import { getSocialIcon, getSocialPriority, getSocialName } from "@/lib/social-utils"; -type Team = { +type Organization = { id: string; title: string; description: string[]; @@ -16,13 +13,17 @@ type Team = { images?: string[]; }; +function getContactLabel(label: string) { + return label.replace(/:$/, ""); +} + export default async function SzakkollegiumokPage({ params, }: { params: Promise<{ lang: Locale }>; }) { const { lang } = await params; - const dictionary = await getDictionary(lang, 'advanced_colleges'); + const dictionary = await getDictionary(lang, "advanced_colleges"); const data = dictionary.advanced_colleges; if (!data) { @@ -31,159 +32,44 @@ export default async function SzakkollegiumokPage({ return (
-
+
- - {/* Intro section, if any */} + {data.description && data.description.length > 0 && ( - + -
- {data.description.map((para: string) => ( -

{para}

+
+ {data.description.map((paragraph: string) => ( +

{paragraph}

))}
)} - - - {data.muszak.title} - - -
- {data.muszak.description.map((para: string) => ( -

{para}

- ))} -
- - {data.muszak.images && data.muszak.images.length > 0 && ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {data.muszak.title} -
- )} - -
-

{data.muszak.social_title}

-
- {[...data.muszak.social_links] - .sort((a: {label: string, url: string}, b: {label: string, url: string}) => getSocialPriority(a.label) - getSocialPriority(b.label)) - .map((link: {label: string, url: string}) => ( - - ))} -
-
-
-
-
- {data.teams.map((team: Team) => { - const imageCount = team.images?.length || 0; - let gridColsClass = 'grid-cols-1 md:grid-cols-3'; - if (imageCount === 1) { - gridColsClass = 'grid-cols-1'; - } else if (imageCount === 2) { - gridColsClass = 'grid-cols-1 md:grid-cols-2'; - } - - return ( - - - - {team.title} - - - -
- {team.description.map((para: string) => ( -

{para}

- ))} -
- - {/* Image Grid / Gallery */} - {team.images && team.images.length > 0 && ( -
- {team.images.map((img: string, imgIdx: number) => { - let containerClass = 'aspect-square bg-slate-50 flex items-center justify-center p-4'; - if (imageCount === 1) { - containerClass = 'bg-slate-50 flex items-center justify-center'; - } else if (imageCount === 2) { - containerClass = 'aspect-square md:aspect-[4/3] bg-slate-50 flex items-center justify-center p-4'; - } + - return ( -
- {imageCount === 1 ? ( - /* eslint-disable-next-line @next/next/no-img-element */ - {`${team.title} - ) : ( - {`${team.title} - )} -
- ); - })} -
- )} - - - -
-

{team.social_title}

-
- {[...team.social_links] - .sort((a: {label: string, url: string}, b: {label: string, url: string}) => getSocialPriority(a.label) - getSocialPriority(b.label)) - .map((link: {label: string, url: string}) => ( - - ))} -
-
-
-
- ); - })} + {data.teams.map((team: Organization) => ( + + ))}
diff --git a/src/app/(app)/[lang]/kozelet/versenycsapatok/page.tsx b/src/app/(app)/[lang]/kozelet/versenycsapatok/page.tsx index 1c1c332..d2266b8 100644 --- a/src/app/(app)/[lang]/kozelet/versenycsapatok/page.tsx +++ b/src/app/(app)/[lang]/kozelet/versenycsapatok/page.tsx @@ -1,13 +1,9 @@ +import { OrganizationCard } from "@/components/common/OrganizationCard"; import { PageHeader } from "@/components/common/PageHeader"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { Button } from "@/components/ui/button"; import { getDictionary } from "@/get-dictionary"; import { Locale } from "@/i18n-config"; -import Image from "next/image"; -import { getSocialIcon, getSocialPriority, getSocialName } from "@/lib/social-utils"; -type Team = { +type Organization = { id: string; title: string; description: string[]; @@ -16,13 +12,17 @@ type Team = { images?: string[]; }; +function getContactLabel(label: string) { + return label.replace(/:$/, ""); +} + export default async function VersenycsapatokPage({ params, }: { params: Promise<{ lang: Locale }>; }) { const { lang } = await params; - const dictionary = await getDictionary(lang, 'competition_teams'); + const dictionary = await getDictionary(lang, "competition_teams"); const data = dictionary.competition_teams; if (!data) { @@ -31,135 +31,30 @@ export default async function VersenycsapatokPage({ return (
-
+
- - - {data.mvk.title} - - -
- {data.mvk.description.map((para: string) => ( -

{para}

- ))} -
- -
-

{data.mvk.social_title}

-
- {[...data.mvk.social_links] - .sort((a: {label: string, url: string}, b: {label: string, url: string}) => getSocialPriority(a.label) - getSocialPriority(b.label)) - .map((link: {label: string, url: string}) => ( - - ))} -
-
-
-
-
- {data.teams.map((team: Team) => { - const imageCount = team.images?.length || 0; - let gridColsClass = 'grid-cols-1 md:grid-cols-3'; - if (imageCount === 1) { - gridColsClass = 'grid-cols-1'; - } else if (imageCount === 2) { - gridColsClass = 'grid-cols-1 md:grid-cols-2'; - } - - return ( - - - - {team.title} - - - -
- {team.description.map((para: string) => ( -

{para}

- ))} -
- - {/* Image Grid / Gallery */} - {team.images && team.images.length > 0 && ( -
- {team.images.map((img: string, imgIdx: number) => { - let containerClass = 'aspect-square bg-slate-50 flex items-center justify-center p-4'; - if (imageCount === 1) { - containerClass = 'bg-slate-50 flex items-center justify-center'; - } else if (imageCount === 2) { - containerClass = 'aspect-square md:aspect-[4/3] bg-slate-50 flex items-center justify-center p-4'; - } - - return ( -
- {imageCount === 1 ? ( - /* eslint-disable-next-line @next/next/no-img-element */ - {`${team.title} - ) : ( - {`${team.title} - )} -
- ); - })} -
- )} - - - -
-

{team.social_title}

-
- {[...team.social_links] - .sort((a: {label: string, url: string}, b: {label: string, url: string}) => getSocialPriority(a.label) - getSocialPriority(b.label)) - .map((link: {label: string, url: string}) => ( - - ))} -
-
-
-
- ); - })} + + + {data.teams.map((team: Organization) => ( + + ))}
diff --git a/src/components/common/OrganizationCard.tsx b/src/components/common/OrganizationCard.tsx new file mode 100644 index 0000000..61ea858 --- /dev/null +++ b/src/components/common/OrganizationCard.tsx @@ -0,0 +1,381 @@ +import Image from "next/image"; +import React from "react"; +import { + ArrowRight, + CalendarDays, + CircleDot, + MapPin, + Users, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Locale } from "@/i18n-config"; +import { cn } from "@/lib/utils"; +import { + getSocialIcon, + getSocialName, + getSocialPriority, +} from "@/lib/social-utils"; +import { parseFormattedText } from "@/utils/parseFormattedText"; + +type TextContent = string | readonly string[] | React.ReactNode; + +export type OrganizationStat = { + label: string; + value: string | number; +}; + +export type OrganizationEvent = { + title: string; + description?: string; + date?: string; + location?: string; + href?: string; +}; + +export type OrganizationGalleryImage = + | string + | { + src: string; + alt?: string; + }; + +export type OrganizationSocialLink = { + label: string; + url: string; +}; + +type OrganizationCardLabels = { + presentation: string; + events: string; + activities: string; + departments: string; + targetAudience: string; + contacts: string; + gallery: string; +}; + +export type OrganizationCardProps = { + name: string; + stats?: readonly OrganizationStat[]; + presentation?: TextContent; + events?: readonly OrganizationEvent[]; + activities?: readonly string[]; + departments?: readonly string[]; + targetAudience?: TextContent; + socialLinks?: readonly OrganizationSocialLink[]; + galleryImages?: readonly OrganizationGalleryImage[]; + imageBasePath?: string; + joinUrl?: string; + joinText?: string; + labels?: Partial; + locale?: Locale; + className?: string; +}; + +const defaultLabels = (locale: Locale): OrganizationCardLabels => + locale === "hu" + ? { + presentation: "Bemutatás", + events: "Kiemelt események", + activities: "Tevékenységek", + departments: "Tagozatok", + targetAudience: "Kinek ajánljuk?", + contacts: "Elérhetőségek", + gallery: "Galéria", + } + : { + presentation: "Presentation", + events: "Featured events", + activities: "Activities", + departments: "Departments", + targetAudience: "Who is it for?", + contacts: "Contact", + gallery: "Gallery", + }; + +function renderTextContent(content: TextContent) { + if (Array.isArray(content)) { + return ( +
+ {content.map((paragraph, index) => ( +

+ {parseFormattedText(paragraph)} +

+ ))} +
+ ); + } + + if (typeof content === "string") { + return

{parseFormattedText(content)}

; + } + + return content; +} + +function resolveImageSrc(image: OrganizationGalleryImage, imageBasePath: string) { + const source = typeof image === "string" ? image : image.src; + if (source.startsWith("/") || source.startsWith("http")) { + return source; + } + + return `${imageBasePath}/${source}`; +} + +function getImageAlt( + image: OrganizationGalleryImage, + organizationName: string, + index: number, +) { + if (typeof image !== "string" && image.alt) { + return image.alt; + } + + return `${organizationName} - ${index + 1}`; +} + +function Section({ + title, + children, + className, +}: Readonly<{ + title: string; + children: React.ReactNode; + className?: string; +}>) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +export function OrganizationCard({ + name, + stats, + presentation, + events, + activities, + departments, + targetAudience, + socialLinks, + galleryImages, + imageBasePath = "", + joinUrl, + joinText, + labels, + locale = "hu", + className, +}: Readonly) { + const sectionLabels = { ...defaultLabels(locale), ...labels }; + const sortedSocialLinks = socialLinks + ? [...socialLinks].sort( + (a, b) => getSocialPriority(a.label) - getSocialPriority(b.label), + ) + : []; + + const hasStats = Boolean(stats?.length); + const hasEvents = Boolean(events?.length); + const hasActivities = Boolean(activities?.length); + const hasDepartments = Boolean(departments?.length); + const hasTargetAudience = Boolean(targetAudience); + const hasSocialLinks = sortedSocialLinks.length > 0; + const hasGallery = Boolean(galleryImages?.length); + const hasJoinCta = Boolean(joinUrl && joinText); + + return ( +
+
+
+

+ {name} +

+ + {hasStats && ( +
+ {stats?.map((stat) => ( +
+
+ {stat.label} +
+
+ {stat.value} +
+
+ ))} +
+ )} +
+
+ +
+ {presentation && ( +
+
+ {renderTextContent(presentation)} +
+
+ )} + + {hasEvents && ( +
+
+ {events?.map((event) => { + const eventContent = ( +
+

+ {event.title} +

+ {event.description && ( +

+ {parseFormattedText(event.description)} +

+ )} + {(event.date || event.location) && ( +
+ {event.date && ( + + + {event.date} + + )} + {event.location && ( + + + {event.location} + + )} +
+ )} +
+ ); + + return event.href ? ( + + {eventContent} + + ) : ( +
{eventContent}
+ ); + })} +
+
+ )} + + {hasActivities && ( +
+
    + {activities?.map((activity, index) => ( +
  • + {index > 0 && ( + + )} + {activity} +
  • + ))} +
+
+ )} + + {hasDepartments && ( +
+
    + {departments?.map((department) => ( +
  • + {department} +
  • + ))} +
+
+ )} + + {hasTargetAudience && ( +
+
+
{renderTextContent(targetAudience)}
+
+
+ )} + + {hasSocialLinks && ( +
+
+ {sortedSocialLinks.map((link) => ( + + ))} +
+
+ )} + + {hasGallery && ( +
+
+ {galleryImages?.map((image, index) => ( +
+ {getImageAlt(image, +
+ ))} +
+
+ )} +
+ + {hasJoinCta && ( + + + + {joinText} + + + + )} +
+ ); +} diff --git a/src/lib/social-utils.tsx b/src/lib/social-utils.tsx index 9ce858b..702e8f5 100644 --- a/src/lib/social-utils.tsx +++ b/src/lib/social-utils.tsx @@ -6,6 +6,7 @@ import { Youtube, Globe, Link as LinkIcon, + Mail, MessageCircle } from "lucide-react"; import { Locale } from "@/i18n-config"; @@ -37,6 +38,7 @@ export function getSocialIcon(label: string) { if (l === 'x' || l.includes('twitter')) return ; if (l.includes('linktr.ee')) return ; if (l.includes('threads')) return ; + if (l.includes('@') || l.includes('email') || l.includes('e-mail')) return ; if (l.includes('weblap') || l.includes('web') || l.includes('honlap') || l.includes('mvk.bme.hu') || l.includes('.hu') || l.includes('.eu') || l.includes('.com') || l.includes('.org')) return ; return ; } @@ -52,7 +54,8 @@ export function getSocialPriority(label: string) { if (l.includes('tiktok')) return 7; if (l.includes('threads')) return 8; if (l.includes('linktr.ee')) return 9; - return 10; + if (l.includes('@') || l.includes('email') || l.includes('e-mail')) return 10; + return 11; } export function getSocialName(label: string, lang: Locale = 'hu') { @@ -67,6 +70,7 @@ export function getSocialName(label: string, lang: Locale = 'hu') { if (l === 'x' || l.includes('twitter')) return 'X (Twitter)'; if (l.includes('linktr.ee')) return 'Linktree'; if (l.includes('threads')) return 'Threads'; + if (l.includes('@') || l.includes('email') || l.includes('e-mail')) return 'Email'; if (l.includes('weblap') || l.includes('web') || l.includes('honlap') || l.includes('mvk.bme.hu') || l.includes('.hu') || l.includes('.eu') || l.includes('.com') || l.includes('.org')) return lang === 'hu' ? 'Weblap' : 'Website'; return label; -} \ No newline at end of file +} From e449c514141fbc4dfb2a980aa2100a4d4c8e786e Mon Sep 17 00:00:00 2001 From: DankaMarci Date: Wed, 8 Jul 2026 16:26:00 +0200 Subject: [PATCH 2/3] feat: enhance OrganizationCard component with additional props and social icons --- .../[lang]/kozelet/ontevekenykorok/page.tsx | 48 +++- .../[lang]/kozelet/szakkollegiumok/page.tsx | 61 +++-- .../[lang]/kozelet/versenycsapatok/page.tsx | 55 +++-- src/components/common/OrganizationCard.tsx | 100 ++++---- src/lib/social-utils.tsx | 231 ++++++++++++++---- 5 files changed, 359 insertions(+), 136 deletions(-) diff --git a/src/app/(app)/[lang]/kozelet/ontevekenykorok/page.tsx b/src/app/(app)/[lang]/kozelet/ontevekenykorok/page.tsx index 4728895..be1bcd9 100644 --- a/src/app/(app)/[lang]/kozelet/ontevekenykorok/page.tsx +++ b/src/app/(app)/[lang]/kozelet/ontevekenykorok/page.tsx @@ -1,4 +1,7 @@ -import { OrganizationCard } from "@/components/common/OrganizationCard"; +import { + OrganizationCard, + type OrganizationCardProps, +} from "@/components/common/OrganizationCard"; import { PageHeader } from "@/components/common/PageHeader"; import { getDictionary } from "@/get-dictionary"; import { Locale } from "@/i18n-config"; @@ -10,12 +13,45 @@ type Organization = { social_title: string; social_links: { label: string; url: string }[]; images?: string[]; + stats?: OrganizationCardProps["stats"]; + events?: OrganizationCardProps["events"]; + activities?: OrganizationCardProps["activities"]; + departments?: OrganizationCardProps["departments"]; + target_audience?: OrganizationCardProps["targetAudience"]; + targetAudience?: OrganizationCardProps["targetAudience"]; + join_url?: string; + joinUrl?: string; + join_text?: string; + joinText?: string; }; function getContactLabel(label: string) { return label.replace(/:$/, ""); } +function getOrganizationCardProps( + organization: Organization, + locale: Locale, +): OrganizationCardProps { + return { + name: organization.title, + stats: organization.stats, + presentation: organization.description, + events: organization.events, + activities: organization.activities, + departments: organization.departments, + targetAudience: + organization.targetAudience ?? organization.target_audience, + socialLinks: organization.social_links, + galleryImages: organization.images, + imageBasePath: "/ontevekenykorok", + joinUrl: organization.joinUrl ?? organization.join_url, + joinText: organization.joinText ?? organization.join_text, + labels: { contacts: getContactLabel(organization.social_title) }, + locale, + }; +} + export default async function OntevekenyKorokPage({ params, }: { @@ -35,16 +71,10 @@ export default async function OntevekenyKorokPage({
- {data.korok.map((kor: Organization) => ( + {(data.korok as Organization[]).map((kor) => ( ))}
diff --git a/src/app/(app)/[lang]/kozelet/szakkollegiumok/page.tsx b/src/app/(app)/[lang]/kozelet/szakkollegiumok/page.tsx index 7a8aca8..b182145 100644 --- a/src/app/(app)/[lang]/kozelet/szakkollegiumok/page.tsx +++ b/src/app/(app)/[lang]/kozelet/szakkollegiumok/page.tsx @@ -1,4 +1,7 @@ -import { OrganizationCard } from "@/components/common/OrganizationCard"; +import { + OrganizationCard, + type OrganizationCardProps, +} from "@/components/common/OrganizationCard"; import { PageHeader } from "@/components/common/PageHeader"; import { Card, CardContent } from "@/components/ui/card"; import { getDictionary } from "@/get-dictionary"; @@ -11,12 +14,46 @@ type Organization = { social_title: string; social_links: { label: string; url: string }[]; images?: string[]; + stats?: OrganizationCardProps["stats"]; + events?: OrganizationCardProps["events"]; + activities?: OrganizationCardProps["activities"]; + departments?: OrganizationCardProps["departments"]; + target_audience?: OrganizationCardProps["targetAudience"]; + targetAudience?: OrganizationCardProps["targetAudience"]; + join_url?: string; + joinUrl?: string; + join_text?: string; + joinText?: string; }; function getContactLabel(label: string) { return label.replace(/:$/, ""); } +function getOrganizationCardProps( + organization: Organization, + locale: Locale, + imageBasePath: string, +): OrganizationCardProps { + return { + name: organization.title, + stats: organization.stats, + presentation: organization.description, + events: organization.events, + activities: organization.activities, + departments: organization.departments, + targetAudience: + organization.targetAudience ?? organization.target_audience, + socialLinks: organization.social_links, + galleryImages: organization.images, + imageBasePath, + joinUrl: organization.joinUrl ?? organization.join_url, + joinText: organization.joinText ?? organization.join_text, + labels: { contacts: getContactLabel(organization.social_title) }, + locale, + }; +} + export default async function SzakkollegiumokPage({ params, }: { @@ -49,25 +86,17 @@ export default async function SzakkollegiumokPage({
- {data.teams.map((team: Organization) => ( + {(data.teams as Organization[]).map((team) => ( ))}
diff --git a/src/app/(app)/[lang]/kozelet/versenycsapatok/page.tsx b/src/app/(app)/[lang]/kozelet/versenycsapatok/page.tsx index d2266b8..7f50d80 100644 --- a/src/app/(app)/[lang]/kozelet/versenycsapatok/page.tsx +++ b/src/app/(app)/[lang]/kozelet/versenycsapatok/page.tsx @@ -1,4 +1,7 @@ -import { OrganizationCard } from "@/components/common/OrganizationCard"; +import { + OrganizationCard, + type OrganizationCardProps, +} from "@/components/common/OrganizationCard"; import { PageHeader } from "@/components/common/PageHeader"; import { getDictionary } from "@/get-dictionary"; import { Locale } from "@/i18n-config"; @@ -10,12 +13,46 @@ type Organization = { social_title: string; social_links: { label: string; url: string }[]; images?: string[]; + stats?: OrganizationCardProps["stats"]; + events?: OrganizationCardProps["events"]; + activities?: OrganizationCardProps["activities"]; + departments?: OrganizationCardProps["departments"]; + target_audience?: OrganizationCardProps["targetAudience"]; + targetAudience?: OrganizationCardProps["targetAudience"]; + join_url?: string; + joinUrl?: string; + join_text?: string; + joinText?: string; }; function getContactLabel(label: string) { return label.replace(/:$/, ""); } +function getOrganizationCardProps( + organization: Organization, + locale: Locale, + imageBasePath?: string, +): OrganizationCardProps { + return { + name: organization.title, + stats: organization.stats, + presentation: organization.description, + events: organization.events, + activities: organization.activities, + departments: organization.departments, + targetAudience: + organization.targetAudience ?? organization.target_audience, + socialLinks: organization.social_links, + galleryImages: organization.images, + imageBasePath, + joinUrl: organization.joinUrl ?? organization.join_url, + joinText: organization.joinText ?? organization.join_text, + labels: { contacts: getContactLabel(organization.social_title) }, + locale, + }; +} + export default async function VersenycsapatokPage({ params, }: { @@ -36,23 +73,13 @@ export default async function VersenycsapatokPage({
- {data.teams.map((team: Organization) => ( + {(data.teams as Organization[]).map((team) => ( ))}
diff --git a/src/components/common/OrganizationCard.tsx b/src/components/common/OrganizationCard.tsx index 61ea858..ab2b1de 100644 --- a/src/components/common/OrganizationCard.tsx +++ b/src/components/common/OrganizationCard.tsx @@ -3,12 +3,10 @@ import React from "react"; import { ArrowRight, CalendarDays, - CircleDot, MapPin, Users, } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { Locale } from "@/i18n-config"; import { cn } from "@/lib/utils"; import { @@ -53,6 +51,7 @@ type OrganizationCardLabels = { targetAudience: string; contacts: string; gallery: string; + join: string; }; export type OrganizationCardProps = { @@ -83,6 +82,7 @@ const defaultLabels = (locale: Locale): OrganizationCardLabels => targetAudience: "Kinek ajánljuk?", contacts: "Elérhetőségek", gallery: "Galéria", + join: "Csatlakozom!", } : { presentation: "Presentation", @@ -92,6 +92,7 @@ const defaultLabels = (locale: Locale): OrganizationCardLabels => targetAudience: "Who is it for?", contacts: "Contact", gallery: "Gallery", + join: "Join us", }; function renderTextContent(content: TextContent) { @@ -135,18 +136,30 @@ function getImageAlt( return `${organizationName} - ${index + 1}`; } +function getStatLabel(label: string) { + return label.replace(/:$/, ""); +} + function Section({ title, children, className, + bordered = true, }: Readonly<{ title: string; children: React.ReactNode; className?: string; + bordered?: boolean; }>) { return ( -
-

+
+

{title}

{children} @@ -172,6 +185,7 @@ export function OrganizationCard({ className, }: Readonly) { const sectionLabels = { ...defaultLabels(locale), ...labels }; + const ctaText = joinText ?? sectionLabels.join; const sortedSocialLinks = socialLinks ? [...socialLinks].sort( (a, b) => getSocialPriority(a.label) - getSocialPriority(b.label), @@ -185,32 +199,32 @@ export function OrganizationCard({ const hasTargetAudience = Boolean(targetAudience); const hasSocialLinks = sortedSocialLinks.length > 0; const hasGallery = Boolean(galleryImages?.length); - const hasJoinCta = Boolean(joinUrl && joinText); + const hasJoinCta = Boolean(joinUrl); return (
-
-
-

+
+
+

{name}

{hasStats && ( -
+
{stats?.map((stat) => (
-
- {stat.label} +
+ {getStatLabel(stat.label)}:
-
+
{stat.value}
@@ -220,10 +234,10 @@ export function OrganizationCard({
-
+
{presentation && ( -
-
+
+
{renderTextContent(presentation)}
@@ -234,17 +248,17 @@ export function OrganizationCard({
{events?.map((event) => { const eventContent = ( -
-

+
+

{event.title}

{event.description && ( -

+

{parseFormattedText(event.description)}

)} {(event.date || event.location) && ( -
+
{event.date && ( @@ -268,6 +282,7 @@ export function OrganizationCard({ href={event.href} target="_blank" rel="noopener noreferrer" + className="block h-full" > {eventContent} @@ -281,15 +296,12 @@ export function OrganizationCard({ {hasActivities && (
-
    +
      {activities?.map((activity, index) => (
    • - {index > 0 && ( - - )} {activity}
    • ))} @@ -303,7 +315,7 @@ export function OrganizationCard({ {departments?.map((department) => (
    • {department}
    • @@ -314,27 +326,27 @@ export function OrganizationCard({ {hasTargetAudience && (
      -
      -
      {renderTextContent(targetAudience)}
      +
      + {renderTextContent(targetAudience)}
      )} {hasSocialLinks && (
      -
      +
      {sortedSocialLinks.map((link) => ( - + {getSocialIcon(link.label, "h-5 w-5")} + ))}
      @@ -342,18 +354,18 @@ export function OrganizationCard({ {hasGallery && (
      -
      +
      {galleryImages?.map((image, index) => (
      {getImageAlt(image,
      ))} @@ -367,11 +379,11 @@ export function OrganizationCard({ href={joinUrl} target="_blank" rel="noopener noreferrer" - className="group flex items-center justify-between gap-4 bg-[#862633] px-5 py-4 text-white transition-colors hover:bg-[#721f2b] sm:px-7" + className="group flex items-center justify-between gap-4 bg-ehk-dark-red px-5 py-4 text-white transition-colors hover:bg-ehk-light-red sm:px-7 md:px-8" > - {joinText} + {ctaText} diff --git a/src/lib/social-utils.tsx b/src/lib/social-utils.tsx index 702e8f5..94034bd 100644 --- a/src/lib/social-utils.tsx +++ b/src/lib/social-utils.tsx @@ -1,76 +1,201 @@ -import { - ExternalLink, - Facebook, - Instagram, - Linkedin, - Youtube, - Globe, - Link as LinkIcon, - Mail, - MessageCircle -} from "lucide-react"; -import { Locale } from "@/i18n-config"; +import { ExternalLink, Globe, Mail } from "lucide-react"; import React from "react"; -export function TiktokIcon({ className }: Readonly<{ className?: string }>) { +import { Locale } from "@/i18n-config"; + +type SocialIconProps = Readonly<{ className?: string }>; + +function FacebookIcon({ className }: SocialIconProps) { + return ( + + ); +} + +function InstagramIcon({ className }: SocialIconProps) { return ( -
      - {content.map((paragraph, index) => ( -

      - {parseFormattedText(paragraph)} -

      - ))} -
      - ); - } - - if (typeof content === "string") { - return

      {parseFormattedText(content)}

      ; - } - - return content; -} - -function resolveImageSrc(image: OrganizationGalleryImage, imageBasePath: string) { - const source = typeof image === "string" ? image : image.src; - if (source.startsWith("/") || source.startsWith("http")) { - return source; - } - - return `${imageBasePath}/${source}`; -} - -function getImageAlt( - image: OrganizationGalleryImage, - organizationName: string, - index: number, -) { - if (typeof image !== "string" && image.alt) { - return image.alt; - } - - return `${organizationName} - ${index + 1}`; -} -function getStatLabel(label: string) { - return label.replace(/:$/, ""); -} - -function Section({ - title, - children, - className, - bordered = true, -}: Readonly<{ - title: string; - children: React.ReactNode; - className?: string; - bordered?: boolean; -}>) { - return ( -
      -

      - {title} -

      - {children} -
      - ); -} +export type { + OrganizationCardLabels, + OrganizationCardProps, + OrganizationEvent, + OrganizationGalleryImage, + OrganizationSocialLink, + OrganizationStat, +} from "@/components/common/organization-card/types"; export function OrganizationCard({ name, @@ -184,23 +40,13 @@ export function OrganizationCard({ locale = "hu", className, }: Readonly) { - const sectionLabels = { ...defaultLabels(locale), ...labels }; - const ctaText = joinText ?? sectionLabels.join; + const sectionLabels = { ...getOrganizationCardLabels(locale), ...labels }; const sortedSocialLinks = socialLinks ? [...socialLinks].sort( (a, b) => getSocialPriority(a.label) - getSocialPriority(b.label), ) : []; - const hasStats = Boolean(stats?.length); - const hasEvents = Boolean(events?.length); - const hasActivities = Boolean(activities?.length); - const hasDepartments = Boolean(departments?.length); - const hasTargetAudience = Boolean(targetAudience); - const hasSocialLinks = sortedSocialLinks.length > 0; - const hasGallery = Boolean(galleryImages?.length); - const hasJoinCta = Boolean(joinUrl); - return (
      -
      -
      -

      - {name} -

      - - {hasStats && ( -
      - {stats?.map((stat) => ( -
      -
      - {getStatLabel(stat.label)}: -
      -
      - {stat.value} -
      -
      - ))} -
      - )} -
      -
      +
      - {presentation && ( -
      -
      - {renderTextContent(presentation)} -
      -
      - )} - - {hasEvents && ( -
      -
      - {events?.map((event) => { - const eventContent = ( -
      -

      - {event.title} -

      - {event.description && ( -

      - {parseFormattedText(event.description)} -

      - )} - {(event.date || event.location) && ( -
      - {event.date && ( - - - {event.date} - - )} - {event.location && ( - - - {event.location} - - )} -
      - )} -
      - ); - - return event.href ? ( - - {eventContent} - - ) : ( -
      {eventContent}
      - ); - })} -
      -
      - )} - - {hasActivities && ( -
      -
        - {activities?.map((activity, index) => ( -
      • - {activity} -
      • - ))} -
      -
      - )} - - {hasDepartments && ( -
      -
        - {departments?.map((department) => ( -
      • - {department} -
      • - ))} -
      -
      - )} - - {hasTargetAudience && ( -
      -
      - {renderTextContent(targetAudience)} -
      -
      - )} - - {hasSocialLinks && ( -
      -
      - {sortedSocialLinks.map((link) => ( - - {getSocialIcon(link.label, "h-5 w-5")} - - ))} -
      -
      - )} - - {hasGallery && ( -
      -
      - {galleryImages?.map((image, index) => ( -
      - {getImageAlt(image, -
      - ))} -
      -
      - )} + + + + + + +
      - {hasJoinCta && ( - - - - {ctaText} - - - - )} + {joinText ?? sectionLabels.join}
      ); } diff --git a/src/components/common/organization-card/sections/ContactsSection.tsx b/src/components/common/organization-card/sections/ContactsSection.tsx new file mode 100644 index 0000000..1b48ebb --- /dev/null +++ b/src/components/common/organization-card/sections/ContactsSection.tsx @@ -0,0 +1,43 @@ +import type { Locale } from "@/i18n-config"; +import { getSocialIcon, getSocialName } from "@/lib/social-utils"; + +import type { OrganizationSocialLink } from "../types"; +import { Section } from "./Section"; + +export function ContactsSection({ + title, + links, + locale, +}: Readonly<{ + title: string; + links: readonly OrganizationSocialLink[]; + locale: Locale; +}>) { + if (!links.length) { + return null; + } + + return ( +
      +
      + {links.map((link) => { + const socialName = getSocialName(link.label, locale); + + return ( + + {getSocialIcon(link.label, "h-5 w-5")} + + ); + })} +
      +
      + ); +} diff --git a/src/components/common/organization-card/sections/EventsSection.tsx b/src/components/common/organization-card/sections/EventsSection.tsx new file mode 100644 index 0000000..f6235c9 --- /dev/null +++ b/src/components/common/organization-card/sections/EventsSection.tsx @@ -0,0 +1,71 @@ +import { CalendarDays, MapPin } from "lucide-react"; + +import { parseFormattedText } from "@/utils/parseFormattedText"; + +import type { OrganizationEvent } from "../types"; +import { Section } from "./Section"; + +function EventCard({ event }: Readonly<{ event: OrganizationEvent }>) { + return ( +
      +

      {event.title}

      + {event.description && ( +

      + {parseFormattedText(event.description)} +

      + )} + {(event.date || event.location) && ( +
      + {event.date && ( + + + {event.date} + + )} + {event.location && ( + + + {event.location} + + )} +
      + )} +
      + ); +} + +export function EventsSection({ + title, + events, +}: Readonly<{ + title: string; + events?: readonly OrganizationEvent[]; +}>) { + if (!events?.length) { + return null; + } + + return ( +
      +
      + {events.map((event) => + event.href ? ( + + + + ) : ( +
      + +
      + ), + )} +
      +
      + ); +} diff --git a/src/components/common/organization-card/sections/GallerySection.tsx b/src/components/common/organization-card/sections/GallerySection.tsx new file mode 100644 index 0000000..3dbad47 --- /dev/null +++ b/src/components/common/organization-card/sections/GallerySection.tsx @@ -0,0 +1,45 @@ +import Image from "next/image"; + +import type { OrganizationGalleryImage } from "../types"; +import { + getImageAlt, + resolveImageSrc, +} from "../utils"; +import { Section } from "./Section"; + +export function GallerySection({ + title, + images, + imageBasePath, + organizationName, +}: Readonly<{ + title: string; + images?: readonly OrganizationGalleryImage[]; + imageBasePath: string; + organizationName: string; +}>) { + if (!images?.length) { + return null; + } + + return ( +
      +
      + {images.map((image, index) => ( +
      + {getImageAlt(image, +
      + ))} +
      +
      + ); +} diff --git a/src/components/common/organization-card/sections/Header.tsx b/src/components/common/organization-card/sections/Header.tsx new file mode 100644 index 0000000..f08c8f2 --- /dev/null +++ b/src/components/common/organization-card/sections/Header.tsx @@ -0,0 +1,36 @@ +import type { OrganizationStat } from "../types"; +import { getStatLabel } from "../utils"; + +export function OrganizationCardHeader({ + name, + stats, +}: Readonly<{ + name: string; + stats?: readonly OrganizationStat[]; +}>) { + return ( +
      +
      +

      + {name} +

      + + {Boolean(stats?.length) && ( +
      + {stats?.map((stat) => ( +
      +
      + {getStatLabel(stat.label)}: +
      +
      {stat.value}
      +
      + ))} +
      + )} +
      +
      + ); +} diff --git a/src/components/common/organization-card/sections/JoinCta.tsx b/src/components/common/organization-card/sections/JoinCta.tsx new file mode 100644 index 0000000..c1014d4 --- /dev/null +++ b/src/components/common/organization-card/sections/JoinCta.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { ArrowRight, Users } from "lucide-react"; + +export function JoinCta({ + href, + children, +}: Readonly<{ + href?: string; + children: React.ReactNode; +}>) { + if (!href) { + return null; + } + + return ( + + + + {children} + + + + ); +} diff --git a/src/components/common/organization-card/sections/ListSections.tsx b/src/components/common/organization-card/sections/ListSections.tsx new file mode 100644 index 0000000..0a79300 --- /dev/null +++ b/src/components/common/organization-card/sections/ListSections.tsx @@ -0,0 +1,55 @@ +import { Section } from "./Section"; + +export function InlineListSection({ + title, + items, +}: Readonly<{ + title: string; + items?: readonly string[]; +}>) { + if (!items?.length) { + return null; + } + + return ( +
      +
        + {items.map((item, index) => ( +
      • + {item} +
      • + ))} +
      +
      + ); +} + +export function DepartmentsSection({ + title, + departments, +}: Readonly<{ + title: string; + departments?: readonly string[]; +}>) { + if (!departments?.length) { + return null; + } + + return ( +
      +
        + {departments.map((department) => ( +
      • + {department} +
      • + ))} +
      +
      + ); +} diff --git a/src/components/common/organization-card/sections/Section.tsx b/src/components/common/organization-card/sections/Section.tsx new file mode 100644 index 0000000..daafad4 --- /dev/null +++ b/src/components/common/organization-card/sections/Section.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +import { cn } from "@/lib/utils"; + +type SectionProps = Readonly<{ + title: string; + children: React.ReactNode; + className?: string; + bordered?: boolean; +}>; + +export function Section({ + title, + children, + className, + bordered = true, +}: SectionProps) { + return ( +
      +

      + {title} +

      + {children} +
      + ); +} diff --git a/src/components/common/organization-card/sections/TextSections.tsx b/src/components/common/organization-card/sections/TextSections.tsx new file mode 100644 index 0000000..32afe71 --- /dev/null +++ b/src/components/common/organization-card/sections/TextSections.tsx @@ -0,0 +1,43 @@ +import type { TextContent } from "../types"; +import { renderTextContent } from "../utils"; +import { Section } from "./Section"; + +export function PresentationSection({ + title, + content, +}: Readonly<{ + title: string; + content?: TextContent; +}>) { + if (!content) { + return null; + } + + return ( +
      +
      + {renderTextContent(content)} +
      +
      + ); +} + +export function RichTextSection({ + title, + content, +}: Readonly<{ + title: string; + content?: TextContent; +}>) { + if (!content) { + return null; + } + + return ( +
      +
      + {renderTextContent(content)} +
      +
      + ); +} diff --git a/src/components/common/organization-card/sections/index.ts b/src/components/common/organization-card/sections/index.ts new file mode 100644 index 0000000..614d730 --- /dev/null +++ b/src/components/common/organization-card/sections/index.ts @@ -0,0 +1,7 @@ +export { ContactsSection } from "./ContactsSection"; +export { DepartmentsSection, InlineListSection } from "./ListSections"; +export { EventsSection } from "./EventsSection"; +export { GallerySection } from "./GallerySection"; +export { JoinCta } from "./JoinCta"; +export { OrganizationCardHeader } from "./Header"; +export { PresentationSection, RichTextSection } from "./TextSections"; diff --git a/src/components/common/organization-card/types.ts b/src/components/common/organization-card/types.ts new file mode 100644 index 0000000..da98ef2 --- /dev/null +++ b/src/components/common/organization-card/types.ts @@ -0,0 +1,59 @@ +import type { ReactNode } from "react"; + +import type { Locale } from "@/i18n-config"; + +export type TextContent = string | readonly string[] | ReactNode; + +export type OrganizationStat = { + label: string; + value: string | number; +}; + +export type OrganizationEvent = { + title: string; + description?: string; + date?: string; + location?: string; + href?: string; +}; + +export type OrganizationGalleryImage = + | string + | { + src: string; + alt?: string; + }; + +export type OrganizationSocialLink = { + label: string; + url: string; +}; + +export type OrganizationCardLabels = { + presentation: string; + events: string; + activities: string; + departments: string; + targetAudience: string; + contacts: string; + gallery: string; + join: string; +}; + +export type OrganizationCardProps = { + name: string; + stats?: readonly OrganizationStat[]; + presentation?: TextContent; + events?: readonly OrganizationEvent[]; + activities?: readonly string[]; + departments?: readonly string[]; + targetAudience?: TextContent; + socialLinks?: readonly OrganizationSocialLink[]; + galleryImages?: readonly OrganizationGalleryImage[]; + imageBasePath?: string; + joinUrl?: string; + joinText?: string; + labels?: Partial; + locale?: Locale; + className?: string; +}; diff --git a/src/components/common/organization-card/utils.tsx b/src/components/common/organization-card/utils.tsx new file mode 100644 index 0000000..bd8ab80 --- /dev/null +++ b/src/components/common/organization-card/utils.tsx @@ -0,0 +1,85 @@ +import React from "react"; + +import type { Locale } from "@/i18n-config"; +import { parseFormattedText } from "@/utils/parseFormattedText"; + +import type { + OrganizationCardLabels, + OrganizationGalleryImage, + TextContent, +} from "./types"; + +const labelsByLocale: Record = { + hu: { + presentation: "Bemutatás", + events: "Kiemelt események", + activities: "Tevékenységek", + departments: "Tagozatok", + targetAudience: "Kinek ajánljuk?", + contacts: "Elérhetőségek", + gallery: "Galéria", + join: "Csatlakozom!", + }, + en: { + presentation: "Presentation", + events: "Featured events", + activities: "Activities", + departments: "Departments", + targetAudience: "Who is it for?", + contacts: "Contact", + gallery: "Gallery", + join: "Join us", + }, +}; + +export function getOrganizationCardLabels(locale: Locale) { + return labelsByLocale[locale] ?? labelsByLocale.hu; +} + +export function renderTextContent(content: TextContent) { + if (Array.isArray(content)) { + return ( +
      + {content.map((paragraph, index) => ( +

      + {parseFormattedText(paragraph)} +

      + ))} +
      + ); + } + + if (typeof content === "string") { + return

      {parseFormattedText(content)}

      ; + } + + return content; +} + +export function resolveImageSrc( + image: OrganizationGalleryImage, + imageBasePath: string, +) { + const source = typeof image === "string" ? image : image.src; + if (source.startsWith("/") || source.startsWith("http")) { + return source; + } + + return `${imageBasePath}/${source}`; +} + +export function getImageAlt( + image: OrganizationGalleryImage, + organizationName: string, + index: number, +) { + if (typeof image !== "string" && image.alt) { + return image.alt; + } + + return `${organizationName} - ${index + 1}`; +} + +export function getStatLabel(label: string) { + return label.replace(/:$/, ""); +}