-
Notifications
You must be signed in to change notification settings - Fork 18
Add a billing tab to the settings #2811
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 27 commits
f0cd0b9
22a50d9
fdf9480
7646797
ef59d5a
d2a4e06
a744efb
b8e5abe
4586692
492ea65
28c49d4
80fd584
54847e9
fc9bfe3
ff65f50
a23e813
5e42545
e44227a
2324df5
f387a3b
31426ec
c370b91
0fb27af
5fa3c1a
96700ab
324665f
bd80766
ac350c2
bc73fba
547b45b
263cab5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
<Settings::FormSection @title="Membership" @description="Your CodeCrafters membership details" data-test-membership-section> | ||
{{#if @user.hasActiveSubscription}} | ||
<div class="inline-flex items-center gap-2 rounded-full bg-teal-50 dark:bg-teal-900 px-2 py-1 border border-teal-500 dark:border-teal-400"> | ||
{{svg-jar "circle" class="h-5 w-5 text-teal-500"}} | ||
<div class="text-teal-500 font-semibold uppercase text-sm"> | ||
Membership active | ||
</div> | ||
</div> | ||
<div class="text-gray-500 dark:text-gray-400 text-xs mt-2"> | ||
{{#if (and @user.isVip (gt @user.vipStatusExpiresAt @user.activeSubscription.cancelAt))}} | ||
<p class="line-through"> | ||
You have access to all CodeCrafters content, valid until | ||
<b class="font-semibold">{{date-format @user.activeSubscription.cancelAt format="PPPp"}}</b>. | ||
</p> | ||
<p> | ||
🎉 You have VIP access to all CodeCrafters content, valid until | ||
<b class="font-semibold">{{date-format @user.vipStatusExpiresAt format="PPPp"}}</b>. | ||
</p> | ||
{{else}} | ||
<p> | ||
You have access to all CodeCrafters content, valid until | ||
<b class="font-semibold">{{date-format @user.activeSubscription.cancelAt format="PPPp"}}</b>. | ||
</p> | ||
{{/if}} | ||
</div> | ||
{{else if @user.isVip}} | ||
<div class="inline-flex items-center gap-2 rounded-full bg-teal-50 px-2 py-1 border border-teal-500"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Arpan-206 no dark mode styles. The same green pill above has different styles added. Maybe you'd want to extract this into a component? Something like |
||
{{svg-jar "circle" class="h-5 w-5 text-teal-500"}} | ||
<div class="text-teal-500 font-semibold uppercase text-sm"> | ||
VIP Access | ||
</div> | ||
</div> | ||
<div class="text-gray-500 dark:text-gray-400 text-xs mt-2"> | ||
<p> | ||
{{#if @user.vipStatusExpiresAt}} | ||
🎉 You have VIP access to all CodeCrafters content, valid until | ||
<b class="font-semibold">{{date-format @user.vipStatusExpiresAt format="PPPp"}}</b>. | ||
{{else}} | ||
🎉 You have VIP access to all CodeCrafters content. | ||
{{/if}} | ||
</p> | ||
</div> | ||
{{else if @user.expiredSubscription}} | ||
<div class="inline-flex items-center gap-2 rounded-full bg-red-50 px-2 py-1 border border-red-500"> | ||
{{svg-jar "circle" class="h-5 w-5 text-red-700 dark:text-red-300"}} | ||
<div class="text-red-700 dark:text-red-300 font-semibold uppercase text-sm"> | ||
Membership inactive | ||
</div> | ||
</div> | ||
<div class="text-gray-500 dark:text-gray-400 text-xs mt-2"> | ||
<p> | ||
Your CodeCrafters membership is | ||
<b class="text-red-600 font-semibold">currently inactive</b>. | ||
</p> | ||
<p> | ||
Start a new membership to get access to | ||
<a href="https://docs.codecrafters.io/membership" target="_blank" rel="noopener noreferrer">membership benefits</a>. | ||
</p> | ||
</div> | ||
<PrimaryLinkButton @size="small" @route="pay" class="mt-3"> | ||
Start membership → | ||
</PrimaryLinkButton> | ||
{{else}} | ||
<div class="inline-flex items-center gap-2 rounded-full bg-yellow-50 px-2 py-1 border border-yellow-500"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No dark mode styles ( |
||
{{svg-jar "circle" class="h-5 w-5 text-yellow-500"}} | ||
<div class="text-yellow-500 font-semibold uppercase text-sm"> | ||
No membership found | ||
</div> | ||
</div> | ||
<div class="text-gray-500 dark:text-gray-400 text-xs mt-2"> | ||
<p> | ||
You don't have a CodeCrafters membership. Start one to get access to | ||
<a href="https://docs.codecrafters.io/membership" target="_blank" rel="noopener noreferrer">membership benefits</a>. | ||
</p> | ||
</div> | ||
<PrimaryLinkButton @size="small" @route="pay" class="mt-3"> | ||
Start membership → | ||
</PrimaryLinkButton> | ||
{{/if}} | ||
</Settings::FormSection> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import Component from '@glimmer/component'; | ||
import type UserModel from 'codecrafters-frontend/models/user'; | ||
|
||
interface Signature { | ||
Element: HTMLDivElement; | ||
|
||
Args: { | ||
user: UserModel; | ||
}; | ||
} | ||
|
||
export default class MembershipSectionComponent extends Component<Signature> {} | ||
|
||
declare module '@glint/environment-ember-loose/registry' { | ||
export default interface Registry { | ||
'Settings::BillingPage::MembershipSection': typeof MembershipSectionComponent; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
<Settings::FormSection @title="Payment history" @description="Details of your previous payments" data-test-payment-history-section> | ||
{{#if this.isLoading}} | ||
<div class="flex justify-center py-8"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-gray-100"></div> | ||
</div> | ||
{{else if this.errorMessage}} | ||
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-400 rounded-md p-4"> | ||
<div class="flex gap-3"> | ||
<div class="flex-shrink-0"> | ||
{{svg-jar "x-circle" class="h-5 w-5 text-red-700 dark:text-red-300"}} | ||
</div> | ||
<p class="text-sm text-red-700 dark:text-red-300">{{this.errorMessage}}</p> | ||
</div> | ||
</div> | ||
{{else if (gt this.charges.length 0)}} | ||
<div class="grid grid-cols-3 gap-y-3"> | ||
<div class="border-b pb-2 text-gray-500 dark:text-gray-400 text-sm">Date</div> | ||
<div class="border-b pb-2 text-gray-500 dark:text-gray-400 text-sm text-right">Amount</div> | ||
<div class="border-b pb-2"></div> | ||
|
||
{{#each this.charges as |charge|}} | ||
<div class="text-gray-600 dark:text-gray-300 text-sm"> | ||
{{date-format charge.createdAt format="PPP"}} | ||
</div> | ||
<div | ||
class="{{if charge.statusIsFailed 'text-red-600 dark:text-red-400' 'text-gray-600 dark:text-gray-300'}} text-sm text-right" | ||
data-test-payment-history-item | ||
> | ||
<span class="font-semibold" data-test-amount>{{charge.displayString}}</span> | ||
{{#if (gt charge.amountRefunded 0)}} | ||
{{#if charge.isFullyRefunded}} | ||
<span class="text-red-600 dark:text-red-400" data-test-refund-text>(refunded)</span> | ||
{{else}} | ||
<span class="text-red-600 dark:text-red-400" data-test-refund-text>(<span | ||
class="font-semibold" | ||
>{{charge.refundedAmountDisplayString}}</span> | ||
refunded)</span> | ||
{{/if}} | ||
{{/if}} | ||
</div> | ||
<div class="flex items-center justify-end"> | ||
{{#if (and charge.invoiceId charge.statusIsSucceeded)}} | ||
<a | ||
href={{charge.invoiceDownloadUrl}} | ||
target="_blank" | ||
class="text-teal-500 dark:text-teal-400 hover:text-teal-600 dark:hover:text-teal-300 font-semibold text-sm" | ||
data-test-download-invoice-link | ||
rel="noopener noreferrer" | ||
> | ||
Download Invoice | ||
</a> | ||
{{else if charge.statusIsFailed}} | ||
<span class="text-gray-600 dark:text-gray-300 text-sm">Payment failed</span> | ||
{{/if}} | ||
</div> | ||
{{/each}} | ||
</div> | ||
{{else}} | ||
<div class="text-gray-700 dark:text-gray-200"> | ||
No payment history found. | ||
</div> | ||
{{/if}} | ||
</Settings::FormSection> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import Component from '@glimmer/component'; | ||
import { inject as service } from '@ember/service'; | ||
import { tracked } from '@glimmer/tracking'; | ||
import * as Sentry from '@sentry/ember'; | ||
import type Store from '@ember-data/store'; | ||
import type UserModel from 'codecrafters-frontend/models/user'; | ||
import type ChargeModel from 'codecrafters-frontend/models/charge'; | ||
|
||
interface Signature { | ||
Element: HTMLDivElement; | ||
Args: { | ||
user: UserModel; | ||
}; | ||
} | ||
|
||
export default class PaymentHistorySectionComponent extends Component<Signature> { | ||
@service declare store: Store; | ||
|
||
@tracked charges: ChargeModel[] = []; | ||
@tracked errorMessage: string | null = null; | ||
rohitpaulk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
@tracked isLoading = true; | ||
|
||
constructor(owner: unknown, args: Signature['Args']) { | ||
super(owner, args); | ||
this.loadCharges(); | ||
} | ||
|
||
async loadCharges() { | ||
this.isLoading = true; | ||
this.errorMessage = null; | ||
|
||
try { | ||
const result = await this.store.query('charge', { | ||
filter: { user_id: this.args.user.id }, | ||
}); | ||
this.charges = result.toArray(); | ||
} catch (error) { | ||
console.error('Failed to fetch charges:', error); | ||
this.errorMessage = 'Failed to load payment history. Please try again later.'; | ||
Arpan-206 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Sentry.captureException(error); | ||
this.charges = []; | ||
} finally { | ||
this.isLoading = false; | ||
} | ||
} | ||
} | ||
|
||
declare module '@glint/environment-ember-loose/registry' { | ||
export default interface Registry { | ||
'Settings::BillingPage::PaymentHistorySection': typeof PaymentHistorySectionComponent; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
<Settings::FormSection @title="Renewal" @description="Details of your upcoming payment"> | ||
<div class="inline-flex items-center gap-2 rounded-full bg-gray-50 dark:bg-gray-800 px-2 py-1 border border-gray-300 dark:border-gray-600"> | ||
{{svg-jar "circle" class="h-5 w-5 text-gray-400"}} | ||
<div class="text-gray-400 font-semibold uppercase text-sm"> | ||
Auto-renew disabled | ||
</div> | ||
</div> | ||
|
||
<div class="text-gray-500 dark:text-gray-400 text-xs mt-2"> | ||
Your membership does not renew automatically. Once your membership expires, you'll be able to make a new one-time payment. | ||
</div> | ||
</Settings::FormSection> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import Component from '@glimmer/component'; | ||
|
||
interface Signature { | ||
Element: HTMLDivElement; | ||
} | ||
|
||
export default class RenewalSectionComponent extends Component<Signature> {} | ||
|
||
declare module '@glint/environment-ember-loose/registry' { | ||
export default interface Registry { | ||
'Settings::BillingPage::RenewalSection': typeof RenewalSectionComponent; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,14 @@ | ||||||
<Settings::FormSection @title="Support" @description="How to get help with billing"> | ||||||
<a href="mailto:[email protected]?subject=Billing help (account: {{@user.username}})" class="inline-block rounded" data-test-support-section> | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
(Place on the outer |
||||||
<PrimaryButton @size="small" data-test-support-contact-button> | ||||||
Get help | ||||||
</PrimaryButton> | ||||||
</a> | ||||||
<div class="text-gray-500 dark:text-gray-400 text-xs mt-3"> | ||||||
<p> | ||||||
Questions? Click the button above or write to us at | ||||||
<a href="mailto:[email protected]" class="underline text-blue-500 dark:text-blue-400">[email protected]</a> | ||||||
and we'll help sort things out. | ||||||
</p> | ||||||
</div> | ||||||
</Settings::FormSection> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import Component from '@glimmer/component'; | ||
import type UserModel from 'codecrafters-frontend/models/user'; | ||
|
||
interface Signature { | ||
Element: HTMLDivElement; | ||
Args: { | ||
user: UserModel; | ||
}; | ||
} | ||
|
||
export default class SupportSectionComponent extends Component<Signature> {} | ||
|
||
declare module '@glint/environment-ember-loose/registry' { | ||
export default interface Registry { | ||
'Settings::BillingPage::SupportSection': typeof SupportSectionComponent; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import Controller from '@ember/controller'; | ||
import type ChargeModel from 'codecrafters-frontend/models/charge'; | ||
import type { ModelType as SettingsModelType } from 'codecrafters-frontend/routes/settings'; | ||
|
||
interface BillingModelType extends SettingsModelType { | ||
charges: ChargeModel[]; | ||
} | ||
|
||
export default class BillingController extends Controller { | ||
declare model: BillingModelType; | ||
} |
This file was deleted.
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
<Settings::BillingPage::MembershipSection @user={{@model.user}} /> | ||
<Settings::FormDivider /> | ||
<Settings::BillingPage::RenewalSection /> | ||
<Settings::FormDivider /> | ||
<Settings::BillingPage::SupportSection @user={{@model.user}} /> | ||
<Settings::FormDivider /> | ||
<Settings::BillingPage::PaymentHistorySection @user={{@model.user}} /> |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This shouldn't be needed, just use HTML with |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of svg let's just do HTML (
h-5 w-5 rounded-full
), that's what we use for simple cases like this