Skip to content

Commit 420e063

Browse files
NiallJoeMaherclaude
andcommitted
feat(relaunch): track onboarding wins + wire missing engagement awards
- feed filters: drop the "·" separators, swap the ▾ triangle for a proper down-chevron SVG - onboarding banner: real completion via engagement.onboardingWins (topics set / 3 follows / first post) — done steps show a mint check + strikethrough + progress rail; banner hides once all three are done; steps open the topics/ compose modals (work on mobile too) - engagement gaps closed in content.* (the relaunch compose/vote path never awarded): upvote_received → post author (idempotent per voter), post_published on content.create (publish) and content.publish (first publish). daily_active (streaks), comment_created, referral already wired; checkBadges runs after each. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 392514d commit 420e063

4 files changed

Lines changed: 195 additions & 70 deletions

File tree

components/Feed/Filters.tsx

Lines changed: 36 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,20 @@ function FilterPill({
6868
}`}
6969
>
7070
{current.label}
71-
<span className="text-[10px] text-faint" aria-hidden="true">
72-
73-
</span>
71+
<svg
72+
className="h-3.5 w-3.5 text-faint"
73+
viewBox="0 0 20 20"
74+
fill="none"
75+
stroke="currentColor"
76+
strokeWidth="1.75"
77+
aria-hidden="true"
78+
>
79+
<path
80+
strokeLinecap="round"
81+
strokeLinejoin="round"
82+
d="M6 8l4 4 4-4"
83+
/>
84+
</svg>
7485
</button>
7586
{open && (
7687
<ul
@@ -160,12 +171,6 @@ const sortOptions: Option[] = [
160171

161172
const ALL_TOPICS = "all";
162173

163-
const Divider = () => (
164-
<span className="text-faint" aria-hidden="true">
165-
·
166-
</span>
167-
);
168-
169174
const FeedFilters = ({
170175
sort,
171176
type,
@@ -189,10 +194,7 @@ const FeedFilters = ({
189194
typeValue !== "all" || sort !== "recent" || tagValue !== ALL_TOPICS;
190195

191196
return (
192-
<div
193-
className="flex items-center gap-1"
194-
data-testid="feed-filters"
195-
>
197+
<div className="flex items-center gap-2" data-testid="feed-filters">
196198
{isDirty && (
197199
<button
198200
type="button"
@@ -204,22 +206,19 @@ const FeedFilters = ({
204206
)}
205207

206208
{showTypeFilter && onTypeChange && (
207-
<>
208-
<FilterPill
209-
testId="type-filter"
210-
label="Filter by type"
211-
value={typeValue}
212-
options={typeOptions}
213-
isDefault={typeValue === "all"}
214-
align="right"
215-
onChange={(next) =>
216-
onTypeChange(
217-
next === "all" ? null : (next.toUpperCase() as ContentType),
218-
)
219-
}
220-
/>
221-
<Divider />
222-
</>
209+
<FilterPill
210+
testId="type-filter"
211+
label="Filter by type"
212+
value={typeValue}
213+
options={typeOptions}
214+
isDefault={typeValue === "all"}
215+
align="right"
216+
onChange={(next) =>
217+
onTypeChange(
218+
next === "all" ? null : (next.toUpperCase() as ContentType),
219+
)
220+
}
221+
/>
223222
)}
224223

225224
<FilterPill
@@ -233,19 +232,14 @@ const FeedFilters = ({
233232
/>
234233

235234
{topics.length > 0 && (
236-
<>
237-
<Divider />
238-
<FilterPill
239-
label="Filter by topic"
240-
value={tagValue}
241-
options={topicOptions}
242-
isDefault={tagValue === ALL_TOPICS}
243-
align="right"
244-
onChange={(next) =>
245-
onTagChange(next === ALL_TOPICS ? null : next)
246-
}
247-
/>
248-
</>
235+
<FilterPill
236+
label="Filter by topic"
237+
value={tagValue}
238+
options={topicOptions}
239+
isDefault={tagValue === ALL_TOPICS}
240+
align="right"
241+
onChange={(next) => onTagChange(next === ALL_TOPICS ? null : next)}
242+
/>
249243
)}
250244
</div>
251245
);

components/Feed/OnboardingBanner.tsx

Lines changed: 91 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@
22

33
import { useSyncExternalStore } from "react";
44
import Link from "next/link";
5+
import { api } from "@/server/trpc/react";
6+
import { useShellActions } from "@/components/Create/ShellActionsProvider";
57

68
const KEY = "codu.onboarding.dismissed";
79

8-
const STEPS = [
9-
{ label: "Pick your topics", href: "/feed" },
10-
{ label: "Follow 3 builders", href: "/discussions" },
11-
{ label: "Post your first tip", href: "/create?kind=til" },
12-
];
13-
1410
// Tiny external store so dismissal is read without setState-in-effect and stays
1511
// SSR-safe (server snapshot = not dismissed → banner renders, client reads
1612
// localStorage on hydration).
@@ -23,18 +19,43 @@ const isDismissed = () =>
2319
typeof window !== "undefined" && localStorage.getItem(KEY) === "1";
2420

2521
/**
26-
* First-run guidance: a dismissible "first win in 3 steps" banner. Onboarding is
27-
* the #1 retention lever, so we guide the first action. Mirrors
28-
* ui_kits/app/Feed.jsx → OnboardingBanner.
22+
* First-run guidance: a dismissible "first win in 3 steps" banner. Steps reflect
23+
* REAL completion (topics picked / 3 follows / first post) via
24+
* engagement.onboardingWins — done steps show a mint check + strikethrough. The
25+
* banner hides itself once all three are done. Mirrors ui_kits/app/Feed.jsx.
2926
*/
3027
export function OnboardingBanner() {
31-
const dismissed = useSyncExternalStore(
32-
subscribe,
33-
isDismissed,
34-
() => false,
35-
);
28+
const dismissed = useSyncExternalStore(subscribe, isDismissed, () => false);
29+
const { openTopics, openCompose } = useShellActions();
30+
const { data: wins } = api.engagement.onboardingWins.useQuery();
31+
32+
// A step is { label, done, action }. Actions reuse the shell modals so they
33+
// work even when the rail is hidden on mobile.
34+
const steps = [
35+
{
36+
label: "Pick your topics",
37+
done: !!wins?.pickedTopics,
38+
onClick: openTopics,
39+
href: undefined as string | undefined,
40+
},
41+
{
42+
label: "Follow 3 builders",
43+
done: !!wins?.followedThree,
44+
onClick: undefined,
45+
href: "/discussions",
46+
},
47+
{
48+
label: "Post your first tip",
49+
done: !!wins?.posted,
50+
onClick: () => openCompose("discussion"),
51+
href: undefined as string | undefined,
52+
},
53+
];
3654

37-
if (dismissed) return null;
55+
const allDone = wins ? steps.every((s) => s.done) : false;
56+
if (dismissed || allDone) return null;
57+
58+
const doneCount = steps.filter((s) => s.done).length;
3859

3960
const dismiss = () => {
4061
localStorage.setItem(KEY, "1");
@@ -69,20 +90,63 @@ export function OnboardingBanner() {
6990
7091
</button>
7192
</div>
72-
<div className="mt-4 grid gap-3 sm:grid-cols-2">
73-
{STEPS.map((s, i) => (
74-
<Link
75-
key={s.label}
76-
href={s.href}
77-
className="flex items-center gap-3 rounded-md border border-hairline bg-elevated p-3 transition-colors hover:border-strong"
78-
>
79-
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full border border-strong font-mono text-xs text-faint">
80-
{i + 1}
81-
</span>
82-
<span className="text-sm font-medium text-fg">{s.label}</span>
83-
</Link>
93+
94+
{/* progress rail */}
95+
<div className="mt-4 flex gap-1.5">
96+
{steps.map((s, i) => (
97+
<span
98+
key={i}
99+
className={`h-1 flex-1 rounded-full ${
100+
s.done ? "bg-accent" : "bg-elevated"
101+
}`}
102+
/>
84103
))}
85104
</div>
105+
106+
<div className="mt-4 grid gap-3 sm:grid-cols-2">
107+
{steps.map((s) => {
108+
const inner = (
109+
<>
110+
<span
111+
className={`flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full text-xs ${
112+
s.done
113+
? "bg-accent font-bold text-on-accent"
114+
: "border border-strong text-faint"
115+
}`}
116+
>
117+
{s.done ? "✓" : ""}
118+
</span>
119+
<span
120+
className={`text-sm font-medium ${
121+
s.done ? "text-muted line-through" : "text-fg"
122+
}`}
123+
>
124+
{s.label}
125+
</span>
126+
</>
127+
);
128+
const className = `flex items-center gap-3 rounded-md border border-hairline p-3 text-left transition-colors ${
129+
s.done ? "bg-transparent" : "bg-elevated hover:border-strong"
130+
}`;
131+
return s.href ? (
132+
<Link key={s.label} href={s.href} className={className}>
133+
{inner}
134+
</Link>
135+
) : (
136+
<button
137+
key={s.label}
138+
type="button"
139+
onClick={s.onClick}
140+
className={className}
141+
>
142+
{inner}
143+
</button>
144+
);
145+
})}
146+
</div>
147+
<p className="mt-3 font-mono text-xs text-faint">
148+
{doneCount}/3 done
149+
</p>
86150
</div>
87151
</div>
88152
);

server/api/router/content.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
notifyAdminOfReview,
5050
} from "@/server/lib/moderation";
5151
import { enforceRateLimit } from "@/server/lib/rateLimit";
52+
import { award } from "@/server/lib/engagement";
5253
import crypto from "crypto";
5354

5455
// Helper to generate slug from title
@@ -649,6 +650,17 @@ export const contentRouter = createTRPCRouter({
649650
}
650651
}
651652

653+
// Engagement: award points + check badges when a post goes live directly
654+
// (the quick-compose Discussion/Link path). Never throws.
655+
if (newContent && input.published) {
656+
await award({
657+
userId,
658+
action: "post_published",
659+
sourceType: "post",
660+
sourceId: newContent.id,
661+
});
662+
}
663+
652664
return newContent;
653665
}),
654666

@@ -790,6 +802,7 @@ export const contentRouter = createTRPCRouter({
790802
const contentItem = await ctx.db
791803
.select({
792804
id: posts.id,
805+
authorId: posts.authorId,
793806
upvotes: posts.upvotesCount,
794807
downvotes: posts.downvotesCount,
795808
})
@@ -836,6 +849,22 @@ export const contentRouter = createTRPCRouter({
836849
.where(eq(post_votes.id, existingVote[0].id));
837850
}
838851

852+
// Award the author points for an upvote (idempotent per voter+post via
853+
// the dedupe index; never self-award). Fire-and-forget — never throws.
854+
if (
855+
voteType === "up" &&
856+
contentItem[0].authorId &&
857+
contentItem[0].authorId !== userId
858+
) {
859+
await award({
860+
userId: contentItem[0].authorId,
861+
action: "upvote_received",
862+
sourceType: "post",
863+
sourceId: contentId,
864+
actorId: userId,
865+
});
866+
}
867+
839868
return { success: true };
840869
}),
841870

@@ -1297,6 +1326,17 @@ export const contentRouter = createTRPCRouter({
12971326
.where(eq(posts.id, input.id))
12981327
.returning();
12991328

1329+
// Award points the first time a post is published (draft → published;
1330+
// the moderation path awards on admin approval instead). Never throws.
1331+
if (input.published && existing[0].status === "draft") {
1332+
await award({
1333+
userId,
1334+
action: "post_published",
1335+
sourceType: "post",
1336+
sourceId: input.id,
1337+
});
1338+
}
1339+
13001340
return updated;
13011341
}),
13021342

server/api/router/engagement.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,37 @@ import { z } from "zod";
22
import { nanoid } from "nanoid";
33
import { and, desc, eq, gt, sql } from "drizzle-orm";
44
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc";
5-
import { point_event, user, badge } from "@/server/db/schema";
5+
import { point_event, user, badge, follow, posts } from "@/server/db/schema";
66
import { getStreak, getUserBadges } from "@/server/lib/engagement";
77

88
export const engagementRouter = createTRPCRouter({
9+
// "Get your first win in 3 steps" — real completion state for the feed banner:
10+
// picked topics, followed 3 builders, published a first post.
11+
onboardingWins: protectedProcedure.query(async ({ ctx }) => {
12+
const uid = ctx.session.user.id;
13+
const [u] = await ctx.db
14+
.select({ topics: user.topics })
15+
.from(user)
16+
.where(eq(user.id, uid))
17+
.limit(1);
18+
const [followRow] = await ctx.db
19+
.select({ c: sql<number>`count(*)` })
20+
.from(follow)
21+
.where(eq(follow.followerId, uid));
22+
const [postRow] = await ctx.db
23+
.select({ c: sql<number>`count(*)` })
24+
.from(posts)
25+
.where(eq(posts.authorId, uid));
26+
const followCount = Number(followRow?.c ?? 0);
27+
const postCount = Number(postRow?.c ?? 0);
28+
return {
29+
pickedTopics: (u?.topics?.length ?? 0) > 0,
30+
followedThree: followCount >= 3,
31+
posted: postCount > 0,
32+
followCount,
33+
};
34+
}),
35+
936
// The signed-in user's streak + total points (personal, never empty-feeling).
1037
myStats: protectedProcedure.query(async ({ ctx }) => {
1138
const streak = await getStreak(ctx.session.user.id);

0 commit comments

Comments
 (0)