Skip to content
Merged
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
Binary file added public/assets/images/landing-2026.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions src/components/Footer/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { render, screen } from "@testing-library/react";
import NeiLogo from "~/public/assets/images/logo-white.png";
import { expect, test, vi } from "vitest";

import Footer from "@/components/Footer";

vi.mock("next/image", () => ({ default: () => null }));
vi.mock("@/components/InstallButton", () => ({
default: () => <button>Install</button>,
}));

test("landing footer retains social links without application controls", () => {
render(<Footer neiLogoSrc={NeiLogo} landing />);
expect(screen.getByRole("link", { name: "Instagram" })).toHaveAttribute(
"href",
"https://www.instagram.com/nei_isep"
);
expect(
screen.queryByRole("link", { name: "Política de Privacidade" })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Install" })
).not.toBeInTheDocument();
});

test("profile footer keeps the existing application controls", () => {
render(<Footer neiLogoSrc={NeiLogo} />);
expect(
screen.getByRole("link", { name: "Política de Privacidade" })
).toHaveAttribute("href", "/privacy-policy");
expect(screen.getByRole("button", { name: "Install" })).toBeInTheDocument();
});
32 changes: 20 additions & 12 deletions src/components/Footer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ import InstallButton from "@/components/InstallButton";

interface FooterProps {
neiLogoSrc: StaticImageData;
landing?: boolean;
}

const Footer: FunctionComponent<FooterProps> = ({ neiLogoSrc }) => {
const Footer: FunctionComponent<FooterProps> = ({
neiLogoSrc,
landing = false,
}) => {
const currentYear = new Date().getFullYear();
const NEI_WEBSITE_URL = "https://nei-isep.org";

return (
<footer className="flex w-full flex-col items-center gap-10 bg-background px-6 pt-12 text-center">
<footer
className={`flex w-full flex-col items-center gap-10 bg-background px-6 text-center ${landing ? "pt-24" : "pt-12"}`}
>
<div className="flex w-full flex-col items-center gap-8 lg:flex-row lg:items-center lg:justify-around">
<div className="max-h-50 max-w-55 transition-all duration-300 hover:scale-105 sm:max-w-[275px] lg:max-w-80">
<a href={NEI_WEBSITE_URL} target="_blank" rel="noreferrer">
Expand Down Expand Up @@ -82,17 +88,19 @@ const Footer: FunctionComponent<FooterProps> = ({ neiLogoSrc }) => {
<FaLink size={45} />
</Link>
</div>
<div className="flex flex-wrap items-center justify-center gap-5">
<InstallButton className="scale-180 text-xl drop-shadow-xl transition-all duration-300 hover:scale-190" />
{!landing && (
<div className="flex flex-wrap items-center justify-center gap-5">
<InstallButton className="scale-180 text-xl drop-shadow-xl transition-all duration-300 hover:scale-190" />

<Link
href="/privacy-policy"
title="Política de Privacidade"
className="text-xl drop-shadow-xl transition-all duration-300 hover:scale-110"
>
<MdOutlinePrivacyTip color="text" size={45} />
</Link>
</div>
<Link
href="/privacy-policy"
title="Política de Privacidade"
className="text-xl drop-shadow-xl transition-all duration-300 hover:scale-110"
>
<MdOutlinePrivacyTip color="text" size={45} />
</Link>
</div>
)}
</div>
</div>

Expand Down
78 changes: 78 additions & 0 deletions src/components/HomePage/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { act, render, screen, within } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { afterEach, beforeEach, expect, test, vi } from "vitest";

import HomePage from "@/components/HomePage";
import { Language } from "@/domain/i18n/translations";
import { branding } from "@/edition/branding";

vi.mock("@/components/Footer", () => ({ default: () => <footer /> }));

const startsAt = Date.parse(branding.event.startsAt);
const values = () =>
within(screen.getByRole("timer"))
.getAllByRole("definition")
.map((element) => element.textContent);

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

test("renders hydration-safe placeholders before the browser clock starts", () => {
const html = renderToString(<HomePage language={Language.PT} />);
expect(html.match(/>-</g)).toHaveLength(4);
});

test("counts down to 17 November at 08:30 Lisbon time", () => {
expect(branding.event.startsAt).toBe("2026-11-17T08:30:00+00:00");
vi.setSystemTime(startsAt - (86400 + 3600 + 60 + 1) * 1000);
render(<HomePage language={Language.PT} />);
expect(values()).toEqual(["01", "01"]);
expect(screen.getByText("17 de novembro de 2026")).toHaveAttribute(
"dateTime",
branding.event.startsAt
);
expect(screen.getByText(/serão anunciados em breve/)).toBeInTheDocument();
});

test("updates every second and rolls days into hours", () => {
vi.setSystemTime(startsAt - 86400 * 1000);
render(<HomePage language={Language.EN} />);
expect(values()).toEqual(["01", "00"]);
act(() => vi.advanceTimersByTime(1000));
expect(values()).toEqual(["00", "23"]);
expect(screen.getByText("days")).toBeInTheDocument();
expect(screen.getByText(/will be announced soon/)).toBeInTheDocument();
});

test("rolls hours down as the deadline approaches", () => {
vi.setSystemTime(startsAt - 3600000 - 500);
render(<HomePage language={Language.EN} />);
expect(values()).toEqual(["00", "01"]);
act(() => vi.advanceTimersByTime(2000));
expect(values()).toEqual(["00", "00"]);
expect(screen.getByText(/will be announced soon/)).toBeInTheDocument();
});

test("already expired countdown stays at zero without revealing old content", () => {
vi.setSystemTime(startsAt + 86400 * 1000);
render(<HomePage language={Language.PT} />);
expect(values()).toEqual(["00", "00"]);
expect(screen.queryByText("Speed Interviews")).not.toBeInTheDocument();
expect(
screen.queryByRole("link", { name: /registar/ })
).not.toBeInTheDocument();
});

test("renders every digit when more than 99 days remain", () => {
vi.setSystemTime(startsAt - (100 * 86400 + 3600) * 1000);
render(<HomePage language={Language.EN} />);
expect(values()).toEqual(["100", "01"]);
});

test("cleans up the timer when leaving the homepage", () => {
vi.setSystemTime(startsAt - 10000);
const { unmount } = render(<HomePage language={Language.PT} />);
expect(vi.getTimerCount()).toBe(1);
unmount();
expect(vi.getTimerCount()).toBe(0);
});
99 changes: 80 additions & 19 deletions src/components/HomePage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,98 @@
"use client";

import { FunctionComponent, useRef } from "react";
import LogoWhite from "~/public/assets/images/logo_white.svg";
import NeiLogoSimplifiedWhite from "~/public/assets/images/logo-white.png";
import { FunctionComponent, useEffect, useState } from "react";
import NeiLogoSimplifiedWhite from "~/public/assets/images/logo-simplified-white.png";

import Content from "@/components/Content";
import Footer from "@/components/Footer";
import GenericContainer from "@/components/GenericContainer";
import HeadsUp from "@/components/HeadsUp";
import Hero from "@/components/Hero";
import HeroContainer from "@/components/HeroContainer";
import type { Language } from "@/domain/i18n/translations";
import { Language } from "@/domain/i18n/translations";
import { branding } from "@/edition/branding";

interface HomePageProps {
language: Language;
}

const HomePage: FunctionComponent<HomePageProps> = ({ language }) => {
const contentRef = useRef<HTMLDivElement>(null);
const [remaining, setRemaining] = useState<number | null>(null);
const isEnglish = language === Language.EN;

useEffect(() => {
const update = () =>
setRemaining(
Math.max(
0,
Math.ceil((Date.parse(branding.event.startsAt) - Date.now()) / 1000)
)
);
update();
const interval = window.setInterval(update, 1000);
return () => window.clearInterval(interval);
}, []);

const units = [
{ label: isEnglish ? "days" : "dias", seconds: 86400, modulo: Infinity },
{ label: isEnglish ? "hours" : "horas", seconds: 3600, modulo: 24 },
];

return (
<HeroContainer>
<GenericContainer>
<Hero
logoSrc={LogoWhite}
logoAlt={branding.heroLogoAlt}
contentRef={contentRef}
/>
<Content contentRef={contentRef} language={language} />
<HeadsUp />
<Footer neiLogoSrc={NeiLogoSimplifiedWhite} />
</GenericContainer>
{/* shortcut: artwork includes the 2026 logo; export a fresh frame for a later edition. */}
<section className="relative h-svh min-h-160 w-full bg-background bg-[url('/assets/images/landing-2026.png')] bg-cover bg-center">
<div className="sr-only">
<h1>
{branding.name} {branding.year}
</h1>
<p>
<time dateTime={branding.event.startsAt}>
{new Intl.DateTimeFormat(isEnglish ? "en-GB" : "pt-PT", {
dateStyle: "long",
timeZone: "Europe/Lisbon",
}).format(new Date(branding.event.startsAt))}
</time>
{" · "}
{branding.event.venue}
</p>
</div>
<dl
role="timer"
aria-label={
isEnglish ? "Time until Fallstack" : "Tempo até ao Fallstack"
}
className="absolute inset-x-4 bottom-[13.85%] flex flex-wrap justify-center gap-3 font-title text-[clamp(1.5rem,4.3vw,4rem)] leading-none text-white sm:gap-x-6"
>
{units.map(({ label, seconds, modulo }) => (
<div key={label} className="flex gap-1.5 sm:gap-2">
<dt className="order-last flex h-[clamp(3rem,6.3vw,5.9375rem)] items-center rounded-xl bg-background px-[clamp(0.5rem,1.3vw,1.25rem)] sm:rounded-3xl">
{label}
</dt>
<dd className="flex gap-1.5 tabular-nums sm:gap-2">
{(remaining === null
? "--"
: String(Math.floor(remaining / seconds) % modulo).padStart(
2,
"0"
)
)
.split("")
.map((digit, index) => (
<span
key={index}
className="flex h-[clamp(3rem,6.3vw,5.9375rem)] w-[clamp(2rem,7.2vw,6.75rem)] items-center justify-center rounded-xl bg-background sm:rounded-3xl"
>
{digit}
</span>
))}
</dd>
</div>
))}
</dl>
<p className="sr-only">
{isEnglish
? "We're preparing the next edition. The programme, companies and more details will be announced soon."
: "Estamos a preparar a próxima edição. O programa, as empresas e mais novidades serão anunciados em breve."}
</p>
</section>
<Footer neiLogoSrc={NeiLogoSimplifiedWhite} landing />
</HeroContainer>
);
};
Expand Down
32 changes: 32 additions & 0 deletions src/components/InstallPopUp/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { usePathname } from "next/navigation";
import { render, screen } from "@testing-library/react";
import { beforeEach, expect, test, vi } from "vitest";

import InstallableContext from "@/contexts/InstallableContext";
import InstallPopUp from "@/components/InstallPopUp";

vi.mock("next/navigation", () => ({ usePathname: vi.fn() }));
vi.mock("@/hooks/useIsMobile", () => ({ default: () => false }));

beforeEach(() => localStorage.clear());

function renderPrompt(pathname: string) {
vi.mocked(usePathname).mockReturnValue(pathname);
return render(
<InstallableContext.Provider
value={{ isInstallable: true, handleConfirm: vi.fn() }}
>
<InstallPopUp />
</InstallableContext.Provider>
);
}

test("does not cover the landing with an install prompt", () => {
renderPrompt("/");
expect(screen.queryByText(/Adiciona o Fallstack/)).not.toBeInTheDocument();
});

test("retains the install prompt on application pages", () => {
renderPrompt("/dashboard");
expect(screen.getByText(/Adiciona o Fallstack/)).toBeInTheDocument();
});
4 changes: 3 additions & 1 deletion src/components/InstallPopUp/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";

import config from "@/config";
Expand All @@ -10,6 +11,7 @@ import PrimaryButton from "@/components/ui/PrimaryButton";

const CookieConsent: React.FC = () => {
const isMobile = useIsMobile();
const pathname = usePathname();
const [visible, setVisible] = useState(false);
const { isInstallable, handleConfirm } = useContext(InstallableContext);

Expand All @@ -27,7 +29,7 @@ const CookieConsent: React.FC = () => {

return (
<AnimatePresence>
{visible && (
{visible && pathname !== "/" && (
<motion.div
className="fixed -bottom-40 left-0 z-20 flex w-full flex-col items-center justify-between gap-4 border-t-gray-100 bg-background px-8 py-4 shadow-lg shadow-black md:-bottom-20 md:flex-row"
animate={{ y: [0, position] }}
Expand Down
26 changes: 26 additions & 0 deletions src/components/TopBar/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { usePathname } from "next/navigation";
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";

import TopBar from "@/components/TopBar";

vi.mock("next/navigation", () => ({ usePathname: vi.fn() }));
vi.mock("@/hooks/useSession", () => ({ default: () => ({ user: null }) }));
vi.mock("@/components/LogoutButton", () => ({ default: () => null }));
vi.mock("@/components/Profile/UserButton", () => ({ default: () => null }));

test("keeps account navigation off the temporary landing", () => {
vi.mocked(usePathname).mockReturnValue("/");
render(<TopBar />);
expect(screen.queryByRole("navigation")).not.toBeInTheDocument();
});

test("keeps account navigation available on other pages", () => {
vi.mocked(usePathname).mockReturnValue("/privacy-policy");
render(<TopBar />);
expect(screen.getByRole("navigation")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Iniciar sessão" })).toHaveAttribute(
"href",
"/login"
);
});
2 changes: 2 additions & 0 deletions src/components/TopBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const TopBar: React.FC<TopBarProps> = ({ solid = false }) => {
);
const opacity = useTransform(() => scrollYProgress.get() * 2.2);

if (pathname === "/") return null;

return (
<nav className={`fixed z-40 h-16 w-full overflow-hidden`}>
{solid ? (
Expand Down
3 changes: 2 additions & 1 deletion src/edition/branding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const branding = {
name: "Fallstack",
year: 2026,
event: {
startsAt: "2026-11-17T08:30:00+00:00",
days: [17, 18],
month: "novembro",
beginningTime: "8:30h",
Expand All @@ -13,7 +14,7 @@ export const branding = {
applicationName: "Fallstack 2026",
title: "Fallstack 2026",
description:
"Website do evento Fallstack 2026. O evento decorrerá nos dias 17 e 18 de novembro no Instituto Superior de Engenharia do Porto. O evento é organizado pelo Núcleo de Estudantes de Informática do ISEP (NEI-ISEP).",
"O Fallstack 2026 está a chegar ao ISEP a 17 de novembro. Estamos a preparar a próxima edição; o programa, as empresas e mais novidades serão anunciados em breve. Organização do Núcleo de Estudantes de Informática do ISEP (NEI-ISEP).",
},
storage: {
authCookie: "@Fallstack26:auth",
Expand Down
Loading