Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion app/components/lobbyside-widget/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@
<div
data-test-lobbyside-widget={{@widgetId}}
{{did-insert this.insertScript}}
{{did-update this.syncVisitorData this.authenticator.currentUserId this.userPrimaryEmail this.userName this.userGithub}}
{{did-update
this.syncVisitorData
this.authenticator.currentUserId
this.userPrimaryEmail
this.userName
this.userGithub
this.customFields.stages_completed
this.customFields.signed_up_date
this.customFields.subscription_type
}}
{{will-destroy this.removeScript}}
></div>
{{/if}}
67 changes: 66 additions & 1 deletion app/components/lobbyside-widget/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,73 @@ import type AuthenticatorService from 'codecrafters-frontend/services/authentica
declare global {
interface Window {
Lobbyside?: {
setVisitor(visitor: { email: string; name: string; github: string }): void;
setVisitor(visitor: {
email: string;
name: string;
github: string;
stages_completed: string;
signed_up_date: string;
subscription_type: string;
}): void;
};
}
}

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

// Structural subset of UserModel so the derivation stays pure + unit-testable.
interface VisitorSource {
createdAt?: Date | null;
isVip?: boolean;
activeSubscription?: { isLifetimeMembership?: boolean } | null;
hasActiveSubscription?: boolean;
teamHasActiveSubscription?: boolean;
courseParticipations?: { completedStageSlugs?: string[] }[];
}

export interface LobbysideCustomFields {
stages_completed: string;
signed_up_date: string;
subscription_type: string;
}

function subscriptionType(user: VisitorSource | null | undefined): string {
if (!user) {
return '';
}

if (user.activeSubscription?.isLifetimeMembership) {
return 'lifetime';
}

if (user.hasActiveSubscription) {
return 'individual';
}

if (user.teamHasActiveSubscription) {
return 'team';
}

if (user.isVip) {
return 'vip';
}

return 'free';
}

export function lobbysideCustomFields(user: VisitorSource | null | undefined): LobbysideCustomFields {
const stagesCompleted = (user?.courseParticipations ?? []).reduce(
(sum, participation) => sum + (participation.completedStageSlugs?.length ?? 0),
0,
);

return {
stages_completed: String(stagesCompleted),
signed_up_date: user?.createdAt ? user.createdAt.toISOString().slice(0, 10) : '',
subscription_type: subscriptionType(user),
};
}

export interface LobbysideWidgetSignature {
Element: HTMLDivElement;
Args: {
Expand All @@ -26,6 +86,10 @@ export interface LobbysideWidgetSignature {
export default class LobbysideWidgetComponent extends Component<LobbysideWidgetSignature> {
@service declare authenticator: AuthenticatorService;

get customFields(): LobbysideCustomFields {
return lobbysideCustomFields(this.authenticator.currentUser);
}

get scriptId(): string {
return `lobbyside-widget-${this.args.widgetId}`;
}
Expand Down Expand Up @@ -139,6 +203,7 @@ export default class LobbysideWidgetComponent extends Component<LobbysideWidgetS
email: this.userPrimaryEmail,
name: this.userName,
github: this.userGithub,
...this.customFields,
});
}
}
Expand Down
2 changes: 2 additions & 0 deletions app/services/authenticator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export default class AuthenticatorService extends Service {
const includedResources = [
'affiliate-links',
'affiliate-links.user',
// Powers the `stages_completed` field the Lobbyside widget pushes via setVisitor.
'course-participations',
'feature-suggestions',
'promotional-discounts',
'promotional-discounts.affiliate-referral',
Expand Down
60 changes: 60 additions & 0 deletions tests/unit/components/lobbyside-widget-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { lobbysideCustomFields } from 'codecrafters-frontend/components/lobbyside-widget';
import { module, test } from 'qunit';
import type UserModel from 'codecrafters-frontend/models/user';

module('Unit | Component | lobbyside-widget', function () {
function createUser(overrides: Partial<UserModel> = {}): UserModel {
return {
createdAt: new Date('2023-04-15T10:30:00.000Z'),
isVip: false,
activeSubscription: null,
hasActiveSubscription: false,
teamHasActiveSubscription: false,
courseParticipations: [],
...overrides,
} as unknown as UserModel;
}

test('returns empty fields for a missing user', function (assert) {
assert.deepEqual(lobbysideCustomFields(null), {
stages_completed: '0',
signed_up_date: '',
subscription_type: '',
});
});

test('formats signed_up_date as an ISO calendar date', function (assert) {
const user = createUser({ createdAt: new Date('2023-04-15T10:30:00.000Z') });

assert.strictEqual(lobbysideCustomFields(user).signed_up_date, '2023-04-15');
});

test('sums completed stage slugs across all course participations', function (assert) {
const user = createUser({
courseParticipations: [{ completedStageSlugs: ['s1', 's2', 's3'] }, { completedStageSlugs: ['s1'] }, { completedStageSlugs: [] }],
} as unknown as Partial<UserModel>);

assert.strictEqual(lobbysideCustomFields(user).stages_completed, '4');
});

test('subscription_type prefers lifetime over every other signal', function (assert) {
const user = createUser({
isVip: true,
hasActiveSubscription: true,
teamHasActiveSubscription: true,
activeSubscription: { isLifetimeMembership: true } as UserModel['activeSubscription'],
});

assert.strictEqual(lobbysideCustomFields(user).subscription_type, 'lifetime');
});

test('subscription_type falls back through individual, team, vip, then free', function (assert) {
assert.strictEqual(lobbysideCustomFields(createUser({ hasActiveSubscription: true })).subscription_type, 'individual');

assert.strictEqual(lobbysideCustomFields(createUser({ teamHasActiveSubscription: true })).subscription_type, 'team');

assert.strictEqual(lobbysideCustomFields(createUser({ isVip: true })).subscription_type, 'vip');

assert.strictEqual(lobbysideCustomFields(createUser()).subscription_type, 'free');
});
});
Loading