From 3bdcede7acbce8fdf6086388fcce9753e8e2773b Mon Sep 17 00:00:00 2001 From: chev Date: Sun, 14 Jun 2026 21:21:47 -0500 Subject: [PATCH 1/3] feat: add premium settings tab copy --- src/locales/en.json | 47 ++++++++++++++++++++++++++++++++++++++++++++- src/locales/ja.json | 47 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 7ab8079..1887c6d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -175,9 +175,50 @@ "updateDiscordConnectionDescription": "Re-sync your profile with Discord.", "updateDiscordButton": "Update Connection", "premiumMembershipLabel": "Premium Membership", - "premiumMembershipDescription": "You are on the Free plan. Compare plans to see Premium benefits.", + "premiumMembershipDescription": "You are on the Free plan. Compare plans in the Premium tab.", + "premiumMembershipDescriptionPremium": "Polycord Premium · renews July 11, 2026.", "manageSubscriptionButton": "Manage Subscription", + "manageBillingButton": "Manage billing", "viewPlansButton": "View plans", + "premiumTitle": "Premium", + "premiumTabDescriptionFree": "Everything in Free, plus more room to learn and be found.", + "premiumTabDescriptionPremium": "Your plan: everything below is included.", + "premiumPriceAmount": "$2.99", + "premiumPriceRenewal": "/ month · renews July 11, 2026", + "premiumPlanNote": "Cancel anytime. Your card keeps its theme until the period ends.", + "premiumUpgradeNote": "$2.99 / month · cancel anytime", + "premiumUpgradeButton": "Upgrade", + "premiumTag": "Premium", + "compareFeatureHeader": "Feature", + "compareFreeHeader": "Free", + "comparePremiumHeader": "Premium", + "compareNoValue": "No", + "compareBumpFeature": "Bump cooldown", + "compareBumpSub": "How often you can bump your profile back to the top of discovery.", + "compareBumpFree": "Every 3 hours", + "compareBumpPremium": "Every 1 h 30 m", + "compareLanguagesFeature": "Target languages", + "compareLanguagesSub": "Languages you can list as actively learning.", + "compareLanguagesFree": "Up to 2", + "compareLanguagesPremium": "Up to 10", + "compareTagsFeature": "Profile tags", + "compareTagsSub": "Interest and topic tags shown on your discovery card.", + "compareTagsFree": "Up to 5", + "compareTagsPremium": "Up to 8", + "compareColoursFeature": "Card colours", + "compareColoursSub": "The banner colour on your discovery card.", + "compareColoursFree": "3 flat colours", + "compareColoursPremium": "Gradient themes", + "compareVoiceFeature": "Voice intro", + "compareVoiceSub": "A short recording partners can play from your card.", + "compareVoicePremium": "20-second recording", + "compareCopyFeature": "Username copy alerts", + "compareCopySub": "When someone copies your Discord username.", + "compareCopyFree": "Anonymous only", + "compareCopyPremium": "See who copied you", + "compareViewFeature": "Profile view alerts", + "compareViewSub": "When someone opens your full profile.", + "compareViewPremium": "See who viewed you", "emailLabel": "Email Address", "emailPlaceholder": "Enter your email for recovery", "emailRequired": "Email is required.", @@ -202,6 +243,10 @@ "matchAlertDescription": "Alert me when a new language partner match is found.", "profileInteractionAlertLabel": "Profile Interaction Alert", "profileInteractionAlertDescription": "Alert me when another user copies my Discord username.", + "profileInteractionAlertDescriptionPremium": "Alert me when someone copies my Discord username. Shows their name when they're signed in.", + "profileViewAlertLabel": "Profile View Alert", + "profileViewAlertDescriptionPremium": "Alert me when someone views my profile. Shows their name when they're signed in.", + "profileViewAlertDescriptionFree": "Alert me when someone views my profile. See who, when they're signed in.", "saveSettings": "Save Settings", "saving": "Saving...", "unsavedChanges": "You have unsaved changes", diff --git a/src/locales/ja.json b/src/locales/ja.json index 03e3b7d..fdfb71d 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -175,9 +175,50 @@ "updateDiscordConnectionDescription": "プロフィールをDiscordと再同期する。", "updateDiscordButton": "連携を更新", "premiumMembershipLabel": "プレミアムメンバーシップ", - "premiumMembershipDescription": "現在は無料プランです。プランを比較してPremiumの特典を確認できます。", + "premiumMembershipDescription": "現在は無料プランです。Premiumタブでプランを比較できます。", + "premiumMembershipDescriptionPremium": "Polycord プレミアム · 2026年7月11日に更新。", "manageSubscriptionButton": "サブスクリプションを管理", + "manageBillingButton": "お支払いを管理", "viewPlansButton": "プランを見る", + "premiumTitle": "プレミアム", + "premiumTabDescriptionFree": "無料プランのすべてに加えて、学びと発見の幅がさらに広がります。", + "premiumTabDescriptionPremium": "現在のプラン:以下のすべてが含まれます。", + "premiumPriceAmount": "$2.99", + "premiumPriceRenewal": "/ 月 · 2026年7月11日に更新", + "premiumPlanNote": "いつでもキャンセルできます。期間が終了するまでカードのテーマは維持されます。", + "premiumUpgradeNote": "$2.99 / 月 · いつでもキャンセル可能", + "premiumUpgradeButton": "アップグレード", + "premiumTag": "プレミアム", + "compareFeatureHeader": "機能", + "compareFreeHeader": "無料", + "comparePremiumHeader": "プレミアム", + "compareNoValue": "なし", + "compareBumpFeature": "バンプのクールダウン", + "compareBumpSub": "プロフィールをディスカバリーの先頭に再表示できる頻度。", + "compareBumpFree": "3時間ごと", + "compareBumpPremium": "1時間30分ごと", + "compareLanguagesFeature": "学習中の言語", + "compareLanguagesSub": "現在学習中として登録できる言語の数。", + "compareLanguagesFree": "最大2つ", + "compareLanguagesPremium": "最大10", + "compareTagsFeature": "プロフィールタグ", + "compareTagsSub": "ディスカバリーカードに表示される興味や話題のタグ。", + "compareTagsFree": "最大5つ", + "compareTagsPremium": "最大8つ", + "compareColoursFeature": "カードの色", + "compareColoursSub": "ディスカバリーカードのバナーの色。", + "compareColoursFree": "単色3色", + "compareColoursPremium": "グラデーションテーマ", + "compareVoiceFeature": "ボイスイントロ", + "compareVoiceSub": "相手がカードから再生できる短い録音。", + "compareVoicePremium": "20秒の録音", + "compareCopyFeature": "ユーザー名コピーの通知", + "compareCopySub": "誰かがあなたのDiscordユーザー名をコピーしたとき。", + "compareCopyFree": "匿名のみ", + "compareCopyPremium": "コピーした相手を表示", + "compareViewFeature": "プロフィール閲覧の通知", + "compareViewSub": "誰かがあなたの完全なプロフィールを開いたとき。", + "compareViewPremium": "閲覧した相手を表示", "emailLabel": "メールアドレス", "emailPlaceholder": "アカウント復旧用のメールアドレスを入力", "emailRequired": "メールアドレスを入力してください。", @@ -202,6 +243,10 @@ "matchAlertDescription": "新しい言語パートナーが見つかったときに通知する。", "profileInteractionAlertLabel": "プロフィール操作の通知", "profileInteractionAlertDescription": "他のユーザーが自分のDiscordユーザー名をコピーしたときに通知する。", + "profileInteractionAlertDescriptionPremium": "誰かが自分のDiscordユーザー名をコピーしたときに通知する。相手がサインインしている場合は名前を表示します。", + "profileViewAlertLabel": "プロフィール閲覧の通知", + "profileViewAlertDescriptionPremium": "誰かが自分のプロフィールを閲覧したときに通知する。相手がサインインしている場合は名前を表示します。", + "profileViewAlertDescriptionFree": "誰かが自分のプロフィールを閲覧したときに通知する。相手がサインインしていれば誰かわかります。", "saveSettings": "設定を保存", "saving": "保存中...", "unsavedChanges": "保存されていない変更があります", From 023c8afaa1167af30f4147b217525ddae144249a Mon Sep 17 00:00:00 2001 From: chev Date: Sun, 14 Jun 2026 21:21:53 -0500 Subject: [PATCH 2/3] feat: add free versus premium comparison table --- .../Settings/CompareTable.stories.tsx | 31 ++++++ src/features/Settings/CompareTable.tsx | 104 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/features/Settings/CompareTable.stories.tsx create mode 100644 src/features/Settings/CompareTable.tsx diff --git a/src/features/Settings/CompareTable.stories.tsx b/src/features/Settings/CompareTable.stories.tsx new file mode 100644 index 0000000..b81d400 --- /dev/null +++ b/src/features/Settings/CompareTable.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, within } from '@storybook/test'; +import { CompareTable } from './CompareTable'; + +const meta: Meta = { + title: 'Features/Settings/CompareTable', + component: CompareTable, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByText('Feature')).toBeInTheDocument(); + await expect(canvas.getByText('Bump cooldown')).toBeInTheDocument(); + await expect(canvas.getByText('Profile view alerts')).toBeInTheDocument(); + await expect(canvas.getByText('Every 1 h 30 m')).toBeInTheDocument(); + await expect(canvas.getByText('See who viewed you')).toBeInTheDocument(); + await expect(canvas.getAllByText('No')).toHaveLength(2); + }, +}; diff --git a/src/features/Settings/CompareTable.tsx b/src/features/Settings/CompareTable.tsx new file mode 100644 index 0000000..58c8285 --- /dev/null +++ b/src/features/Settings/CompareTable.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +const COLUMNS = + 'grid-cols-[minmax(0,1fr)_90px_130px] min-[720px]:grid-cols-[minmax(0,1fr)_150px_170px]'; + +type CompareRow = { + feature: string; + sub: string; + free: string | null; + premium: string; +}; + +const CMP_ROWS: CompareRow[] = [ + { + feature: 'compareBumpFeature', + sub: 'compareBumpSub', + free: 'compareBumpFree', + premium: 'compareBumpPremium', + }, + { + feature: 'compareLanguagesFeature', + sub: 'compareLanguagesSub', + free: 'compareLanguagesFree', + premium: 'compareLanguagesPremium', + }, + { + feature: 'compareTagsFeature', + sub: 'compareTagsSub', + free: 'compareTagsFree', + premium: 'compareTagsPremium', + }, + { + feature: 'compareColoursFeature', + sub: 'compareColoursSub', + free: 'compareColoursFree', + premium: 'compareColoursPremium', + }, + { + feature: 'compareVoiceFeature', + sub: 'compareVoiceSub', + free: null, + premium: 'compareVoicePremium', + }, + { + feature: 'compareCopyFeature', + sub: 'compareCopySub', + free: 'compareCopyFree', + premium: 'compareCopyPremium', + }, + { + feature: 'compareViewFeature', + sub: 'compareViewSub', + free: null, + premium: 'compareViewPremium', + }, +]; + +export const CompareTable = () => { + const t = useTranslations('Settings'); + + return ( +
+
+
+ {t('compareFeatureHeader')} +
+
+ {t('compareFreeHeader')} +
+
+ {t('comparePremiumHeader')} +
+
+ {CMP_ROWS.map((row) => ( +
+
+

+ {t(row.feature)} +

+

{t(row.sub)}

+
+
+ {row.free ? ( + t(row.free) + ) : ( + {t('compareNoValue')} + )} +
+
+ + {t(row.premium)} +
+
+ ))} +
+ ); +}; From 29c8c34e4be20cd5ed75b9a98515c9f3ba081230 Mon Sep 17 00:00:00 2001 From: chev Date: Sun, 14 Jun 2026 21:22:00 -0500 Subject: [PATCH 3/3] feat: add premium settings tab with gated view alert --- .../[lang]/settings/SettingsRouteClient.tsx | 1 + .../Settings/SettingsPage.stories.tsx | 73 +++++++++ src/features/Settings/SettingsPage.tsx | 139 ++++++++++++++++-- 3 files changed, 203 insertions(+), 10 deletions(-) diff --git a/src/app/[lang]/settings/SettingsRouteClient.tsx b/src/app/[lang]/settings/SettingsRouteClient.tsx index 2f561d6..c77cfb9 100644 --- a/src/app/[lang]/settings/SettingsRouteClient.tsx +++ b/src/app/[lang]/settings/SettingsRouteClient.tsx @@ -51,6 +51,7 @@ export const SettingsRouteClient = ({ pushNotifications: true, matchAlert: true, profileInteractionAlert: true, + profileViewAlert: false, theme: 'dark', applicationLanguage: locale, timeFormat: '24hr', diff --git a/src/features/Settings/SettingsPage.stories.tsx b/src/features/Settings/SettingsPage.stories.tsx index 9a0877f..a20e76d 100644 --- a/src/features/Settings/SettingsPage.stories.tsx +++ b/src/features/Settings/SettingsPage.stories.tsx @@ -10,6 +10,7 @@ const defaultSettings: SettingsFormValues = { pushNotifications: true, matchAlert: true, profileInteractionAlert: true, + profileViewAlert: true, theme: 'dark', applicationLanguage: 'en', timeFormat: '24hr', @@ -144,3 +145,75 @@ export const SaveFlow: Story = { ); }, }; + +export const PremiumTabFree: Story = { + args: { premium: false }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + fireEvent.click(canvas.getByRole('button', { name: 'Premium' })); + + await waitFor(() => + expect(canvas.getByText('Bump cooldown')).toBeInTheDocument(), + ); + expect(canvas.getByText('Every 1 h 30 m')).toBeInTheDocument(); + expect(canvas.getByText('See who viewed you')).toBeInTheDocument(); + expect(canvas.getAllByText('No')).toHaveLength(2); + + expect( + canvas.getByText('$2.99 / month · cancel anytime'), + ).toBeInTheDocument(); + expect(canvas.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument(); + expect( + canvas.queryByRole('button', { name: 'Manage billing' }), + ).not.toBeInTheDocument(); + }, +}; + +export const PremiumTabPremium: Story = { + args: { premium: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + fireEvent.click(canvas.getByRole('button', { name: 'Premium' })); + + await waitFor(() => + expect( + canvas.getByText('Your plan: everything below is included.'), + ).toBeInTheDocument(), + ); + expect(canvas.getByText('$2.99')).toBeInTheDocument(); + expect(canvas.getByText('Bump cooldown')).toBeInTheDocument(); + expect( + canvas.getByRole('button', { name: 'Manage billing' }), + ).toBeInTheDocument(); + expect( + canvas.queryByRole('button', { name: 'Upgrade' }), + ).not.toBeInTheDocument(); + }, +}; + +export const GatedProfileViewAlert: Story = { + args: { premium: false }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + fireEvent.click(canvas.getByRole('button', { name: 'Notifications' })); + + await waitFor(() => + expect(canvas.getByText('Profile View Alert')).toBeInTheDocument(), + ); + expect(canvas.getAllByText('Premium')).toHaveLength(2); + + fireEvent.click(canvas.getByRole('switch', { name: 'Profile View Alert' })); + + await waitFor(() => + expect(canvas.getByText('Bump cooldown')).toBeInTheDocument(), + ); + expect( + canvas.getByText( + 'Everything in Free, plus more room to learn and be found.', + ), + ).toBeInTheDocument(); + }, +}; diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx index c20cc5e..39e6517 100644 --- a/src/features/Settings/SettingsPage.tsx +++ b/src/features/Settings/SettingsPage.tsx @@ -18,6 +18,7 @@ import { Toggle, } from '@/components/Form'; import { languageOptions, type TimeFormat } from '@/constants/languages'; +import { CompareTable } from './CompareTable'; const settingsSchema = z.object({ isPublic: z.boolean(), @@ -27,6 +28,7 @@ const settingsSchema = z.object({ pushNotifications: z.boolean(), matchAlert: z.boolean(), profileInteractionAlert: z.boolean(), + profileViewAlert: z.boolean(), theme: z.enum(['dark', 'light']), applicationLanguage: z.string().min(1), timeFormat: z.enum(['12hr', '24hr']), @@ -45,14 +47,21 @@ export type SettingsPageProps = { onSubmit?: (data: SettingsFormValues) => Promise | void; onUpdateDiscordConnection: () => void; onManageSubscription: () => void; + premium?: boolean; userAvatarUrl?: string; userDisplayName: string; }; -type SectionId = 'account' | 'appearance' | 'privacy' | 'notifications'; +type SectionId = + | 'account' + | 'premium' + | 'appearance' + | 'privacy' + | 'notifications'; const sections: { id: SectionId; labelKey: string }[] = [ { id: 'account', labelKey: 'accountTitle' }, + { id: 'premium', labelKey: 'premiumTitle' }, { id: 'appearance', labelKey: 'appearanceTitle' }, { id: 'privacy', labelKey: 'privacyTitle' }, { id: 'notifications', labelKey: 'notificationsTitle' }, @@ -111,6 +120,7 @@ export const SettingsPage: React.FC = ({ onSubmit: onSubmitProp, onUpdateDiscordConnection, onManageSubscription, + premium = false, userAvatarUrl, userDisplayName, }) => { @@ -319,15 +329,29 @@ export const SettingsPage: React.FC = ({ - + {premium ? ( + + ) : ( + + )} = ({ )} + {activeSection === 'premium' && ( + + {premium ? ( +
+
+
+ + {t('premiumPriceAmount')} + + + {t('premiumPriceRenewal')} + +
+

+ {t('premiumPlanNote')} +

+
+ +
+ ) : null} + + + + {premium ? null : ( +
+ + {t('premiumUpgradeNote')} + + +
+ )} +
+ )} + {activeSection === 'appearance' && ( = ({ = ({ )} /> + + {premium ? ( + + ( + + )} + /> + + ) : ( +
+
+

+ {t('profileViewAlertLabel')} + + {t('premiumTag')} + +

+

+ {t('profileViewAlertDescriptionFree')} +

+
+
+ jumpToSection('premium')} + aria-label={t('profileViewAlertLabel')} + /> +
+
+ )}
)}