Skip to content

Commit 43288e3

Browse files
authored
Merge pull request #1846 from dubinc/year-in-review
Year in Review card
2 parents 7ea7c34 + b34f46c commit 43288e3

File tree

11 files changed

+326
-23
lines changed

11 files changed

+326
-23
lines changed

apps/web/app/api/workspaces/[idOrSlug]/route.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,37 @@ import { NextResponse } from "next/server";
1515
// GET /api/workspaces/[idOrSlug] – get a specific workspace by id or slug
1616
export const GET = withWorkspace(
1717
async ({ workspace, headers }) => {
18-
const domains = await prisma.domain.findMany({
19-
where: {
20-
projectId: workspace.id,
21-
},
22-
select: {
23-
slug: true,
24-
primary: true,
25-
},
26-
});
18+
const [domains, yearInReviews] = await Promise.all([
19+
prisma.domain.findMany({
20+
where: {
21+
projectId: workspace.id,
22+
},
23+
select: {
24+
slug: true,
25+
primary: true,
26+
},
27+
take: 100,
28+
}),
29+
prisma.yearInReview.findMany({
30+
where: {
31+
workspaceId: workspace.id,
32+
year: 2024,
33+
},
34+
}),
35+
]);
2736

2837
return NextResponse.json(
29-
WorkspaceSchema.parse({
30-
...workspace,
31-
id: `ws_${workspace.id}`,
32-
domains,
33-
flags: await getFeatureFlags({
34-
workspaceId: workspace.id,
38+
{
39+
...WorkspaceSchema.parse({
40+
...workspace,
41+
id: `ws_${workspace.id}`,
42+
domains,
43+
flags: await getFeatureFlags({
44+
workspaceId: workspace.id,
45+
}),
3546
}),
36-
}),
47+
yearInReview: yearInReviews.length > 0 ? yearInReviews[0] : null,
48+
},
3749
{ headers },
3850
);
3951
},

apps/web/app/app.dub.co/(dashboard)/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { MainNav } from "@/ui/layout/main-nav";
22
import { AppSidebarNav } from "@/ui/layout/sidebar/app-sidebar-nav";
33
import { HelpButtonRSC } from "@/ui/layout/sidebar/help-button-rsc";
4-
import { NewsRSC } from "@/ui/layout/sidebar/news-rsc";
54
import { ReferButton } from "@/ui/layout/sidebar/refer-button";
5+
import { YearEndReviewCard } from "@/ui/layout/sidebar/year-end-review-card";
66
import Toolbar from "@/ui/layout/toolbar/toolbar";
77
import { constructMetadata } from "@dub/utils";
88
import { ReactNode } from "react";
@@ -22,7 +22,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
2222
<HelpButtonRSC />
2323
</>
2424
}
25-
newsContent={<NewsRSC />}
25+
newsContent={<YearEndReviewCard />}
2626
>
2727
{children}
2828
</MainNav>

apps/web/app/app.dub.co/(onboarding)/[slug]/upgrade/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { PlanSelector } from "../../onboarding/(steps)/plan/plan-selector";
44
import { StepPage } from "../../onboarding/(steps)/step-page";
55
import ExitButton from "./exit-button";
66

7-
export default function Plan() {
7+
export default function UpgradePage({ params }: { params: { slug: string } }) {
88
return (
99
<div className="relative flex flex-col items-center">
1010
<ExitButton />
11-
<Link href="/">
11+
<Link href={`/${params.slug}`}>
1212
<Wordmark className="mt-6 h-8" />
1313
</Link>
1414
<div className="mt-8 flex w-full flex-col items-center px-3 pb-16 md:mt-20 lg:px-8">
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"use client";
2+
3+
import useWorkspace from "@/lib/swr/use-workspace";
4+
import { BlurImage, ExpandingArrow } from "@dub/ui";
5+
import {
6+
cn,
7+
DICEBEAR_AVATAR_URL,
8+
smartTruncate,
9+
STAGGER_CHILD_VARIANTS,
10+
} from "@dub/utils";
11+
import { COUNTRIES } from "@dub/utils/src/constants/countries";
12+
import NumberFlow from "@number-flow/react";
13+
import { motion } from "framer-motion";
14+
import { redirect, useParams } from "next/navigation";
15+
16+
export default function WrappedPageClient() {
17+
const { slug, year } = useParams();
18+
const { name, logo, yearInReview, loading } = useWorkspace();
19+
20+
const { totalLinks, totalClicks, topLinks, topCountries } =
21+
yearInReview || {};
22+
23+
const stats = {
24+
"Total Links": totalLinks,
25+
"Total Clicks": totalClicks,
26+
};
27+
28+
const placeholderArray = Array.from({ length: 5 }, (_, index) => ({
29+
item: "placeholder",
30+
count: 0,
31+
}));
32+
33+
if (!loading && !yearInReview) {
34+
redirect(`/${slug}`);
35+
}
36+
37+
return (
38+
<div className="relative mx-auto my-10 max-w-lg px-4 sm:px-8">
39+
<h1 className="animate-slide-up-fade font-display mx-0 mb-4 mt-8 p-0 text-center text-xl font-semibold text-black [animation-delay:150ms] [animation-duration:1s] [animation-fill-mode:both]">
40+
Dub {year} Year in Review 🎊
41+
</h1>
42+
<p className="animate-slide-up-fade text-center text-sm leading-6 text-black [animation-delay:300ms] [animation-duration:1s] [animation-fill-mode:both]">
43+
As we put a wrap on {year}, we wanted to say thank you for your support!
44+
Here's a look back at your activity in {year}:
45+
</p>
46+
47+
<div className="animate-slide-up-fade my-8 rounded-lg border border-neutral-200 bg-white p-2 shadow-md [animation-delay:450ms] [animation-duration:1s] [animation-fill-mode:both]">
48+
<div
49+
className="flex h-24 flex-col items-center justify-center rounded-lg"
50+
style={{
51+
backgroundImage: `url(https://assets.dub.co/misc/year-in-review-header.jpg)`,
52+
backgroundSize: "cover",
53+
backgroundPosition: "center",
54+
}}
55+
>
56+
{name ? (
57+
<>
58+
<BlurImage
59+
src={logo || `${DICEBEAR_AVATAR_URL}${name}`}
60+
alt={name || "Workspace Logo"}
61+
className="h-8 rounded-full"
62+
width={32}
63+
height={32}
64+
/>
65+
<h2 className="mt-1 text-xl font-semibold">{name}</h2>
66+
</>
67+
) : (
68+
<>
69+
<div className="h-8 animate-pulse rounded-full bg-neutral-200" />
70+
<div className="h-5 w-12 animate-pulse rounded-md bg-neutral-200" />
71+
</>
72+
)}
73+
</div>
74+
<div className="grid w-full grid-cols-2 gap-2 p-4">
75+
{Object.entries(stats).map(([key, value]) => (
76+
<StatCard key={key} title={key} value={value} />
77+
))}
78+
</div>
79+
<div className="grid gap-2 p-4">
80+
<StatTable
81+
title="Top Links"
82+
value={
83+
topLinks
84+
? (topLinks as { item: string; count: number }[])
85+
: placeholderArray
86+
}
87+
/>
88+
<StatTable
89+
title="Top Countries"
90+
value={
91+
topCountries
92+
? (topCountries as { item: string; count: number }[])
93+
: placeholderArray
94+
}
95+
/>
96+
</div>
97+
</div>
98+
</div>
99+
);
100+
}
101+
102+
const StatCard = ({
103+
title,
104+
value,
105+
}: {
106+
title: string;
107+
value: number | undefined;
108+
}) => {
109+
return (
110+
<div className="text-center">
111+
<h3 className="font-medium text-neutral-500">{title}</h3>
112+
<NumberFlow
113+
value={value || 0}
114+
className={cn(
115+
"text-lg font-medium text-black",
116+
value === undefined && "text-neutral-300",
117+
)}
118+
/>
119+
</div>
120+
);
121+
};
122+
123+
const StatTable = ({
124+
title,
125+
value,
126+
}: {
127+
title: string;
128+
value: { item: string; count: number }[];
129+
}) => {
130+
const { slug } = useParams();
131+
return (
132+
<div className="mb-2">
133+
<h3 className="mb-2 font-medium text-neutral-500">{title}</h3>
134+
<motion.div
135+
variants={{
136+
show: {
137+
transition: {
138+
delayChildren: 0.5,
139+
staggerChildren: 0.08,
140+
},
141+
},
142+
}}
143+
initial="hidden"
144+
animate="show"
145+
className="grid divide-y divide-neutral-200 text-sm"
146+
>
147+
{value.map(({ item, count }, index) => {
148+
const [domain, ...pathParts] = item.split("/");
149+
const path = pathParts.join("/") || "_root";
150+
return (
151+
<motion.div
152+
key={index}
153+
variants={STAGGER_CHILD_VARIANTS}
154+
className="text-sm text-gray-500"
155+
>
156+
<a
157+
href={`/${slug}/analytics?${new URLSearchParams({
158+
...(title === "Top Links"
159+
? {
160+
domain,
161+
key: path,
162+
}
163+
: {
164+
country: item,
165+
}),
166+
interval: "1y",
167+
}).toString()}`}
168+
key={index}
169+
className="group flex justify-between py-1.5"
170+
>
171+
{item === "placeholder" ? (
172+
<div className="h-4 w-12 animate-pulse rounded-md bg-neutral-200" />
173+
) : (
174+
<div className="flex items-center gap-2">
175+
{title === "Top Countries" && (
176+
<img
177+
src={`https://hatscripts.github.io/circle-flags/flags/${item.toLowerCase()}.svg`}
178+
alt={COUNTRIES[item]}
179+
className="size-4"
180+
/>
181+
)}
182+
<div className="flex gap-0.5">
183+
<p className="font-medium text-black">
184+
{title === "Top Links"
185+
? smartTruncate(item, 33)
186+
: COUNTRIES[item]}{" "}
187+
</p>
188+
<ExpandingArrow className="size-3" />
189+
</div>
190+
</div>
191+
)}
192+
<NumberFlow
193+
value={count}
194+
className={cn(
195+
"text-neutral-600 group-hover:text-black",
196+
count === 0 && "text-neutral-300",
197+
)}
198+
/>
199+
</a>
200+
</motion.div>
201+
);
202+
})}
203+
</motion.div>
204+
</div>
205+
);
206+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Wordmark } from "@dub/ui";
2+
import Link from "next/link";
3+
import WrappedPageClient from "./client";
4+
5+
export default function WrappedPage({
6+
params,
7+
}: {
8+
params: { slug: string; year: string };
9+
}) {
10+
return (
11+
<div className="relative flex flex-col items-center">
12+
<Link href={`/${params.slug}`}>
13+
<Wordmark className="mt-6 h-8" />
14+
</Link>
15+
<WrappedPageClient />
16+
</div>
17+
);
18+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { redirect } from "next/navigation";
2+
3+
export default function WrappedParentPage({
4+
params,
5+
}: {
6+
params: { slug: string };
7+
}) {
8+
redirect(`/${params.slug}/wrapped/2024`);
9+
}

apps/web/lib/middleware/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export default async function AppMiddleware(req: NextRequest) {
8888
"/programs",
8989
"/settings",
9090
"/upgrade",
91+
"/wrapped",
9192
].includes(path) ||
9293
path.startsWith("/settings/") ||
9394
isTopLevelSettingsRedirect(path)

apps/web/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
SaleStatus,
1010
UtmTemplate,
1111
Webhook,
12+
YearInReview,
1213
} from "@dub/prisma/client";
1314
import { WEBHOOK_TRIGGER_DESCRIPTIONS } from "./webhook/constants";
1415
import { clickEventResponseSchema } from "./zod/schemas/clicks";
@@ -139,6 +140,7 @@ export type ExpandedWorkspaceProps = WorkspaceProps & {
139140
id: string;
140141
name: string;
141142
}[];
143+
yearInReview: YearInReview | null;
142144
};
143145

144146
export type WorkspaceWithUsers = Omit<WorkspaceProps, "domains">;

apps/web/tests/workspaces/retrieve-workspace.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ describe("GET /workspaces/{idOrSlug}", async () => {
2222
slug: workspace.slug,
2323
});
2424

25-
WorkspaceSchema.extend({ createdAt: z.string() })
25+
WorkspaceSchema.extend({
26+
createdAt: z.string(),
27+
yearInReview: z.object({}).nullable(),
28+
})
2629
.strict()
2730
.parse(workspaceFetched);
2831
});
@@ -41,7 +44,10 @@ describe("GET /workspaces/{idOrSlug}", async () => {
4144
slug: workspace.slug,
4245
});
4346

44-
WorkspaceSchema.extend({ createdAt: z.string() })
47+
WorkspaceSchema.extend({
48+
createdAt: z.string(),
49+
yearInReview: z.object({}).nullable(),
50+
})
4551
.strict()
4652
.parse(workspaceFetched);
4753
});

apps/web/ui/layout/sidebar/app-sidebar-nav.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ const NAV_AREAS: SidebarNavAreas<{
106106
href: `/${slug}/programs/${programs[0].id}/resources`,
107107
},
108108
{
109-
name: "Settings",
109+
name: "Configuration",
110110
href: `/${slug}/programs/${programs[0].id}/settings`,
111111
},
112112
],

0 commit comments

Comments
 (0)