Skip to content

Commit 651391b

Browse files
Enhance landing page (#39)
* Add the challenges to landing page * Add tooltips with descriptions * Change layout a little * Move start back up * Enhance landing page with challenge list and background invaders * Refactor challenge data into useFetchUserData hook * Improve tooltip contrast * Adjust styling slightly * tooltip left --------- Co-authored-by: Carlos Sánchez <oceanrdn@gmail.com>
1 parent 114a91b commit 651391b

File tree

8 files changed

+226
-111
lines changed

8 files changed

+226
-111
lines changed

packages/nextjs/app/page.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@ export const metadata = getMetadata({
1010

1111
const Home: NextPage = () => {
1212
return (
13-
<div className="pt-4 pb-20">
14-
<div className="mx-auto max-w-4xl px-6">
15-
<HeroInvaders />
16-
</div>
13+
<div className="flex flex-col items-center pt-4 pb-20 px-6">
14+
<HeroInvaders />
1715
</div>
1816
);
1917
};

packages/nextjs/components/HeroInvaders.tsx

Lines changed: 109 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import clsx from "clsx";
77
import { useAccount } from "wagmi";
88
import { PlayIcon } from "@heroicons/react/24/solid";
99
import { useFetchUserData } from "~~/hooks/useFetchUserData";
10-
import { TOTAL_CHALLENGES } from "~~/utils/getChallenges";
10+
import { CHALLENGE_DESCRIPTIONS, CHALLENGE_NAMES, SEASONS, TOTAL_CHALLENGES } from "~~/utils/getChallenges";
1111

1212
const invaderClass = "mx-auto w-10 h-10 md:w-12 md:h-12 cursor-crosshair";
1313
const gridClass = "mx-auto my-6 md:my-8 grid grid-cols-4 gap-4";
@@ -18,7 +18,7 @@ export function HeroInvaders() {
1818
const [rowThreeMove, setRowThreeMove] = useState("translate-x-0");
1919

2020
const { address: connectedAddress } = useAccount();
21-
const { hasCompletedChallenge1 } = useFetchUserData({ address: connectedAddress });
21+
const { hasCompletedChallenge1, challengesBySeasons } = useFetchUserData({ address: connectedAddress });
2222

2323
useEffect(() => {
2424
const interval = setInterval(() => {
@@ -52,48 +52,117 @@ export function HeroInvaders() {
5252

5353
return (
5454
<>
55-
<div>
56-
<div className={clsx(gridClass, rowOneMove)}>
57-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-1.svg" alt="" />
58-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-2.svg" alt="" />
59-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-3.svg" alt="" />
60-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-4.svg" alt="" />
55+
{/* Hero Section with Background Invaders */}
56+
<div className="relative min-h-[400px] flex flex-col items-center justify-center py-12 w-full max-w-3xl">
57+
{/* Animated Invaders Background */}
58+
<div className="absolute inset-0 opacity-20 pointer-events-none overflow-hidden">
59+
<div className={clsx(gridClass, rowOneMove)}>
60+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-1.svg" alt="" />
61+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-2.svg" alt="" />
62+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-3.svg" alt="" />
63+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-4.svg" alt="" />
64+
</div>
65+
<div className={clsx(gridClass, rowTwoMove)}>
66+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-5.svg" alt="" />
67+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-6.svg" alt="" />
68+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-7.svg" alt="" />
69+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-8.svg" alt="" />
70+
</div>
71+
<div className={clsx(gridClass, rowThreeMove)}>
72+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-9.svg" alt="" />
73+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-10.svg" alt="" />
74+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-11.svg" alt="" />
75+
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-12.svg" alt="" />
76+
</div>
6177
</div>
62-
<div className={clsx(gridClass, rowTwoMove)}>
63-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-5.svg" alt="" />
64-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-6.svg" alt="" />
65-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-7.svg" alt="" />
66-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-8.svg" alt="" />
67-
</div>
68-
<div className={clsx(gridClass, rowThreeMove)}>
69-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-9.svg" alt="" />
70-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-10.svg" alt="" />
71-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-11.svg" alt="" />
72-
<Image width={96} height={96} className={invaderClass} src="/season-1/invader-12.svg" alt="" />
78+
79+
{/* Hero Content */}
80+
<div className="relative z-10">
81+
<div className="mx-auto px-12 max-w-60">
82+
<Image width={112} height={80} className="w-full h-auto" src="/fortress-noflag.svg" alt="" />
83+
</div>
84+
<div className="mt-8 text-center">
85+
<h1 className="md:text-2xl font-pressStart tracking-wide leading-relaxed">Solidity Invaders</h1>
86+
<p className="mt-6 text-lg md:text-xl/8">
87+
ALERT! Invaders have taken {TOTAL_CHALLENGES} flags from the BuidlGuidl Fortress across multiple seasons.
88+
Your mission is to complete Ethereum coding challenges and reclaim all of the flags. Each season brings
89+
new invaders and tougher challenges!
90+
</p>
91+
</div>
92+
{/* Start/Continue Button */}
93+
<div className="text-center mt-8">
94+
{!hasCompletedChallenge1 && (
95+
<Link href="/bangkok/challenges/1" className="pl-8 pr-6 btn btn-primary btn-outline font-pressStart">
96+
Start <PlayIcon className="h-6 w-6" />
97+
</Link>
98+
)}
99+
{hasCompletedChallenge1 && (
100+
<Link
101+
href={`/profile/${connectedAddress}`}
102+
className="pl-8 pr-6 btn btn-primary btn-outline font-pressStart"
103+
>
104+
Continue <PlayIcon className="h-6 w-6" />
105+
</Link>
106+
)}
107+
</div>
73108
</div>
74109
</div>
75-
<div className="text-center my-12">
76-
{!hasCompletedChallenge1 && (
77-
<Link href="/bangkok/challenges/1" className="pl-8 pr-6 btn btn-primary btn-outline font-pressStart">
78-
Start <PlayIcon className="h-6 w-6" />
79-
</Link>
80-
)}
81-
{hasCompletedChallenge1 && (
82-
<Link href={`/profile/${connectedAddress}`} className="pl-8 pr-6 btn btn-primary btn-outline font-pressStart">
83-
Continue <PlayIcon className="h-6 w-6" />
84-
</Link>
85-
)}
86-
</div>
87-
<div className="mx-auto px-12 max-w-60">
88-
<Image width={112} height={80} className="w-full h-auto" src="/fortress-noflag.svg" alt="" />
110+
111+
{/* Challenge Grid - 2 Columns (1 per season) */}
112+
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-3xl">
113+
{Object.entries(challengesBySeasons)
114+
.sort(([a], [b]) => Number(a) - Number(b))
115+
.map(([season, challenges]) => (
116+
<div key={season}>
117+
<p className="m-0 mb-4 text-sm text-gray-400 font-pressStart">
118+
{SEASONS[Number(season)]?.name ?? `Season ${season}`}
119+
</p>
120+
<div className="space-y-2">
121+
{challenges.map(challenge => {
122+
const seasonNum = Number(season);
123+
const challengeId = Number(challenge.challengeId);
124+
const slug = SEASONS[seasonNum]?.slug ?? "bangkok";
125+
const description = CHALLENGE_DESCRIPTIONS[seasonNum]?.[challenge.challengeId] ?? "";
126+
return (
127+
<div key={challenge.challengeId} className="tooltip tooltip-left w-full" data-tip={description}>
128+
<Link
129+
href={`/${slug}/challenges/${challengeId}`}
130+
className={clsx(
131+
"flex items-center gap-3 p-2 rounded-md hover:bg-gray-900 transition-colors",
132+
challenge.solved && "bg-gray-900/50",
133+
)}
134+
>
135+
<Image
136+
width={32}
137+
height={32}
138+
className="w-8 h-8 shrink-0"
139+
src={`/season-${season}/invader-${challengeId}.svg`}
140+
alt=""
141+
/>
142+
<span className={clsx("text-sm", challenge.solved && "text-primary")}>
143+
{CHALLENGE_NAMES[seasonNum]?.[challenge.challengeId] ?? `Challenge ${challengeId}`}
144+
</span>
145+
{challenge.solved && <span className="text-primary text-xs ml-auto"></span>}
146+
</Link>
147+
</div>
148+
);
149+
})}
150+
</div>
151+
</div>
152+
))}
89153
</div>
90-
<div className="mt-12 text-center">
91-
<h1 className="md:text-2xl font-pressStart tracking-wide leading-relaxed">Solidity Invaders</h1>
92-
<p className="mx-auto mt-6 text-lg md:text-xl/8">
93-
ALERT! Invaders have taken {TOTAL_CHALLENGES} flags from the BuidlGuidl Fortress across multiple seasons. Your
94-
mission is to complete Ethereum coding challenges and reclaim all of the flags. Each season brings new
95-
invaders and tougher challenges!
96-
</p>
154+
155+
{/* Telegram CTA */}
156+
<div className="mt-16 text-center">
157+
<p className="text-gray-400 mb-4">Got questions or need help?</p>
158+
<a
159+
href="https://t.me/+B9dXIETeXBswOWYy"
160+
target="_blank"
161+
rel="noopener noreferrer"
162+
className="btn btn-outline btn-sm"
163+
>
164+
Join our Telegram
165+
</a>
97166
</div>
98167
</>
99168
);

packages/nextjs/components/InvaderCard.tsx

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,40 @@ import Link from "next/link";
33
import { FlagIcon } from "./FlagIcon";
44
import clsx from "clsx";
55
import { getFlagBgColor, getFlagColor } from "~~/utils/flagColor";
6-
import { CHALLENGE_NAMES, getSlugBySeason } from "~~/utils/getChallenges";
6+
import { CHALLENGE_DESCRIPTIONS, CHALLENGE_NAMES, getSlugBySeason } from "~~/utils/getChallenges";
77

88
const invaderClass = "mx-auto w-10 h-10 md:w-12 md:h-12";
99

1010
export function InvaderCard({ challengeId, season }: { challengeId: number; season: number }) {
11+
const description = CHALLENGE_DESCRIPTIONS[season]?.[challengeId.toString()] ?? "";
12+
1113
return (
12-
<div className="card flex items-center justify-center border border-gray-800 bg-gray-950 rounded-md aspect-square">
13-
<Link href={`/${getSlugBySeason(season)}/challenges/${challengeId}`}>
14-
<div className="invader mx-auto relative w-10 text-center">
15-
<Image
16-
width={96}
17-
height={96}
18-
className={invaderClass}
19-
src={`/season-${season}/invader-${challengeId}.svg`}
20-
alt={`Invader Season ${season} - Challenge ${challengeId}`}
21-
/>
22-
<div className="absolute top-0 -right-7">
23-
<div className="relative rotate-12">
24-
<FlagIcon className={clsx("w-8 h-8", getFlagColor(challengeId))} />
25-
<span className="absolute top-[5px] left-[6px] m-0 p-0 leading-none text-xs text-white font-semibold [text-shadow:_1px_1px_1px_rgb(0_0_0_/_40%)]">
26-
{challengeId}
27-
</span>
14+
<div className="tooltip tooltip-bottom" data-tip={description}>
15+
<div className="card flex items-center justify-center border border-gray-800 bg-gray-950 rounded-md aspect-square">
16+
<Link href={`/${getSlugBySeason(season)}/challenges/${challengeId}`}>
17+
<div className="invader mx-auto relative w-10 text-center">
18+
<Image
19+
width={96}
20+
height={96}
21+
className={invaderClass}
22+
src={`/season-${season}/invader-${challengeId}.svg`}
23+
alt={`Invader Season ${season} - Challenge ${challengeId}`}
24+
/>
25+
<div className="absolute top-0 -right-7">
26+
<div className="relative rotate-12">
27+
<FlagIcon className={clsx("w-8 h-8", getFlagColor(challengeId))} />
28+
<span className="absolute top-[5px] left-[6px] m-0 p-0 leading-none text-xs text-white font-semibold [text-shadow:_1px_1px_1px_rgb(0_0_0_/_40%)]">
29+
{challengeId}
30+
</span>
31+
</div>
2832
</div>
2933
</div>
30-
</div>
31-
<p className="m-0 px-2 text-sm antialiased">
32-
{CHALLENGE_NAMES[season]?.[challengeId.toString()] ?? `Challenge ${challengeId}`}
33-
</p>
34-
<div className={clsx("dot", getFlagBgColor(challengeId))}></div>
35-
</Link>
34+
<p className="m-0 px-2 text-sm antialiased">
35+
{CHALLENGE_NAMES[season]?.[challengeId.toString()] ?? `Challenge ${challengeId}`}
36+
</p>
37+
<div className={clsx("dot", getFlagBgColor(challengeId))}></div>
38+
</Link>
39+
</div>
3640
</div>
3741
);
3842
}

packages/nextjs/components/InvaderCardCaptured.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { FlagIcon } from "./FlagIcon";
55
import clsx from "clsx";
66
import { getFormattedDateTime } from "~~/utils/date";
77
import { getFlagColor } from "~~/utils/flagColor";
8-
import { CHALLENGE_NAMES, getSlugBySeason } from "~~/utils/getChallenges";
8+
import { CHALLENGE_DESCRIPTIONS, CHALLENGE_NAMES, getSlugBySeason } from "~~/utils/getChallenges";
99

1010
export function InvaderCardCaptured({
1111
challengeId,
@@ -17,9 +17,11 @@ export function InvaderCardCaptured({
1717
timestamp?: number;
1818
}) {
1919
const timeCaptured = timestamp ? getFormattedDateTime(new Date(timestamp * 1000)) : "";
20+
const description = CHALLENGE_DESCRIPTIONS[season]?.[challengeId.toString()] ?? "";
21+
const tooltipText = `${description}${timeCaptured ? ` • Captured: ${timeCaptured}` : ""}`;
2022

2123
return (
22-
<div className="tooltip" data-tip={`Captured: ${timeCaptured}`}>
24+
<div className="tooltip tooltip-bottom" data-tip={tooltipText}>
2325
<div
2426
className="relative flex items-center justify-center border border-slate-400 bg-gray-950 rounded-md aspect-square overflow-hidden"
2527
style={{

packages/nextjs/components/UserData.tsx

Lines changed: 3 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,44 +5,10 @@ import { ProgressInvaders } from "./ProgressInvaders";
55
import { Address } from "~~/components/scaffold-eth";
66
import { useFetchUserData } from "~~/hooks/useFetchUserData";
77
import { getFormattedDateTime } from "~~/utils/date";
8-
import { CHALLENGE_NAMES, SEASONS } from "~~/utils/getChallenges";
8+
import { SEASONS } from "~~/utils/getChallenges";
99

1010
export const UserData = ({ address }: { address: string }) => {
11-
const { userData } = useFetchUserData({ address });
12-
13-
const mergedChallengeDataBySeason = Object.entries(CHALLENGE_NAMES).reduce((acc, [seasonKey, challengesMap]) => {
14-
const season = Number(seasonKey);
15-
const challengeIds = Object.keys(challengesMap).sort((a, b) => Number(a) - Number(b));
16-
17-
acc[season] = challengeIds.map(challengeId => {
18-
const userChallenge = userData?.challenges?.items.find(c => {
19-
const challengeIdNumber = Number(challengeId);
20-
const cChallengeIdNumber = Number(c.challengeId);
21-
22-
// Challenge #1 is shared across seasons: if it's completed in any season, mark it as solved
23-
if (challengeIdNumber === 1) {
24-
return cChallengeIdNumber === 1;
25-
}
26-
27-
return c.season === season && cChallengeIdNumber === challengeIdNumber;
28-
});
29-
30-
if (userChallenge) {
31-
return {
32-
challengeId,
33-
solved: true,
34-
timestamp: userChallenge.timestamp,
35-
};
36-
}
37-
38-
return {
39-
challengeId,
40-
solved: false,
41-
};
42-
});
43-
44-
return acc;
45-
}, {} as Record<number, { challengeId: string; solved: boolean; timestamp?: number }[]>);
11+
const { userData, challengesBySeasons } = useFetchUserData({ address });
4612

4713
return (
4814
<>
@@ -71,7 +37,7 @@ export const UserData = ({ address }: { address: string }) => {
7137
</div>
7238
</div>
7339
)}
74-
{Object.entries(mergedChallengeDataBySeason)
40+
{Object.entries(challengesBySeasons)
7541
.sort(([a], [b]) => Number(a) - Number(b))
7642
.map(([season, challenges]) => (
7743
<div key={season} className="mt-12">

packages/nextjs/hooks/useFetchUserData.tsx

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import { useQuery } from "@tanstack/react-query";
44
import { gql, request } from "graphql-request";
55
import { UsersData } from "~~/types/utils";
6+
import { CHALLENGE_NAMES } from "~~/utils/getChallenges";
7+
8+
export type ChallengeProgress = { challengeId: string; solved: boolean; timestamp?: number };
69

710
const fetchUser = async (userId: string) => {
811
const UserQuery = gql`
@@ -41,14 +44,48 @@ export const useFetchUserData = ({ address }: { address?: string }) => {
4144
refetchInterval: 10000,
4245
});
4346

44-
// challenge1 only exists in season 1
45-
const hasCompletedChallenge1 = data?.users?.items[0]?.challenges?.items.some(
46-
challenge => Number(challenge.challengeId) === 1,
47-
);
47+
const userData = data?.users?.items[0];
48+
49+
const hasCompletedChallenge1 = userData?.challenges?.items.some(challenge => Number(challenge.challengeId) === 1);
50+
51+
const challengesBySeasons = Object.entries(CHALLENGE_NAMES).reduce((acc, [seasonKey, challengesMap]) => {
52+
const season = Number(seasonKey);
53+
const challengeIds = Object.keys(challengesMap).sort((a, b) => Number(a) - Number(b));
54+
55+
acc[season] = challengeIds.map(challengeId => {
56+
const userChallenge = userData?.challenges?.items.find(c => {
57+
const challengeIdNumber = Number(challengeId);
58+
const cChallengeIdNumber = Number(c.challengeId);
59+
60+
// Challenge #1 is shared across seasons: if it's completed in any season, mark it as solved
61+
if (challengeIdNumber === 1) {
62+
return cChallengeIdNumber === 1;
63+
}
64+
65+
return c.season === season && cChallengeIdNumber === challengeIdNumber;
66+
});
67+
68+
if (userChallenge) {
69+
return {
70+
challengeId,
71+
solved: true,
72+
timestamp: userChallenge.timestamp,
73+
};
74+
}
75+
76+
return {
77+
challengeId,
78+
solved: false,
79+
};
80+
});
81+
82+
return acc;
83+
}, {} as Record<number, ChallengeProgress[]>);
4884

4985
return {
50-
userData: data?.users?.items[0],
86+
userData,
5187
hasCompletedChallenge1,
88+
challengesBySeasons,
5289
loading: isLoading,
5390
error: isError,
5491
};

0 commit comments

Comments
 (0)