Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
278 changes: 213 additions & 65 deletions pages/hackathon/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
import { BiTableSchema, TableCellLocation, TableCellUser } from 'mobx-lark';
import { BiTableSchema, TableCellLocation, TableCellUser, TableFormView } from 'mobx-lark';
import { observer } from 'mobx-react';
import Link from 'next/link';
import { cache, compose, errorLogger } from 'next-ssr-middleware';
import { FC, useContext } from 'react';
import {
Badge,
Button,
ButtonGroup,
Card,
Col,
Container,
Dropdown,
DropdownButton,
Row,
} from 'react-bootstrap';
import { Badge, Button, Card, Col, Container, Row } from 'react-bootstrap';
import { text2color, UserRankView } from 'idea-react';
import { formatDate } from 'web-utility';

Expand Down Expand Up @@ -77,78 +67,240 @@ interface HackathonDetailProps {
};
}

const FormButtonBar = ['Person', 'Project', 'Product', 'Evaluation'];
const FormButtonBar = ['Person', 'Project', 'Product', 'Evaluation'] as const;

type FormGroupKey = (typeof FormButtonBar)[number];
type FormLink = TableFormView;

interface FormGroupMeta {
description: I18nKey;
eyebrow: I18nKey;
title: I18nKey;
}

interface FormGroup {
key: FormGroupKey;
list: FormLink[];
Comment thread
TechQuery marked this conversation as resolved.
Outdated
meta: FormGroupMeta;
}

const FormSectionMeta: Record<FormGroupKey, FormGroupMeta> = {
Person: {
eyebrow: 'participants',
title: 'hackathon_participant_registration',
description: 'hackathon_participant_registration_description',
},
Project: {
eyebrow: 'hackathon_team_lead',
title: 'hackathon_project_registration',
description: 'hackathon_project_registration_description',
},
Product: {
eyebrow: 'hackathon_submission',
title: 'product_submission',
description: 'hackathon_product_submission_description',
},
Evaluation: {
eyebrow: 'hackathon_review',
title: 'hackathon_evaluation_entry',
description: 'hackathon_evaluation_entry_description',
},
};

const isPublicForm = ({ shared, shared_limit }: FormLink) =>
shared || ['tenant_editable', 'anyone_editable'].includes(shared_limit as string);

const HackathonDetail: FC<HackathonDetailProps> = observer(({ activity, hackathon }) => {
const { t } = useContext(I18nContext);

const { name, summary, location, startTime, endTime, databaseSchema } = activity,
const {
name,
summary,
location,
startTime,
endTime,
databaseSchema,
host,
image,
type: activityType,
} = activity,
{ people, organizations, agenda, prizes, templates, projects } = hackathon;
const { forms } = databaseSchema as BiTableSchema;
const forms = (((databaseSchema as BiTableSchema).forms || {}) as Partial<
Record<FormGroupKey, FormLink[]>
>)!;
const formGroups = FormButtonBar.flatMap<FormGroup>(key => {
const list = forms[key]?.filter(isPublicForm) || [];

return list[0] ? [{ key, list, meta: FormSectionMeta[key] }] : [];
});
const primaryForm =
formGroups.find(({ key }) => key === 'Person') ||
formGroups.find(({ key }) => key === 'Project') ||
formGroups[0];
const secondaryForm =
formGroups.find(({ key }) => key === 'Project' && key !== primaryForm?.key) ||
formGroups.find(({ key }) => key !== primaryForm?.key);
const heroStats = [
{ label: t('participants'), value: people.length },
{ label: t('projects'), value: projects.length },
{ label: t('templates'), value: templates.length },
{ label: t('organizations'), value: organizations.length },
];
const hostTags = (host as string[] | undefined)?.slice(0, 3) || [];
const agendaPreview = agenda.slice(0, 3);

return (
<>
<PageHead title={name as string} />

{/* Hero Section */}
<section className={styles.hero}>
<Container>
<h1 className={`text-center ${styles.title}`}>{name as string}</h1>
<p className={`text-center ${styles.description}`}>{summary as string}</p>
<Row className="align-items-center g-4">
<Col lg={7}>
<ul className="list-unstyled d-flex flex-wrap gap-3 mb-3">
<li className={styles.heroTag}>{(activityType as string) || t('hackathon')}</li>
{hostTags.map(tag => (
<li key={tag} className={styles.heroTag}>
{tag}
</li>
))}
</ul>

<Row className="mt-4 justify-content-center">
<Col md={4}>
<Card className={styles.infoCard}>
<Card.Body>
<h5 className="text-white mb-2">📍 {t('event_location')}</h5>
<p className="text-white-50 mb-0">
{(location as TableCellLocation)?.full_address}
</p>
</Card.Body>
</Card>
<h1 className={styles.title}>{name as string}</h1>
<p className={styles.description}>{summary as string}</p>

<ul className="list-unstyled d-flex flex-wrap gap-3 mt-4 mb-0">
{heroStats.map(({ label, value }) => (
<li key={label} className={styles.statChip}>
{value} {label}
</li>
))}
</ul>

<nav className="d-flex flex-wrap gap-3 mt-4">
{primaryForm && (
<Button href={primaryForm.list[0].shared_url} target="_blank" rel="noreferrer">
{t(primaryForm.meta.title)}
</Button>
)}
{secondaryForm && (
<Button
href={secondaryForm.list[0].shared_url}
target="_blank"
rel="noreferrer"
variant="light"
>
{t(secondaryForm.meta.title)}
</Button>
)}
{formGroups[0] && (
<Button href="#entry-hub" variant="outline-light">
{t('hackathon_view_all_entries')}
</Button>
)}
</nav>

<Row className="mt-4 g-3" md={2}>
<Col>
<Card className={styles.infoCard} body>
<h5 className="text-white mb-2">📍 {t('event_location')}</h5>
<address className="text-white-50 mb-0 fst-normal">
{(location as TableCellLocation)?.full_address}
</address>
</Card>
</Col>
<Col>
<Card className={styles.infoCard} body>
<h5 className="text-white mb-2">⏰ {t('event_duration')}</h5>
<p className="text-white-50 mb-0">
<time dateTime={startTime as string}>{formatDate(startTime as string)}</time>{' '}
- <time dateTime={endTime as string}>{formatDate(endTime as string)}</time>
</p>
</Card>
</Col>
</Row>
</Col>
<Col md={4}>
<Card className={styles.infoCard}>

<Col lg={5}>
<Card className={styles.heroVisualCard}>
{image && (
<div className={styles.heroImageWrap}>
<LarkImage src={image} alt={name as string} className={styles.heroImage} />
</div>
)}
<Card.Body>
<h5 className="text-white mb-2">⏰ {t('event_duration')}</h5>
<p className="text-white-50 mb-0">
{formatDate(startTime as string)} - {formatDate(endTime as string)}
</p>
{agendaPreview[0] && (
<dl className="d-grid gap-3 mb-0">
<div className={`${styles.heroVisualHead} ${styles.heroVisualItem}`}>
<dt className={styles.heroVisualKicker}>{t('hackathon_agenda_preview')}</dt>
<dd className="fw-semibold">{agendaPreview[0].name as string}</dd>
</div>

{agendaPreview.map(({ name, startedAt, endedAt }) => (
<div
key={`${name as string}-${startedAt as string}`}
className={styles.heroVisualItem}
>
<dt className="fw-semibold">{name as string}</dt>
<dd>
<time dateTime={startedAt as string}>
{formatDate(startedAt as string)}
</time>{' '}
-{' '}
<time dateTime={endedAt as string}>
{formatDate(endedAt as string)}
</time>
</dd>
</div>
))}
</dl>
)}
</Card.Body>
</Card>
</Col>
</Row>

<ButtonGroup className="d-flex mt-3">
{FormButtonBar.map((key, index) => {
const list = forms[key]?.filter(
// @ts-expect-error Upstream types bug
({ shared_limit }) => shared_limit === 'anyone_editable',
);

return !list?.[0] ? null : list.length < 2 ? (
<Button href={list[0].shared_url} target="_blank" rel="noreferrer">
{index + 1}. {list[0].name}
</Button>
) : (
<DropdownButton
as={ButtonGroup}
title={`${index + 1}. ${t('product_submission')}`}
id={`dropdown-${key}`}
>
{list.map(({ name, shared_url }) => (
<Dropdown.Item key={name} href={shared_url} target="_blank" rel="noreferrer">
{name}
</Dropdown.Item>
))}
</DropdownButton>
);
})}
</ButtonGroup>
</Container>
</section>

<Container className="my-5">
{formGroups[0] && (
<section id="entry-hub" className={styles.section}>
<hgroup className="mb-0">
<p className={styles.sectionEyebrow}>{t('hackathon_action_hub')}</p>
<h2 className={styles.sectionTitle}>{t('hackathon_entry_flow')}</h2>
<p className={styles.sectionLead}>{t('hackathon_entry_flow_description')}</p>
</hgroup>

<Row as="ol" className="list-unstyled mt-4 g-3" md={2} xl={4}>
{formGroups.map(({ key, list, meta }, index) => (
<Col as="li" key={key}>
<Card className={styles.entryCard} body>
<span className={styles.entryStep}>
{t('hackathon_step')} {String(index + 1).padStart(2, '0')} ·{' '}
{t(meta.eyebrow)}
</span>
<h3 className="h5 text-white mt-2">{t(meta.title)}</h3>
<p className="text-white-50 mb-3">{t(meta.description)}</p>
<nav className="d-grid gap-2">
{list.map(({ name, shared_url }) => (
<Button
key={name}
href={shared_url}
target="_blank"
rel="noreferrer"
variant="light"
>
{name}
</Button>
))}
</nav>
</Card>
</Col>
))}
</Row>
</section>
)}

<section className={`${styles.section} ${styles.prizeSection}`}>
<h2 className={styles.sectionTitle}>🏆 {t('prizes')}</h2>
<div className="mt-4">
Expand Down Expand Up @@ -187,7 +339,6 @@ const HackathonDetail: FC<HackathonDetailProps> = observer(({ activity, hackatho
</ol>
</section>

{/* Mid-front: Organizations - Horizontal logo layout */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>🏢 {t('organizations')}</h2>
<nav className={styles.orgContainer}>
Expand All @@ -205,7 +356,6 @@ const HackathonDetail: FC<HackathonDetailProps> = observer(({ activity, hackatho
</nav>
</section>

{/* Mid-back: Templates - Using GitCard, 3-4 per row */}
<section className={`${styles.section} ${styles.templateSection}`}>
<h2 className={styles.sectionTitle}>🛠️ {t('templates')}</h2>
<Row className="mt-4 g-3" md={2} lg={3} xl={4}>
Expand All @@ -224,7 +374,6 @@ const HackathonDetail: FC<HackathonDetailProps> = observer(({ activity, hackatho
</Row>
</section>

{/* Mid-back: Projects - Narrow cards, 3-4 per row */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>💡 {t('projects')}</h2>

Expand Down Expand Up @@ -259,7 +408,6 @@ const HackathonDetail: FC<HackathonDetailProps> = observer(({ activity, hackatho
</Row>
</section>

{/* Footer: Participants - Circular avatars only */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>👥 {t('participants')}</h2>
<nav className={styles.participantCloud}>
Expand Down
Loading