Skip to content

Commit bd8c59c

Browse files
authored
feat: add premium tier to badges (#4558)
1 parent 8a4a196 commit bd8c59c

File tree

10 files changed

+342
-1
lines changed

10 files changed

+342
-1
lines changed

.snaplet/dataModel.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5600,6 +5600,20 @@
56005600
"isId": false,
56015601
"maxLength": null
56025602
},
5603+
{
5604+
"id": "public.profile_badges.premium_tier",
5605+
"name": "premium_tier",
5606+
"columnName": "premium_tier",
5607+
"type": "int4",
5608+
"isRequired": false,
5609+
"kind": "scalar",
5610+
"isList": false,
5611+
"isGenerated": false,
5612+
"sequence": false,
5613+
"hasDefaultValue": false,
5614+
"isId": false,
5615+
"maxLength": null
5616+
},
56035617
{
56045618
"name": "news",
56055619
"type": "news",

seed.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ const seedBadges = (): Partial<profile_badgesScalars>[] => [
147147
display_name: 'PRO',
148148
image_url:
149149
'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/icons/pro.svg',
150+
premium_tier: 1,
150151
},
151152
];
152153

shared/mocks/data/badges.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ export const badges: Partial<DBProfileBadge>[] = [
77
image_url:
88
'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/icons/pro.svg',
99
action_url: '',
10+
premium_tier: 1,
1011
},
1112
{
1213
name: 'supporter',
1314
display_name: 'Supporter',
1415
image_url:
1516
'https://wbskztclbriekwpehznv.supabase.co/storage/v1/object/public/one-army/icons/supporter.svg',
1617
action_url: '',
18+
premium_tier: null,
1719
},
1820
];

shared/models/profileBadge.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export class DBProfileBadge {
44
display_name: string;
55
image_url: string;
66
action_url: string | null;
7+
premium_tier: number | null;
78

89
constructor(obj: Partial<DBProfileBadge>) {
910
Object.assign(this, obj);
@@ -20,6 +21,7 @@ export class ProfileBadge {
2021
displayName: string;
2122
imageUrl: string;
2223
actionUrl?: string;
24+
premiumTier?: number;
2325

2426
constructor(obj: Partial<ProfileBadge>) {
2527
Object.assign(this, obj);
@@ -33,6 +35,7 @@ export class ProfileBadge {
3335
displayName: badge.display_name,
3436
imageUrl: badge.image_url,
3537
actionUrl: badge.action_url || undefined,
38+
premiumTier: badge.premium_tier || undefined,
3639
});
3740
}
3841

@@ -43,6 +46,7 @@ export class ProfileBadge {
4346
displayName: value.display_name,
4447
imageUrl: value.image_url,
4548
actionUrl: value.action_url || undefined,
49+
premiumTier: value.premium_tier || undefined,
4650
});
4751
}
4852
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { render } from '@testing-library/react';
2+
import { ProfileStoreProvider } from 'src/stores/Profile/profile.store';
3+
import { FactoryUser } from 'src/test/factories/User';
4+
import { describe, expect, it, vi } from 'vitest';
5+
6+
import { PremiumTierWrapper } from './PremiumTierWrapper';
7+
8+
vi.mock('src/stores/Profile/profile.store', () => ({
9+
useProfileStore: () => ({
10+
profile: FactoryUser({
11+
badges: [
12+
{
13+
id: 1,
14+
name: 'supporter',
15+
displayName: 'Supporter',
16+
imageUrl: 'https://example.com/icons/supporter.svg',
17+
},
18+
{
19+
id: 2,
20+
name: 'pro',
21+
displayName: 'PRO',
22+
imageUrl: 'https://example.com/icons/pro.svg',
23+
premiumTier: 1,
24+
},
25+
],
26+
}),
27+
}),
28+
ProfileStoreProvider: ({ children }: { children: React.ReactNode }) => children,
29+
}));
30+
31+
describe('PremiumTierWrapper', () => {
32+
it('renders fallback when user does not have required tier', () => {
33+
const { getByText } = render(
34+
<ProfileStoreProvider>
35+
<PremiumTierWrapper tierRequired={2} fallback={<div>Fallback Content</div>}>
36+
<div>Test Content</div>
37+
</PremiumTierWrapper>
38+
</ProfileStoreProvider>,
39+
);
40+
expect(getByText('Fallback Content')).toBeTruthy();
41+
});
42+
43+
it('renders child components when user has required tier', () => {
44+
const { getByText } = render(
45+
<ProfileStoreProvider>
46+
<PremiumTierWrapper tierRequired={1}>
47+
<div>Test Content</div>
48+
</PremiumTierWrapper>
49+
</ProfileStoreProvider>,
50+
);
51+
expect(getByText('Test Content')).toBeTruthy();
52+
});
53+
54+
it('renders child components when tierRequired is 0', () => {
55+
const { getByText } = render(
56+
<ProfileStoreProvider>
57+
<PremiumTierWrapper tierRequired={0}>
58+
<div>Test Content</div>
59+
</PremiumTierWrapper>
60+
</ProfileStoreProvider>,
61+
);
62+
expect(getByText('Test Content')).toBeTruthy();
63+
});
64+
});

src/common/PremiumTierWrapper.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from 'react';
2+
import { observer } from 'mobx-react';
3+
import { useProfileStore } from 'src/stores/Profile/profile.store';
4+
5+
import type { Profile } from 'oa-shared';
6+
7+
interface IProps {
8+
children: React.ReactNode;
9+
fallback?: React.ReactNode;
10+
tierRequired: number;
11+
}
12+
13+
export const PremiumTierWrapper = observer((props: IProps) => {
14+
const { children, fallback, tierRequired } = props;
15+
const { profile } = useProfileStore();
16+
17+
const hasRequiredTier = userHasPremiumTier(profile, tierRequired);
18+
19+
return <>{hasRequiredTier ? children : fallback || null}</>;
20+
});
21+
22+
export const userHasPremiumTier = (user?: Profile | null, tierRequired?: number): boolean => {
23+
if (!tierRequired || tierRequired <= 0) {
24+
return true;
25+
}
26+
27+
if (!user?.badges || user.badges.length === 0) {
28+
return false;
29+
}
30+
31+
return user.badges.some((badge) => badge.premiumTier === tierRequired);
32+
};

src/services/profileService.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { DBAuthorVotes, DBMedia, DBProfile, ProfileFormData, ProfileType }
99
export class ProfileServiceServer {
1010
constructor(private client: SupabaseClient) {}
1111

12+
// TODO: add premium_tier to profile_badges selection once migration is applied
1213
async getByAuthId(id: string): Promise<DBProfile | null> {
1314
const { data } = await this.client
1415
.from('profiles')
@@ -74,6 +75,7 @@ export class ProfileServiceServer {
7475
return data as DBProfile;
7576
}
7677

78+
// TODO: add premium_tier to profile_badges selection once migration is applied
7779
async getUsersByUsername(usernames: string[]): Promise<DBProfile[] | null> {
7880
const { data } = await this.client
7981
.from('profiles')
@@ -275,6 +277,7 @@ export class ProfileServiceServer {
275277
action_url
276278
)
277279
)`,
280+
// TODO: add premium_tier to profile_badges selection once migration is applied to CI test database
278281
)
279282
.single();
280283
if (error) {

src/test/factories/User.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const FactoryUser = (userOverloads: Partial<Profile> = {}): Partial<Profi
3030
name: 'pro',
3131
displayName: 'PRO',
3232
imageUrl: faker.image.avatar(),
33+
premiumTier: 1,
3334
},
3435
{
3536
id: 2,

0 commit comments

Comments
 (0)