not ) to stay out of the spam folder. Plus it handles sending emails at the perfect time in each recipient’s local time zone so they’re more likely to open them.
Building The App
The full tutorial has all the snippets and goes through all the bugs and edge cases but here I’ll just share the most important bits of the app that power the gamification.
Trophy has SDKs in most major programming languages but here I was using NextJS so I installed the Node SDK with npm:
npm i @trophyso/node
To track each user’s progress and power the gamification features I set up a NextJS server action to fire each time a user views a flashcard. That way I could tell Trophy that the interaction happened and it could keep a running count of how many flashcard each user had viewed:
"use server";
import { TrophyApiClient } from "@trophyso/node";
import { EventResponse } from "@trophyso/node/api";
// Set up Trophy SDK with API key
const trophy = new TrophyApiClient({
apiKey: process.env.TROPHY_API_KEY as string,
});
/**
* Track a flashcard viewed event in Trophy
* @returns The event response from Trophy
*/
export async function viewFlashcard(): Promise<EventResponse | null> {
try {
return await trophy.metrics.event("flashcards-viewed", {
user: {
// Mock email
email: "user@example.com",
// Mock timezone
tz: "Europe/London",
// Mock user ID
id: "18",
},
// Event represents a single user viewing 1 flashcard
value: 1,
});
} catch (error) {
console.error(error);
return null;
}
}
The response to this call to the Trophy SDK would return back any changes to the users achievements or streaks as a result of them viewing that new flashcard:
{
"eventId": "0040fe51-6bce-4b44-b0ad-bddc4e123534",
"metricId": "d01dcbcb-d51e-4c12-b054-dc811dcdc623",
"total": 10,
"achievements": [
{
"metricId": "5100fe51-6bce-6j44-b0hs-bddc4e123682",
"completed": [
{
"id": "5100fe51-6bce-6j44-b0hs-bddc4e123682",
"name": "Elementary",
"metricId": "5100fe51-6bce-6j44-b0hs-bddc4e123682",
"metricValue": 10,
"metricName": "flashcards viewed",
"achievedAt": "2020-01-01T00:00:00Z"
}
]
}
],
"currentStreak": {
"frequency": "daily",
"length": 1,
"expires": "2025-04-12",
"extended": true,
"periodEnd": "2025-04-05",
"periodStart": "2025-03-31",
"started": "2025-04-02"
}
}
This made it super easy to read the response and fire off some pop-ups and play sound effects in a simple useEffect
:
"use client";
import {
Carousel,
CarouselContent,
CarouselPrevious,
CarouselNext,
type CarouselApi,
} from "@/components/ui/carousel";
import { IFlashcard } from "@/types/flashcard";
import Flashcard from "./flashcard";
import { useEffect, useState } from "react";
import { Progress } from "@/components/ui/progress";
import { viewFlashcard } from "./actions";
interface Props {
flashcards: IFlashcard[];
}
export default function Flashcards({ flashcards }: Props) {
const [flashIndex, setFlashIndex] = useState(0);
const [api, setApi] = useState<CarouselApi>();
useEffect(() => {
if (!api) {
return;
}
// Initialize the flash index
setFlashIndex(api.selectedScrollSnap() + 1);
api.on("select", () => {
// Update the flash index when the carousel is scrolled
setFlashIndex(api.selectedScrollSnap() + 1);
// Track the flashcard viewed event
viewFlashcard();
});
}, [api]);
return (
<div className="flex flex-col items-center justify-center gap-4 max-w-md">
<Progress value={(flashIndex / flashcards.length) * 100} />
<Carousel className="w-full" setApi={setApi}>
<CarouselContent>
{flashcards.map((flashcard) => (
<Flashcard key={flashcard.id} flashcard={flashcard} />
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
);
}
I then used a couple more server actions to fetch data from Trophy about the achievements the user had unlocked so far, and about their streak for the last 14 days:
/**
* Get the achievements for a user
* @returns The achievements for the user
*/
export async function getAchievements(): Promise<
MultiStageAchievementResponse[] | null
> {
try {
return await trophy.users.allachievements(USER_ID);
} catch (error) {
console.error(error);
return null;
}
}
/**
* Get the streak for a user
* @returns The streak for the user
*/
export async function getStreak(): Promise<StreakResponse | null> {
try {
return await trophy.users.streak(USER_ID, {
historyPeriods: 14,
});
} catch (error) {
console.error(error);
return null;
}
}
And finally I built some fun UI to display all this data in a dialog which served as the students ‘study center’:
import { Separator } from "@/components/ui/separator";
import {
MultiStageAchievementResponse,
StreakResponse,
} from "@trophyso/node/api";
import { Flame, GraduationCap } from "lucide-react";
import Image from "next/image";
import dayjs from "dayjs";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
interface Props {
achievements: MultiStageAchievementResponse[] | null;
streak: StreakResponse | null;
}
export default function StudyJourney({ achievements, streak }: Props) {
const sundayOffset = 7 - ((new Date().getDay() + 6) % 7);
const adjustedStreakHistory =
streak?.streakHistory?.slice(
sundayOffset - 1,
streak.streakHistory.length
) || Array(14).fill(null);
return (
<div className="absolute top-10 right-10 z-50 cursor-pointer">
<Dialog>
<DialogTrigger>
<div className="h-12 w-12 cursor-pointer duration-100 border-1 border-gray-300 shadow-sm transition-all rounded-full relative hover:bg-gray-100">
<GraduationCap className="h-6 w-6 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-gray-800" />
</div>
</DialogTrigger>
<DialogContent className="flex flex-col gap-3 min-w-[500px]">
{/* Heading */}
<DialogHeader>
<DialogTitle>Your study journey</DialogTitle>
<DialogDescription>
Keep studying to extend your streak and earn new badges
</DialogDescription>
</DialogHeader>
{/* Streak */}
<div className="flex flex-col gap-2 items-center justify-between pt-2">
<div className="flex flex-col items-center gap-4">
<div className="relative h-24 w-24">
<svg className="h-full w-full" viewBox="0 0 100 100">
<circle
className="stroke-primary/20"
strokeWidth="10"
fill="transparent"
r="45"
cx="50"
cy="50"
/>
<circle
className="stroke-primary transition-all"
strokeWidth="10"
strokeLinecap="round"
fill="transparent"
r="45"
cx="50"
cy="50"
strokeDasharray={`${(streak?.length || 0) * 10} 1000`}
transform="rotate(-90 50 50)"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold text-primary">
{streak?.length || 0}
</span>
</div>
</div>
<div className="flex flex-col text-center">
<h3 className="text-lg font-semibold">
{streak && streak.length > 0
? `Your study streak`
: `No study streak`}
</h3>
<p className="text-sm text-gray-500">
{streak && streak.length > 0
? `${streak.length} day${
streak.length > 1 ? "s" : ""
} in a row`
: `Start a streak`}
</p>
</div>
</div>
<div className="flex flex-col">
<div className="grid grid-cols-7 gap-1">
{["M", "T", "W", "T", "F", "S", "S"].map((day, i) => (
<div
key={i}
className="h-10 flex items-center justify-center"
>
<span className="text-sm text-gray-500">{day}</span>
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{adjustedStreakHistory.map((day, i) => {
if (day === null) {
return (
<div
key={i}
className="h-10 w-10 rounded-lg bg-white border border-gray-200 flex items-center justify-center"
>
<Flame className="h-6 w-6 text-gray-200" />
</div>
);
}
return (
<div
key={i}
className={`h-10 w-10 rounded-lg ${
day.length > 0 ? "bg-primary" : "bg-primary/10"
} flex items-center justify-center`}
>
<Flame
className={`h-6 w-6 ${
day.length > 0 ? "text-white" : "text-primary/30"
}`}
/>
</div>
);
})}
</div>
</div>
</div>
<Separator />
{/* Achievements */}
{achievements && achievements.length > 0 ? (
<div className="flex flex-col gap-3">
<div>
<p className="text-lg font-semibold">Your badges</p>
</div>
<div className="grid grid-cols-3 gap-2 w-full">
{achievements?.map((achievement) => (
<div
key={achievement.id}
className="p-2 rounded-md border border-gray-200 flex flex-col gap-1 items-center shadow-sm"
>
<Image
src={achievement.badgeUrl as string}
alt={achievement.name as string}
width={100}
height={100}
className="rounded-full border-gray-300"
/>
<p className="font-semibold">{achievement.name}</p>
<p className="text-gray-500 text-sm">
{dayjs(achievement.achievedAt).format("MMM D, YYYY")}
</p>
</div>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-1 items-center justify-center min-h-[100px] p-3 w-full mt-3">
<p className="font-semibold text-lg">No badges yet</p>
<p className="text-sm text-gray-500 text-center max-w-[200px]">
Keep studying to unlock more badges!
</p>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
Follow The Tutorial
Building this app was a lot of fun! For a step-by-step walkthrough check out the full tutorial. Or peek at the source code or live demo to see the finished product.
If instead you just want to get started building your own gamification experience, create a free account and follow the Trophy quick start guide.
