Skip to content

Commit 1b3a193

Browse files
Push custom values (signup date, subscription, stages) to Lobbyside
Surface host-pushed custom columns on the Lobbyside live-visitor table by passing three derived fields through `setVisitor`: - signed_up_date — user.createdAt (ISO calendar date) - subscription_type — lifetime > individual > team > vip > free - stages_completed — summed completedStageSlugs across course participations stages_completed needs course-participations on the current-user payload, so add it to the syncCurrentUser include. Derivation is extracted to a pure, unit-tested helper. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a2898d7 commit 1b3a193

4 files changed

Lines changed: 138 additions & 2 deletions

File tree

app/components/lobbyside-widget/index.hbs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,16 @@
1616
<div
1717
data-test-lobbyside-widget={{@widgetId}}
1818
{{did-insert this.insertScript}}
19-
{{did-update this.syncVisitorData this.authenticator.currentUserId this.userPrimaryEmail this.userName this.userGithub}}
19+
{{did-update
20+
this.syncVisitorData
21+
this.authenticator.currentUserId
22+
this.userPrimaryEmail
23+
this.userName
24+
this.userGithub
25+
this.customFields.stages_completed
26+
this.customFields.signed_up_date
27+
this.customFields.subscription_type
28+
}}
2029
{{will-destroy this.removeScript}}
2130
></div>
2231
{{/if}}

app/components/lobbyside-widget/index.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,73 @@ import type AuthenticatorService from 'codecrafters-frontend/services/authentica
88
declare global {
99
interface Window {
1010
Lobbyside?: {
11-
setVisitor(visitor: { email: string; name: string; github: string }): void;
11+
setVisitor(visitor: {
12+
email: string;
13+
name: string;
14+
github: string;
15+
stages_completed: string;
16+
signed_up_date: string;
17+
subscription_type: string;
18+
}): void;
1219
};
1320
}
1421
}
1522

1623
export type LobbysideAudience = 'everyone' | 'staff';
1724

25+
// Structural subset of UserModel so the derivation stays pure + unit-testable.
26+
interface VisitorSource {
27+
createdAt?: Date | null;
28+
isVip?: boolean;
29+
activeSubscription?: { isLifetimeMembership?: boolean } | null;
30+
hasActiveSubscription?: boolean;
31+
teamHasActiveSubscription?: boolean;
32+
courseParticipations?: { completedStageSlugs?: string[] }[];
33+
}
34+
35+
export interface LobbysideCustomFields {
36+
stages_completed: string;
37+
signed_up_date: string;
38+
subscription_type: string;
39+
}
40+
41+
function subscriptionType(user: VisitorSource | null | undefined): string {
42+
if (!user) {
43+
return '';
44+
}
45+
46+
if (user.activeSubscription?.isLifetimeMembership) {
47+
return 'lifetime';
48+
}
49+
50+
if (user.hasActiveSubscription) {
51+
return 'individual';
52+
}
53+
54+
if (user.teamHasActiveSubscription) {
55+
return 'team';
56+
}
57+
58+
if (user.isVip) {
59+
return 'vip';
60+
}
61+
62+
return 'free';
63+
}
64+
65+
export function lobbysideCustomFields(user: VisitorSource | null | undefined): LobbysideCustomFields {
66+
const stagesCompleted = (user?.courseParticipations ?? []).reduce(
67+
(sum, participation) => sum + (participation.completedStageSlugs?.length ?? 0),
68+
0,
69+
);
70+
71+
return {
72+
stages_completed: String(stagesCompleted),
73+
signed_up_date: user?.createdAt ? user.createdAt.toISOString().slice(0, 10) : '',
74+
subscription_type: subscriptionType(user),
75+
};
76+
}
77+
1878
export interface LobbysideWidgetSignature {
1979
Element: HTMLDivElement;
2080
Args: {
@@ -26,6 +86,10 @@ export interface LobbysideWidgetSignature {
2686
export default class LobbysideWidgetComponent extends Component<LobbysideWidgetSignature> {
2787
@service declare authenticator: AuthenticatorService;
2888

89+
get customFields(): LobbysideCustomFields {
90+
return lobbysideCustomFields(this.authenticator.currentUser);
91+
}
92+
2993
get scriptId(): string {
3094
return `lobbyside-widget-${this.args.widgetId}`;
3195
}
@@ -139,6 +203,7 @@ export default class LobbysideWidgetComponent extends Component<LobbysideWidgetS
139203
email: this.userPrimaryEmail,
140204
name: this.userName,
141205
github: this.userGithub,
206+
...this.customFields,
142207
});
143208
}
144209
}

app/services/authenticator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ export default class AuthenticatorService extends Service {
116116
const includedResources = [
117117
'affiliate-links',
118118
'affiliate-links.user',
119+
// Powers the `stages_completed` field the Lobbyside widget pushes via setVisitor.
120+
'course-participations',
119121
'feature-suggestions',
120122
'promotional-discounts',
121123
'promotional-discounts.affiliate-referral',
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { lobbysideCustomFields } from 'codecrafters-frontend/components/lobbyside-widget';
2+
import { module, test } from 'qunit';
3+
import type UserModel from 'codecrafters-frontend/models/user';
4+
5+
module('Unit | Component | lobbyside-widget', function () {
6+
function createUser(overrides: Partial<UserModel> = {}): UserModel {
7+
return {
8+
createdAt: new Date('2023-04-15T10:30:00.000Z'),
9+
isVip: false,
10+
activeSubscription: null,
11+
hasActiveSubscription: false,
12+
teamHasActiveSubscription: false,
13+
courseParticipations: [],
14+
...overrides,
15+
} as unknown as UserModel;
16+
}
17+
18+
test('returns empty fields for a missing user', function (assert) {
19+
assert.deepEqual(lobbysideCustomFields(null), {
20+
stages_completed: '0',
21+
signed_up_date: '',
22+
subscription_type: '',
23+
});
24+
});
25+
26+
test('formats signed_up_date as an ISO calendar date', function (assert) {
27+
const user = createUser({ createdAt: new Date('2023-04-15T10:30:00.000Z') });
28+
29+
assert.strictEqual(lobbysideCustomFields(user).signed_up_date, '2023-04-15');
30+
});
31+
32+
test('sums completed stage slugs across all course participations', function (assert) {
33+
const user = createUser({
34+
courseParticipations: [{ completedStageSlugs: ['s1', 's2', 's3'] }, { completedStageSlugs: ['s1'] }, { completedStageSlugs: [] }],
35+
} as unknown as Partial<UserModel>);
36+
37+
assert.strictEqual(lobbysideCustomFields(user).stages_completed, '4');
38+
});
39+
40+
test('subscription_type prefers lifetime over every other signal', function (assert) {
41+
const user = createUser({
42+
isVip: true,
43+
hasActiveSubscription: true,
44+
teamHasActiveSubscription: true,
45+
activeSubscription: { isLifetimeMembership: true } as UserModel['activeSubscription'],
46+
});
47+
48+
assert.strictEqual(lobbysideCustomFields(user).subscription_type, 'lifetime');
49+
});
50+
51+
test('subscription_type falls back through individual, team, vip, then free', function (assert) {
52+
assert.strictEqual(lobbysideCustomFields(createUser({ hasActiveSubscription: true })).subscription_type, 'individual');
53+
54+
assert.strictEqual(lobbysideCustomFields(createUser({ teamHasActiveSubscription: true })).subscription_type, 'team');
55+
56+
assert.strictEqual(lobbysideCustomFields(createUser({ isVip: true })).subscription_type, 'vip');
57+
58+
assert.strictEqual(lobbysideCustomFields(createUser()).subscription_type, 'free');
59+
});
60+
});

0 commit comments

Comments
 (0)