+
-
- {/* 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.social_title}
-
-
-
-
-
- {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.social_title}
-
-
-
-
- );
- })}
+ {(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 1c1c332..7f50d80 100644
--- a/src/app/(app)/[lang]/kozelet/versenycsapatok/page.tsx
+++ b/src/app/(app)/[lang]/kozelet/versenycsapatok/page.tsx
@@ -1,28 +1,65 @@
+import {
+ OrganizationCard,
+ type OrganizationCardProps,
+} 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[];
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,
}: {
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 +68,20 @@ export default async function VersenycsapatokPage({
return (
-
+
-
-
- {data.mvk.title}
-
-
-
- {data.mvk.description.map((para: string) => (
-
{para}
- ))}
-
-
-
-
{data.mvk.social_title}
-
-
-
-
-
- {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.social_title}
-
-
-
-
- );
- })}
+ {(data.teams as Organization[]).map((team) => (
+
+ ))}
diff --git a/src/components/common/OrganizationCard.tsx b/src/components/common/OrganizationCard.tsx
new file mode 100644
index 0000000..750a95f
--- /dev/null
+++ b/src/components/common/OrganizationCard.tsx
@@ -0,0 +1,93 @@
+import {
+ ContactsSection,
+ DepartmentsSection,
+ EventsSection,
+ GallerySection,
+ InlineListSection,
+ JoinCta,
+ OrganizationCardHeader,
+ PresentationSection,
+ RichTextSection,
+} from "@/components/common/organization-card/sections";
+import type { OrganizationCardProps } from "@/components/common/organization-card/types";
+import { getOrganizationCardLabels } from "@/components/common/organization-card/utils";
+import { getSocialPriority } from "@/lib/social-utils";
+import { cn } from "@/lib/utils";
+
+export type {
+ OrganizationCardLabels,
+ OrganizationCardProps,
+ OrganizationEvent,
+ OrganizationGalleryImage,
+ OrganizationSocialLink,
+ OrganizationStat,
+} from "@/components/common/organization-card/types";
+
+export function OrganizationCard({
+ name,
+ stats,
+ presentation,
+ events,
+ activities,
+ departments,
+ targetAudience,
+ socialLinks,
+ galleryImages,
+ imageBasePath = "",
+ joinUrl,
+ joinText,
+ labels,
+ locale = "hu",
+ className,
+}: Readonly
) {
+ const sectionLabels = { ...getOrganizationCardLabels(locale), ...labels };
+ const sortedSocialLinks = socialLinks
+ ? [...socialLinks].sort(
+ (a, b) => getSocialPriority(a.label) - getSocialPriority(b.label),
+ )
+ : [];
+
+ return (
+
+
+
+
+
+ {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 (
+
+ );
+}
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) => (
+
+
+
+ ))}
+
+
+ );
+}
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(/:$/, "");
+}
diff --git a/src/lib/social-utils.tsx b/src/lib/social-utils.tsx
index 9ce858b..94034bd 100644
--- a/src/lib/social-utils.tsx
+++ b/src/lib/social-utils.tsx
@@ -1,72 +1,201 @@
-import {
- ExternalLink,
- Facebook,
- Instagram,
- Linkedin,
- Youtube,
- Globe,
- Link as LinkIcon,
- 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 (
+
+ );
+}
+
+function LinkedInIcon({ className }: SocialIconProps) {
+ return (
+
+ );
+}
+
+function YouTubeIcon({ className }: SocialIconProps) {
+ return (
+
+ );
+}
+
+export function TiktokIcon({ className }: SocialIconProps) {
+ return (
+
+ );
+}
+
+export function XSocialIcon({ className }: SocialIconProps) {
return (
-