Skip to content

Commit a7e50ed

Browse files
authored
Merge pull request #2646 from codecrafters-io/add-join-track-route-support
add-join-track-route-support
2 parents f094d51 + c3aad48 commit a7e50ed

File tree

10 files changed

+252
-19
lines changed

10 files changed

+252
-19
lines changed

app/components/affiliate-link-page/accept-referral-button.ts

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Component from '@glimmer/component';
22
import type AffiliateLinkModel from 'codecrafters-frontend/models/affiliate-link';
33
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
44
import type CourseModel from 'codecrafters-frontend/models/course';
5+
import type LanguageModel from 'codecrafters-frontend/models/language';
56
import type RouterService from '@ember/routing/router-service';
67
import type Store from '@ember-data/store';
78
import { action } from '@ember/object';
@@ -15,6 +16,7 @@ interface Signature {
1516
Args: {
1617
affiliateLink: AffiliateLinkModel;
1718
course?: CourseModel;
19+
language?: LanguageModel;
1820
};
1921
}
2022

@@ -78,6 +80,8 @@ export default class AcceptReferralButtonComponent extends Component<Signature>
7880

7981
if (this.args.course) {
8082
this.router.transitionTo('course', this.args.course.slug);
83+
} else if (this.args.language) {
84+
this.router.transitionTo('track', this.args.language.slug);
8185
} else {
8286
this.router.transitionTo('pay');
8387
}

app/components/affiliate-link-page/accept-referral-container.ts

+2
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import Component from '@glimmer/component';
22
import logoImage from '/assets/images/logo/logomark-color.svg';
33
import type AffiliateLinkModel from 'codecrafters-frontend/models/affiliate-link';
44
import type CourseModel from 'codecrafters-frontend/models/course';
5+
import type LanguageModel from 'codecrafters-frontend/models/language';
56

67
interface Signature {
78
Element: HTMLDivElement;
89

910
Args: {
1011
affiliateLink: AffiliateLinkModel;
1112
course?: CourseModel;
13+
language?: LanguageModel;
1214
verticalSize: 'tall' | 'compact';
1315
};
1416
}

app/components/track-page/header/index.hbs

+22-18
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
{{markdown-to-html @language.trackDescriptionMarkdown}}
1515
</p>
1616

17-
<div class="flex items-center gap-3 mb-5 flex-wrap">
17+
<div class="flex items-center gap-3 flex-wrap">
1818
{{#if (gt @language.trackLearnersCount 3000)}}
1919
{{! Extra if condition helps glint know that @language.trackLearnersCount is defined }}
2020
{{#if @language.trackLearnersCount}}
@@ -26,25 +26,29 @@
2626
<TrackPage::Header::Statistic @description="{{format-number @language.stagesCount}} exercises" @icon="terminal" class="flex-shrink-0" />
2727
</div>
2828

29-
<div class="flex items-center flex-wrap gap-x-2 gap-y-4">
30-
{{#if this.currentUserHasStartedTrack}}
31-
<TrackPage::ResumeTrackButton @language={{@language}} @courses={{@courses}} />
32-
{{else}}
33-
<TrackPage::StartTrackButton @language={{@language}} @courses={{@courses}} />
34-
{{/if}}
29+
{{#if (has-block "cta")}}
30+
{{yield to="cta"}}
31+
{{else}}
32+
<div class="flex items-center flex-wrap gap-x-2 gap-y-4 mt-5">
33+
{{#if this.currentUserHasStartedTrack}}
34+
<TrackPage::ResumeTrackButton @language={{@language}} @courses={{@courses}} />
35+
{{else}}
36+
<TrackPage::StartTrackButton @language={{@language}} @courses={{@courses}} />
37+
{{/if}}
3538

36-
{{#if (gt this.topParticipants.length 0)}}
37-
<div class="hidden sm:flex items-center">
38-
<div class="flex -space-x-1 hover:space-x-1 items-center">
39-
{{#each this.topParticipants as |user|}}
40-
<TrackPage::Header::TopParticipantAvatar @user={{user}} />
41-
{{/each}}
42-
</div>
39+
{{#if (gt this.topParticipants.length 0)}}
40+
<div class="hidden sm:flex items-center">
41+
<div class="flex -space-x-1 hover:space-x-1 items-center">
42+
{{#each this.topParticipants as |user|}}
43+
<TrackPage::Header::TopParticipantAvatar @user={{user}} />
44+
{{/each}}
45+
</div>
4346

44-
<span class="text-xs text-gray-600 dark:text-gray-400 ml-2">Join the best</span>
45-
</div>
46-
{{/if}}
47-
</div>
47+
<span class="text-xs text-gray-600 dark:text-gray-400 ml-2">Join the best</span>
48+
</div>
49+
{{/if}}
50+
</div>
51+
{{/if}}
4852
</div>
4953
<div class="ml-4 hidden md:flex h-36 w-36 flex-shrink-0">
5054
<LanguageLogo @language={{@language}} @variant="teal" class="dark:opacity-90" />

app/components/track-page/header/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ interface Signature {
1313
language: LanguageModel;
1414
courses: CourseModel[];
1515
};
16+
17+
Blocks: {
18+
cta?: [];
19+
};
1620
}
1721

1822
export default class TrackPageHeaderComponent extends Component<Signature> {

app/controllers/join-track.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Controller from '@ember/controller';
2+
import { inject as service } from '@ember/service';
3+
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
4+
import type CourseModel from 'codecrafters-frontend/models/course';
5+
import { type ModelType } from 'codecrafters-frontend/routes/join-track';
6+
7+
export default class JoinTrackController extends Controller {
8+
declare model: ModelType;
9+
10+
queryParams = [{ affiliateLinkSlug: 'via' }];
11+
12+
@service declare authenticator: AuthenticatorService;
13+
14+
get courses(): CourseModel[] {
15+
return this.model.courses.rejectBy('releaseStatusIsAlpha').rejectBy('releaseStatusIsDeprecated');
16+
}
17+
18+
get sortedCourses(): CourseModel[] {
19+
return this.courses.sortBy('sortPositionForTrack');
20+
}
21+
22+
get testimonials(): CourseModel['testimonials'] {
23+
return this.sortedCourses[0] ? this.sortedCourses[0].testimonials : [];
24+
}
25+
}

app/router.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ Router.map(function () {
6464
this.route('course-overview', { path: '/courses/:course_slug/overview' }); // TODO: Add dark mode support
6565
this.route('debug');
6666
this.route('join'); // TODO: Add dark mode support
67-
this.route('join-course', { path: '/join/:course_slug' }); // TODO: Add dark mode support
67+
this.route('join-course', { path: '/join/:course_slug' });
68+
this.route('join-track', { path: '/join-track/:track_slug' });
6869
this.route('login'); // TODO: Add dark mode support?
6970
this.route('logged-in'); // TODO: Add dark mode support?
7071
this.route('membership'); // TODO: Add dark mode support

app/routes/join-track.ts

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import BaseRoute from 'codecrafters-frontend/utils/base-route';
2+
import RouteInfoMetadata, { RouteColorScheme } from 'codecrafters-frontend/utils/route-info-metadata';
3+
import config from 'codecrafters-frontend/config/environment';
4+
import type AffiliateLinkModel from 'codecrafters-frontend/models/affiliate-link';
5+
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
6+
import type CourseModel from 'codecrafters-frontend/models/course';
7+
import type LanguageModel from 'codecrafters-frontend/models/language';
8+
import type MetaDataService from 'codecrafters-frontend/services/meta-data';
9+
import type RouterService from '@ember/routing/router-service';
10+
import type Store from '@ember-data/store';
11+
import { inject as service } from '@ember/service';
12+
13+
export interface ModelType {
14+
courses: CourseModel[];
15+
language: LanguageModel;
16+
affiliateLink: AffiliateLinkModel;
17+
}
18+
19+
export default class JoinTrackRoute extends BaseRoute {
20+
allowsAnonymousAccess = true;
21+
22+
@service declare authenticator: AuthenticatorService;
23+
@service declare metaData: MetaDataService;
24+
@service declare router: RouterService;
25+
@service declare store: Store;
26+
27+
previousMetaImageUrl: string | undefined;
28+
29+
afterModel(model: ModelType) {
30+
if (!model.affiliateLink || !model.language) {
31+
this.router.transitionTo('not-found');
32+
33+
return;
34+
}
35+
36+
this.previousMetaImageUrl = this.metaData.imageUrl;
37+
this.metaData.imageUrl = `${config.x.metaTagImagesBaseURL}language-${model.language.slug}.jpg`;
38+
}
39+
40+
buildRouteInfoMetadata(): RouteInfoMetadata {
41+
return new RouteInfoMetadata({ colorScheme: RouteColorScheme.Both });
42+
}
43+
44+
deactivate() {
45+
this.metaData.imageUrl = this.previousMetaImageUrl;
46+
}
47+
48+
async model(params: { track_slug: string; affiliateLinkSlug: string }): Promise<ModelType> {
49+
const affiliateLinks = (await this.store.query('affiliate-link', {
50+
slug: params.affiliateLinkSlug,
51+
include: 'user',
52+
})) as unknown as AffiliateLinkModel[];
53+
54+
const affiliateLink = affiliateLinks[0]!; // afterModel handles the case where this is undefined
55+
56+
// Make sure we have all courses loaded to display
57+
const courses = (await this.store.findAll('course', {
58+
include: 'extensions,stages,language-configurations.language',
59+
})) as unknown as CourseModel[];
60+
61+
const language = this.store.peekAll('language').findBy('slug', params.track_slug) as LanguageModel;
62+
63+
return {
64+
courses: courses.filter((course) => course.betaOrLiveLanguages.includes(language)),
65+
language,
66+
affiliateLink,
67+
};
68+
}
69+
}

app/templates/join-track.hbs

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{{page-title (concat "Join " this.model.affiliateLink.usernameForDisplay " on CodeCrafters") replace=true}}
2+
3+
{{! Note: This is very similar to the track page atm so it re-uses a lot of the same components. }}
4+
<div class="container mx-auto lg:max-w-screen-lg px-3 md:px-6 py-6 md:py-10">
5+
<TrackPage::Header @language={{@model.language}} @courses={{this.sortedCourses}}>
6+
<:cta>
7+
{{! suppress CTA }}
8+
</:cta>
9+
</TrackPage::Header>
10+
</div>
11+
12+
<div class="h-px bg-gray-200 dark:bg-white/5 w-full"></div>
13+
14+
<div class="bg-white dark:bg-gray-850">
15+
<div class="container mx-auto lg:max-w-screen-lg px-3 md:px-6 py-6 md:py-10">
16+
<div class="lg:hidden flex justify-center items-center pb-6 md:pb-10">
17+
<AffiliateLinkPage::AcceptReferralContainer
18+
@affiliateLink={{this.model.affiliateLink}}
19+
{{!-- @course={{this.model.course}} --}}
20+
@verticalSize="tall"
21+
class="w-full bg-github-green-dot-wall"
22+
/>
23+
</div>
24+
25+
<div class="flex items-start">
26+
<div class="flex-grow">
27+
<TrackPage::IntroductionAndCourses @language={{@model.language}} @courses={{this.sortedCourses}} class="w-full mb-4" />
28+
</div>
29+
30+
<div class="w-80 flex-shrink-0 hidden lg:block">
31+
<AffiliateLinkPage::AcceptReferralContainer
32+
@affiliateLink={{this.model.affiliateLink}}
33+
{{!-- @language={{this.model.language}} --}}
34+
@verticalSize="compact"
35+
class="mb-6 ml-2 w-full bg-github-green-dot-wall"
36+
/>
37+
38+
<TrackLeaderboard class="ml-2 mb-6" @language={{@model.language}} />
39+
40+
{{! TODO: Show testimonials for track instead of courses? }}
41+
<div class="mx-6">
42+
{{#each this.testimonials as |testimonial|}}
43+
<CourseOverviewPage::TestimonialListItem @testimonial={{testimonial}} />
44+
{{/each}}
45+
</div>
46+
</div>
47+
</div>
48+
49+
<div class="flex justify-center items-center py-6 md:py-10">
50+
<AffiliateLinkPage::AcceptReferralContainer
51+
@affiliateLink={{this.model.affiliateLink}}
52+
{{!-- @course={{this.model.course}} --}}
53+
@verticalSize="tall"
54+
class="w-full bg-github-green-dot-wall"
55+
/>
56+
</div>
57+
</div>
58+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import percySnapshot from '@percy/ember';
2+
import joinTrackPage from 'codecrafters-frontend/tests/pages/join-track-page';
3+
import testScenario from 'codecrafters-frontend/mirage/scenarios/test';
4+
import { currentURL } from '@ember/test-helpers';
5+
import { module, test } from 'qunit';
6+
import { setupAnimationTest } from 'ember-animated/test-support';
7+
import { setupApplicationTest } from 'codecrafters-frontend/tests/helpers';
8+
import { signIn } from 'codecrafters-frontend/tests/support/authentication-helpers';
9+
10+
module('Acceptance | view-join-track-page', function (hooks) {
11+
setupApplicationTest(hooks);
12+
setupAnimationTest(hooks);
13+
14+
test('can view join track page when not logged in', async function (assert) {
15+
testScenario(this.server);
16+
17+
this.server.create('affiliate-link', { user: this.server.schema.users.first(), slug: 'dummy' });
18+
19+
await joinTrackPage.visit({ track_slug: 'go' });
20+
assert.notOk(joinTrackPage.acceptReferralButtons[0].isVisible, 'First button is hidden (for mobile only)');
21+
assert.ok(joinTrackPage.acceptReferralButtons[1].isVisible, 'Second button is visible (leaderboard)');
22+
assert.ok(joinTrackPage.acceptReferralButtons[2].isVisible, 'Third button is visible (bottom of page)');
23+
assert.strictEqual(joinTrackPage.acceptReferralButtons.length, 3, 'Three accept referral buttons are present');
24+
25+
await percySnapshot('Join Track Page | Anonymous');
26+
});
27+
28+
test('can view affiliate link when logged in', async function (assert) {
29+
testScenario(this.server);
30+
signIn(this.owner, this.server);
31+
32+
this.server.create('affiliate-link', { user: this.server.schema.users.first(), slug: 'dummy' });
33+
34+
await joinTrackPage.visit({ track_slug: 'go' });
35+
assert.notOk(joinTrackPage.acceptReferralButtons[0].isVisible, 'First button is hidden (for mobile only)');
36+
assert.ok(joinTrackPage.acceptReferralButtons[1].isVisible, 'Second button is visible (leaderboard)');
37+
assert.ok(joinTrackPage.acceptReferralButtons[2].isVisible, 'Third button is visible (bottom of page)');
38+
assert.strictEqual(joinTrackPage.acceptReferralButtons.length, 3, 'Three accept referral buttons are present');
39+
40+
await percySnapshot('Affiliate Link Page | View Affiliate Link (anonymous)');
41+
});
42+
43+
test('redirects to not found if track slug is invalid', async function (assert) {
44+
testScenario(this.server);
45+
46+
this.server.create('affiliate-link', { user: this.server.schema.users.first(), slug: 'dummy' });
47+
48+
await joinTrackPage.visit({ track_slug: 'invalid', affiliate_link_slug: 'dummy' });
49+
assert.strictEqual(currentURL(), '/404');
50+
});
51+
52+
test('redirects to not found if affiliate link is invalid', async function (assert) {
53+
testScenario(this.server);
54+
55+
await joinTrackPage.visit({ track_slug: 'rust', affiliate_link_slug: 'invalid' });
56+
assert.strictEqual(currentURL(), '/404');
57+
});
58+
});

tests/pages/join-track-page.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { collection, visitable } from 'ember-cli-page-object';
2+
import createPage from 'codecrafters-frontend/tests/support/create-page';
3+
4+
export default createPage({
5+
acceptReferralButtons: collection('[data-test-accept-referral-button]'),
6+
7+
visit: visitable('/join-track/:track_slug'),
8+
});

0 commit comments

Comments
 (0)