Skip to content

Commit

Permalink
Merge pull request #2550 from codecrafters-io/update-referral-discoun…
Browse files Browse the repository at this point in the history
…t-logic

feat: update discount models and refactor related logic
  • Loading branch information
rohitpaulk authored Jan 18, 2025
2 parents c118b61 + 25e7048 commit 3e2e212
Show file tree
Hide file tree
Showing 20 changed files with 192 additions and 120 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<EmberTooltip @text="Click to login via GitHub" />
{{else if this.currentUserIsReferrer}}
<EmberTooltip @text="You can't accept your own referral offer." />
{{else if this.currentUserIsAlreadyEligibleForReferralDiscount}}
{{else if this.currentUserHasActiveDiscountFromAffiliateReferral}}
<EmberTooltip @text="You've already accepted {{this.currentUser.currentAffiliateReferral.affiliateLink.usernameForDisplay}}'s referral offer." />
{{else if this.currentUserCanAccessMembershipBenefits}}
<EmberTooltip @text="As a CodeCrafters member, you already have full access." />
Expand Down
6 changes: 3 additions & 3 deletions app/components/affiliate-link-page/accept-referral-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default class AcceptReferralButtonComponent extends Component<Signature>
return (
!this.isCreatingAffiliateReferral &&
!this.currentUserIsReferrer &&
!this.currentUserIsAlreadyEligibleForReferralDiscount &&
!this.currentUserHasActiveDiscountFromAffiliateReferral &&
!this.currentUserCanAccessMembershipBenefits
);
}
Expand All @@ -42,8 +42,8 @@ export default class AcceptReferralButtonComponent extends Component<Signature>
return this.authenticator.currentUser && this.authenticator.currentUser.canAccessMembershipBenefits;
}

get currentUserIsAlreadyEligibleForReferralDiscount() {
return this.authenticator.currentUser && this.authenticator.currentUser.isEligibleForReferralDiscount;
get currentUserHasActiveDiscountFromAffiliateReferral() {
return this.authenticator.currentUser && this.authenticator.currentUser.activeDiscountFromAffiliateReferral;
}

get currentUserIsAnonymous() {
Expand Down
18 changes: 12 additions & 6 deletions app/components/course-page/course-stage-step/upgrade-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Store from '@ember-data/store';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { waitFor } from '@ember/test-waiters';

export interface Signature {
Element: HTMLDivElement;
Expand Down Expand Up @@ -62,24 +63,28 @@ export default class UpgradePromptComponent extends Component<Signature> {
@tracked isLoadingRegionalDiscount: boolean = true;
@tracked regionalDiscount: RegionalDiscountModel | null = null;

get activeDiscountForYearlyPlan() {
return this.authenticator.currentUser?.activeDiscountForYearlyPlan;
}

get featureToHighlight(): Feature {
return features.find((feature) => feature.slug === this.args.featureSlugToHighlight)!;
}

get secondaryCopyMarkdown(): string {
if (this.authenticator.currentUser!.isEligibleForEarlyBirdDiscount && this.regionalDiscount) {
if (this.activeDiscountForYearlyPlan && this.regionalDiscount) {
return `Plans start at ~~$30/month~~ $15/month (discounted price for ${
this.regionalDiscount.countryName
}) when billed annually. Save an additional 40% by joining within ${formatDistanceStrictWithOptions(
}) when billed annually. Save an additional ${this.activeDiscountForYearlyPlan.percentageOff}% by joining within ${formatDistanceStrictWithOptions(
{},
new Date(),
this.authenticator.currentUser!.earlyBirdDiscountEligibilityExpiresAt,
this.activeDiscountForYearlyPlan.expiresAt,
)}.`;
} else if (this.authenticator.currentUser!.isEligibleForEarlyBirdDiscount) {
return `Plans start at $30/month when billed annually. Save 40% by joining within ${formatDistanceStrictWithOptions(
} else if (this.activeDiscountForYearlyPlan) {
return `Plans start at $30/month when billed annually. Save ${this.activeDiscountForYearlyPlan.percentageOff}% by joining within ${formatDistanceStrictWithOptions(
{},
new Date(),
this.authenticator.currentUser!.earlyBirdDiscountEligibilityExpiresAt,
this.activeDiscountForYearlyPlan.expiresAt,
)}.`;
} else if (this.regionalDiscount) {
return `Plans start at ~~$30/month~~ $15/month (discounted price for ${this.regionalDiscount.countryName}) when billed annually.`;
Expand All @@ -93,6 +98,7 @@ export default class UpgradePromptComponent extends Component<Signature> {
}

@action
@waitFor
async handleDidInsert(): Promise<void> {
this.regionalDiscount = await this.store.createRecord('regional-discount').fetchCurrent();
this.isLoadingRegionalDiscount = false;
Expand Down
14 changes: 6 additions & 8 deletions app/components/pay-page/configure-checkout-session-modal.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Component from '@glimmer/component';
import PromotionalDiscountModel from 'codecrafters-frontend/models/promotional-discount';
import RegionalDiscountModel from 'codecrafters-frontend/models/regional-discount';
import Store from '@ember-data/store';
import window from 'ember-window-mock';
Expand All @@ -15,8 +16,7 @@ interface Signature {
additionalCheckoutSessionProperties: {
pricingFrequency: string;
regionalDiscount: RegionalDiscountModel | null;
earlyBirdDiscountEnabled?: boolean;
referralDiscountEnabled?: boolean;
promotionalDiscount: PromotionalDiscountModel | null;
};
};
}
Expand All @@ -31,14 +31,12 @@ export default class ConfigureCheckoutSessionModal extends Component<Signature>
this.isCreatingCheckoutSession = true;

const checkoutSession = this.store.createRecord('individual-checkout-session', {
autoRenewSubscription: false, // None of our plans are subscriptions at the moment
regionalDiscount: this.args.additionalCheckoutSessionProperties.regionalDiscount,
earlyBirdDiscountEnabled: this.args.additionalCheckoutSessionProperties.earlyBirdDiscountEnabled,
referralDiscountEnabled: this.args.additionalCheckoutSessionProperties.referralDiscountEnabled,
extraInvoiceDetailsRequested: this.extraInvoiceDetailsRequested,
successUrl: `${window.location.origin}/tracks`,
cancelUrl: `${window.location.origin}/pay`,
extraInvoiceDetailsRequested: this.extraInvoiceDetailsRequested,
pricingFrequency: this.args.additionalCheckoutSessionProperties.pricingFrequency,
promotionalDiscount: this.args.additionalCheckoutSessionProperties.promotionalDiscount,
regionalDiscount: this.args.additionalCheckoutSessionProperties.regionalDiscount,
successUrl: `${window.location.origin}/tracks`,
});

await checkoutSession.save();
Expand Down
3 changes: 0 additions & 3 deletions app/components/pay-page/early-bird-discount-notice.js

This file was deleted.

10 changes: 5 additions & 5 deletions app/components/pay-page/referral-discount-notice.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,21 @@
</div>

<div class="text-blue-700 prose max-w-none prose-blue leading-7">
{{#if @affiliateReferral.affiliateLink.overriddenAffiliateAvatarUrl}}
{{#if @discount.affiliateReferral.affiliateLink.overriddenAffiliateAvatarUrl}}
<img
alt="avatar"
src={{@affiliateReferral.affiliateLink.overriddenAffiliateAvatarUrl}}
src={{@discount.affiliateReferral.affiliateLink.overriddenAffiliateAvatarUrl}}
class="inline-block mb-0 w-6 h-6 transform -translate-y-px"
/>
{{else}}
<AvatarImage
@user={{@affiliateReferral.referrer}}
@user={{@discount.affiliateReferral.referrer}}
class="inline-block mb-0 w-6 h-6 border border-gray-100 rounded-full transform -translate-y-px"
/>
{{/if}}

<b>{{@affiliateReferral.affiliateLink.usernameForDisplay}}</b>'s referral offer: Subscribe by
<span class="font-semibold percy-timestamp">{{date-format @affiliateReferral.discountPeriodEndsAt format="PPPp"}}</span>
<b>{{@discount.affiliateReferral.affiliateLink.usernameForDisplay}}</b>'s referral offer: Subscribe by
<span class="font-semibold percy-timestamp">{{date-format @discount.expiresAt format="PPPp"}}</span>
to get
<span class="font-semibold">40% off</span>
the 1 year plan.
Expand Down
4 changes: 2 additions & 2 deletions app/components/pay-page/referral-discount-notice.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import Component from '@glimmer/component';
import type AffiliateReferralModel from 'codecrafters-frontend/models/affiliate-referral';
import type PromotionalDiscountModel from 'codecrafters-frontend/models/promotional-discount';

interface Signature {
Element: HTMLDivElement;

Args: {
affiliateReferral: AffiliateReferralModel;
discount: PromotionalDiscountModel;
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
{{! @glint-nocheck: not typesafe yet }}
<div class="flex p-3 bg-blue-100 border border-blue-300 rounded shadow-sm relative" data-test-private-leaderboard-feature-suggestion ...attributes>
<div class="flex p-3 bg-blue-100 border border-blue-300 rounded shadow-sm relative" data-test-signup-discount-notice ...attributes>
<div class="mr-3 mt-0.5">
{{svg-jar "clock" class="w-6 fill-current text-blue-400 animate-pulse"}}
</div>

<div class="text-blue-700 prose prose-blue leading-7">
Limited offer: Subscribe by
<span class="font-semibold percy-timestamp">{{date-format @expiresAt format="PPPp"}}</span>
<span class="font-semibold percy-timestamp">{{date-format @discount.expiresAt format="PPPp"}}</span>
to get
<span class="font-semibold">40% off</span>
the annual plan.
Expand Down
18 changes: 18 additions & 0 deletions app/components/pay-page/signup-discount-notice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Component from '@glimmer/component';
import PromotionalDiscountModel from 'codecrafters-frontend/models/promotional-discount';

interface Signature {
Element: HTMLDivElement;

Args: {
discount: PromotionalDiscountModel;
};
}

export default class SignupDiscountNoticeComponent extends Component<Signature> {}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
'PayPage::SignupDiscountNotice': typeof SignupDiscountNoticeComponent;
}
}
14 changes: 8 additions & 6 deletions app/controllers/pay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type AuthenticatorService from 'codecrafters-frontend/services/authentica
import type MonthlyChallengeBannerService from 'codecrafters-frontend/services/monthly-challenge-banner';
import type RouterService from '@ember/routing/router-service';
import type { ModelType } from 'codecrafters-frontend/routes/pay';
import type PromotionalDiscountModel from 'codecrafters-frontend/models/promotional-discount';

export default class PayController extends Controller {
declare model: ModelType;
Expand All @@ -21,20 +22,21 @@ export default class PayController extends Controller {
@tracked selectedPricingFrequency = '';
@tracked shouldApplyRegionalDiscount = false;

get activeDiscountForYearlyPlan(): PromotionalDiscountModel | null {
return this.user?.activeDiscountForYearlyPlan || null;
}

get additionalCheckoutSessionProperties() {
return {
pricingFrequency: this.selectedPricingFrequency,
promotionalDiscount: this.activeDiscountForYearlyPlan,
regionalDiscount: this.shouldApplyRegionalDiscount ? this.model.regionalDiscount : null,
earlyBirdDiscountEnabled: this.selectedPricingFrequency === 'yearly' && this.user?.isEligibleForEarlyBirdDiscount,
referralDiscountEnabled: this.selectedPricingFrequency === 'yearly' && this.user?.isEligibleForReferralDiscount,
};
}

get discountedYearlyPrice() {
if (this.user?.isEligibleForReferralDiscount) {
return 216;
} else if (this.user?.isEligibleForEarlyBirdDiscount) {
return 216;
if (this.activeDiscountForYearlyPlan) {
return this.activeDiscountForYearlyPlan.computeDiscountedPrice(360);
} else {
return null;
}
Expand Down
8 changes: 0 additions & 8 deletions app/models/affiliate-referral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,10 @@ export default class AffiliateReferralModel extends Model {
@attr('number') declare withdrawableEarningsAmountInCents: number;
@attr('number') declare withheldEarningsAmountInCents: number;

get discountPeriodEndsAt() {
return new Date(this.activatedAt.getTime() + 3 * 24 * 60 * 60 * 1000);
}

get hasStartedTrial() {
return this.statusIsTrialing || this.statusIsFirstChargeSuccessful || this.statusIsTrialCancelled;
}

get isWithinDiscountPeriod() {
return this.discountPeriodEndsAt > new Date();
}

get spentAmountInDollars() {
return this.spentAmountInCents / 100;
}
Expand Down
5 changes: 2 additions & 3 deletions app/models/individual-checkout-session.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import Model, { attr, belongsTo } from '@ember-data/model';
import type PromotionalDiscountModel from 'codecrafters-frontend/models/promotional-discount';
import type RegionalDiscountModel from 'codecrafters-frontend/models/regional-discount';

export default class IndividualCheckoutSessionModel extends Model {
@attr('boolean') declare autoRenewSubscription: boolean;
@attr('boolean') declare earlyBirdDiscountEnabled: boolean;
@attr('boolean') declare extraInvoiceDetailsRequested: boolean;
@attr('string') declare pricingFrequency: string;
@attr('boolean') declare referralDiscountEnabled: boolean;
@attr('string') declare successUrl: string;
@attr('string') declare cancelUrl: string;
@attr('string') declare url: string;

@belongsTo('regional-discount', { async: false, inverse: null }) declare regionalDiscount: RegionalDiscountModel;
@belongsTo('promotional-discount', { async: false, inverse: null }) declare promotionalDiscount: PromotionalDiscountModel;
}
29 changes: 29 additions & 0 deletions app/models/promotional-discount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import AffiliateReferralModel from 'codecrafters-frontend/models/affiliate-referral';
import Model, { attr, belongsTo } from '@ember-data/model';
import UserModel from 'codecrafters-frontend/models/user';

export default class PromotionalDiscountModel extends Model {
@belongsTo('affiliate-referral', { async: false, inverse: null }) declare affiliateReferral: AffiliateReferralModel;
@belongsTo('user', { async: false, inverse: 'promotionalDiscounts' }) declare user: UserModel;

@attr('date') createdAt!: Date;
@attr('date') expiresAt!: Date;
@attr('string') type!: 'signup' | 'affiliate_referral';
@attr('number') percentageOff!: number;

get isExpired() {
return this.expiresAt < new Date();
}

get isFromAffiliateReferral() {
return this.type === 'affiliate_referral';
}

get isFromSignup() {
return this.type === 'signup';
}

computeDiscountedPrice(price: number) {
return price - (price * this.percentageOff) / 100;
}
}
39 changes: 19 additions & 20 deletions app/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import ReferralActivationModel from 'codecrafters-frontend/models/referral-activ
import AffiliateEarningsPayoutModel from 'codecrafters-frontend/models/affiliate-earnings-payout';
import ReferralLinkModel from 'codecrafters-frontend/models/referral-link';
import RepositoryModel from 'codecrafters-frontend/models/repository';
import PromotionalDiscountModel from 'codecrafters-frontend/models/promotional-discount';
import SubscriptionModel from 'codecrafters-frontend/models/subscription';
import TeamMembershipModel from 'codecrafters-frontend/models/team-membership';
import UserProfileEventModel from 'codecrafters-frontend/models/user-profile-event';
Expand Down Expand Up @@ -63,10 +64,24 @@ export default class UserModel extends Model {
@hasMany('affiliate-earnings-payout', { async: false, inverse: 'user' }) affiliateEarningsPayouts!: AffiliateEarningsPayoutModel[];
@hasMany('referral-link', { async: false, inverse: 'user' }) referralLinks!: ReferralLinkModel[];
@hasMany('repository', { async: false, inverse: 'user' }) repositories!: RepositoryModel[];
@hasMany('promotional-discount', { async: false, inverse: 'user' }) promotionalDiscounts!: PromotionalDiscountModel[];
@hasMany('subscription', { async: false, inverse: 'user' }) subscriptions!: SubscriptionModel[];
@hasMany('team-membership', { async: false, inverse: 'user' }) teamMemberships!: TeamMembershipModel[];
@hasMany('user-profile-event', { async: false, inverse: 'user' }) profileEvents!: UserProfileEventModel[];

// Our discounts currently only apply to yearly plans. Change this if we need to support other pricing frequencies.
get activeDiscountForYearlyPlan(): PromotionalDiscountModel | null {
return this.activeDiscountFromAffiliateReferral || this.activeDiscountFromSignup;
}

get activeDiscountFromAffiliateReferral() {
return this.activePromotionalDiscountForType('affiliate_referral');
}

get activeDiscountFromSignup() {
return this.activePromotionalDiscountForType('signup');
}

get activeSubscription() {
return this.subscriptions.sortBy('startDate').reverse().findBy('isActive');
}
Expand Down Expand Up @@ -95,10 +110,6 @@ export default class UserModel extends Model {
return this.affiliateReferralsAsCustomer.rejectBy('isNew').sortBy('createdAt').reverse()[0];
}

get earlyBirdDiscountEligibilityExpiresAt() {
return new Date(this.createdAt.getTime() + 24 * 60 * 60 * 1000);
}

get expiredSubscription() {
if (this.hasActiveSubscription) {
return null;
Expand Down Expand Up @@ -143,22 +154,6 @@ export default class UserModel extends Model {
return this.referralLinks.rejectBy('isNew').length > 0;
}

get isEligibleForEarlyBirdDiscount() {
if (this.isEligibleForReferralDiscount) {
return false; // Prioritize referral
}

return this.earlyBirdDiscountEligibilityExpiresAt > new Date();
}

get isEligibleForReferralDiscount() {
if (this.currentAffiliateReferral) {
return this.currentAffiliateReferral.isWithinDiscountPeriod;
} else {
return false;
}
}

get isTeamAdmin() {
return !!this.managedTeams[0];
}
Expand Down Expand Up @@ -191,6 +186,10 @@ export default class UserModel extends Model {
return this.teamMemberships.mapBy('team');
}

activePromotionalDiscountForType(type: PromotionalDiscountModel['type']) {
return this.promotionalDiscounts.filterBy('type', type).rejectBy('isExpired').sortBy('createdAt').reverse()[0] || null;
}

canAttemptCourseStage(courseStage: CourseStageModel) {
return courseStage.isFree || courseStage.course.isFree || this.canAccessPaidContent;
}
Expand Down
Loading

0 comments on commit 3e2e212

Please sign in to comment.