From 4453e0e3db64a8c209fe48ae625dd5cfb3a71667 Mon Sep 17 00:00:00 2001 From: chev Date: Fri, 19 Jun 2026 23:36:36 -0500 Subject: [PATCH 01/10] feat: add profile bump api --- drizzle/0004_shocking_wendell_rand.sql | 2 + drizzle/meta/0004_snapshot.json | 580 +++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/api/profile/bump/route.ts | 57 ++ src/db/profiles.ts | 27 +- src/db/schema.ts | 2 + src/features/Discovery/bumpProfileRequest.ts | 33 ++ src/features/Profile/bumpProfile.ts | 18 + src/lib/entitlements.ts | 7 + 9 files changed, 731 insertions(+), 2 deletions(-) create mode 100644 drizzle/0004_shocking_wendell_rand.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 src/app/api/profile/bump/route.ts create mode 100644 src/features/Discovery/bumpProfileRequest.ts create mode 100644 src/features/Profile/bumpProfile.ts create mode 100644 src/lib/entitlements.ts diff --git a/drizzle/0004_shocking_wendell_rand.sql b/drizzle/0004_shocking_wendell_rand.sql new file mode 100644 index 0000000..acfe476 --- /dev/null +++ b/drizzle/0004_shocking_wendell_rand.sql @@ -0,0 +1,2 @@ +ALTER TABLE "profiles" ADD COLUMN "last_bumped_at" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "profiles_last_bumped_at_idx" ON "profiles" USING btree ("last_bumped_at"); \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..718323b --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,580 @@ +{ + "id": "5f349de4-e411-4255-8c26-36ffc26542db", + "prevId": "ce2b3ae6-fe2b-4d70-b44b-3d71f3e997aa", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.profile_target_languages": { + "name": "profile_target_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "proficiency_level": { + "name": "proficiency_level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "profile_target_languages_profile_id_idx": { + "name": "profile_target_languages_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_language_idx": { + "name": "profile_target_languages_language_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_profile_language_idx": { + "name": "profile_target_languages_profile_language_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_target_languages_profile_position_idx": { + "name": "profile_target_languages_profile_position_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profile_target_languages_profile_id_profiles_id_fk": { + "name": "profile_target_languages_profile_id_profiles_id_fk", + "tableFrom": "profile_target_languages", + "tableTo": "profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profile_target_languages_position_check": { + "name": "profile_target_languages_position_check", + "value": "\"profile_target_languages\".\"position\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_anonymous_copy": { + "name": "allow_anonymous_copy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_timezone": { + "name": "display_timezone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "primary_language": { + "name": "primary_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "target_language": { + "name": "target_language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "proficiency_level": { + "name": "proficiency_level", + "type": "proficiency_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "availability": { + "name": "availability", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'flexible'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_bumped_at": { + "name": "last_bumped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "profiles_user_id_idx": { + "name": "profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_public_idx": { + "name": "profiles_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_primary_language_idx": { + "name": "profiles_primary_language_idx", + "columns": [ + { + "expression": "primary_language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_target_language_idx": { + "name": "profiles_target_language_idx", + "columns": [ + { + "expression": "target_language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_country_idx": { + "name": "profiles_country_idx", + "columns": [ + { + "expression": "country", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_last_bumped_at_idx": { + "name": "profiles_last_bumped_at_idx", + "columns": [ + { + "expression": "last_bumped_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profiles_user_id_users_id_fk": { + "name": "profiles_user_id_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profiles_bio_length_check": { + "name": "profiles_bio_length_check", + "value": "char_length(\"profiles\".\"bio\") between 10 and 500" + }, + "profiles_availability_check": { + "name": "profiles_availability_check", + "value": "\"profiles\".\"availability\" in ('weeknights', 'weekends', 'weekday_mornings', 'flexible')" + }, + "profiles_tags_limit_check": { + "name": "profiles_tags_limit_check", + "value": "cardinality(\"profiles\".\"tags\") <= 6" + } + }, + "isRLSEnabled": false + }, + "public.saved_profiles": { + "name": "saved_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "saved_profiles_user_profile_idx": { + "name": "saved_profiles_user_profile_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_profiles_user_id_idx": { + "name": "saved_profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_profiles_profile_id_idx": { + "name": "saved_profiles_profile_id_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_profiles_user_id_users_id_fk": { + "name": "saved_profiles_user_id_users_id_fk", + "tableFrom": "saved_profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_profiles_profile_id_profiles_id_fk": { + "name": "saved_profiles_profile_id_profiles_id_fk", + "tableFrom": "saved_profiles", + "tableTo": "profiles", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_discord_user_id_idx": { + "name": "users_discord_user_id_idx", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.proficiency_level": { + "name": "proficiency_level", + "schema": "public", + "values": ["beginner", "intermediate", "advanced", "native-level"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 45505c5..f8d389d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1781921410375, "tag": "0003_petite_darkhawk", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1781930036703, + "tag": "0004_shocking_wendell_rand", + "breakpoints": true } ] } diff --git a/src/app/api/profile/bump/route.ts b/src/app/api/profile/bump/route.ts new file mode 100644 index 0000000..269ab18 --- /dev/null +++ b/src/app/api/profile/bump/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server'; +import { + bumpProfileForUser, + getProfileByUserId, + upsertDiscordUser, +} from '@/db'; +import { getBumpCooldown } from '@/features/Profile/bumpProfile'; +import { getCurrentUser } from '@/lib/auth'; +import { hasPremiumEntitlement } from '@/lib/entitlements'; + +export const POST = async () => { + const currentUser = await getCurrentUser(); + + if (!currentUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await upsertDiscordUser(currentUser); + const row = await getProfileByUserId(user.id); + + if (!row?.profile.isPublic) { + return NextResponse.json( + { error: 'Public profile required' }, + { status: 404 }, + ); + } + + const premium = hasPremiumEntitlement(currentUser); + const { nextBumpAt, remainingMs } = getBumpCooldown( + row.profile.lastBumpedAt, + premium, + ); + + if (remainingMs > 0) { + return NextResponse.json( + { + error: 'Cooldown active', + nextBumpAt: nextBumpAt.toISOString(), + remainingMs, + }, + { status: 429 }, + ); + } + + const bumpedAt = new Date(); + await bumpProfileForUser(user.id, bumpedAt); + + return NextResponse.json({ + lastBumpedAt: bumpedAt.toISOString(), + nextBumpAt: getBumpCooldown( + bumpedAt, + premium, + bumpedAt, + ).nextBumpAt.toISOString(), + premium, + }); +}; diff --git a/src/db/profiles.ts b/src/db/profiles.ts index 5519137..a7cf581 100644 --- a/src/db/profiles.ts +++ b/src/db/profiles.ts @@ -1,6 +1,6 @@ import 'server-only'; -import { and, asc, desc, eq, inArray } from 'drizzle-orm'; +import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'; import { availabilityPresetToPattern } from '@/constants/availability'; import { isValidAvailability } from '@/constants/languages'; import type { DiscoveryProfile } from '@/features/Discovery/ProfileCard'; @@ -73,6 +73,13 @@ const toDiscoveryProfile = ({ ? availabilityPresetToPattern(profile.availability) : undefined, allowAnonymousCopy: profile.allowAnonymousCopy, + lastBumpedAt: profile.lastBumpedAt?.toISOString(), + bumpedMinutesAgo: profile.lastBumpedAt + ? Math.max( + 0, + Math.floor((Date.now() - profile.lastBumpedAt.getTime()) / 60000), + ) + : undefined, }); const targetLanguagesForProfile = ( @@ -224,7 +231,10 @@ export const listPublicProfiles = async () => { .from(profiles) .innerJoin(users, eq(profiles.userId, users.id)) .where(eq(profiles.isPublic, true)) - .orderBy(desc(profiles.updatedAt)); + .orderBy( + sql`${profiles.lastBumpedAt} desc nulls last`, + desc(profiles.updatedAt), + ); const targetLanguagesByProfile = await listTargetLanguagesByProfileIds( rows.map((row) => row.profile.id), @@ -338,4 +348,17 @@ export const deleteProfileForUser = async (userId: string) => { return deletedProfiles.length > 0; }; +export const bumpProfileForUser = async ( + userId: string, + bumpedAt = new Date(), +) => { + const [profile] = await db + .update(profiles) + .set({ lastBumpedAt: bumpedAt }) + .where(eq(profiles.userId, userId)) + .returning(); + + return profile ?? null; +}; + export const mapProfileToDiscoveryProfile = toDiscoveryProfile; diff --git a/src/db/schema.ts b/src/db/schema.ts index 98a4f67..c1866e3 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -59,6 +59,7 @@ export const profiles = pgTable( tags: text('tags').array().default(sql`'{}'::text[]`).notNull(), country: varchar('country', { length: 2 }), timezone: varchar('timezone', { length: 64 }), + lastBumpedAt: timestamp('last_bumped_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }) .defaultNow() .notNull(), @@ -72,6 +73,7 @@ export const profiles = pgTable( index('profiles_primary_language_idx').on(table.primaryLanguage), index('profiles_target_language_idx').on(table.targetLanguage), index('profiles_country_idx').on(table.country), + index('profiles_last_bumped_at_idx').on(table.lastBumpedAt), check( 'profiles_bio_length_check', sql`char_length(${table.bio}) between 10 and 500`, diff --git a/src/features/Discovery/bumpProfileRequest.ts b/src/features/Discovery/bumpProfileRequest.ts new file mode 100644 index 0000000..5348cd5 --- /dev/null +++ b/src/features/Discovery/bumpProfileRequest.ts @@ -0,0 +1,33 @@ +export type BumpProfileResponse = { + lastBumpedAt: string; + nextBumpAt: string; + premium: boolean; +}; + +export class BumpProfileError extends Error { + constructor( + message: string, + readonly status: number, + readonly remainingMs?: number, + ) { + super(message); + } +} + +export const bumpProfileRequest = async (): Promise => { + const response = await fetch('/api/profile/bump', { method: 'POST' }); + const data = (await response.json().catch(() => ({}))) as { + error?: string; + remainingMs?: number; + }; + + if (!response.ok) { + throw new BumpProfileError( + data.error ?? 'Profile bump failed', + response.status, + data.remainingMs, + ); + } + + return data as BumpProfileResponse; +}; diff --git a/src/features/Profile/bumpProfile.ts b/src/features/Profile/bumpProfile.ts new file mode 100644 index 0000000..1499000 --- /dev/null +++ b/src/features/Profile/bumpProfile.ts @@ -0,0 +1,18 @@ +export const FREE_BUMP_COOLDOWN_MS = 3 * 60 * 60 * 1000; +export const PREMIUM_BUMP_COOLDOWN_MS = 90 * 60 * 1000; + +export const getBumpCooldownMs = (premium: boolean) => + premium ? PREMIUM_BUMP_COOLDOWN_MS : FREE_BUMP_COOLDOWN_MS; + +export const getBumpCooldown = ( + lastBumpedAt: Date | null | undefined, + premium: boolean, + now = new Date(), +) => { + const nextBumpAt = lastBumpedAt + ? new Date(lastBumpedAt.getTime() + getBumpCooldownMs(premium)) + : now; + const remainingMs = Math.max(0, nextBumpAt.getTime() - now.getTime()); + + return { nextBumpAt, remainingMs }; +}; diff --git a/src/lib/entitlements.ts b/src/lib/entitlements.ts new file mode 100644 index 0000000..7c0fc1f --- /dev/null +++ b/src/lib/entitlements.ts @@ -0,0 +1,7 @@ +import type { CurrentUser } from './auth'; + +export const hasPremiumEntitlement = (user: CurrentUser) => + (process.env.POLYCORD_PREMIUM_USER_IDS ?? '') + .split(',') + .map((id) => id.trim()) + .includes(user.id); From eb5886c712e33bc33498e4640508ed53374986c7 Mon Sep 17 00:00:00 2001 From: chev Date: Fri, 19 Jun 2026 23:36:56 -0500 Subject: [PATCH 02/10] feat: wire profile bump feedback --- .../Discovery/DiscoveryPage.stories.tsx | 42 +++++++- src/features/Discovery/DiscoveryPage.tsx | 102 +++++++++++++++++- src/features/Discovery/ProfileCard.tsx | 26 ++++- src/features/Discovery/discoverySort.ts | 10 +- src/locales/en.json | 17 ++- src/locales/ja.json | 17 ++- 6 files changed, 205 insertions(+), 9 deletions(-) diff --git a/src/features/Discovery/DiscoveryPage.stories.tsx b/src/features/Discovery/DiscoveryPage.stories.tsx index 08fdcff..1d93bb8 100644 --- a/src/features/Discovery/DiscoveryPage.stories.tsx +++ b/src/features/Discovery/DiscoveryPage.stories.tsx @@ -1,6 +1,14 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { expect, userEvent, waitFor, within } from '@storybook/test'; +import { + expect, + fn, + screen, + userEvent, + waitFor, + within, +} from '@storybook/test'; import { useTranslations } from 'next-intl'; +import { MOCK_USER_AVATAR_URL } from '@/constants/mock-data'; import { DiscoveryPage } from './DiscoveryPage'; import { createSampleProfiles } from './profileFixtures'; import 'src/app/globals.css'; @@ -321,3 +329,35 @@ export const FeedError: Story = { await expect(canvas.getByText(/load profiles/)).toBeInTheDocument(); }, }; + +export const BumpProfile: Story = { + render: (args) => { + const t = useTranslations('DiscoveryStories'); + + return ( + ({ + lastBumpedAt: new Date().toISOString(), + nextBumpAt: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(), + premium: false, + }))} + /> + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const avatarButton = Array.from( + canvasElement.querySelectorAll('nav button'), + ).find((button) => button.querySelector('img')); + + if (!avatarButton) throw new Error('User menu trigger not found'); + + await userEvent.click(avatarButton); + await userEvent.click(await screen.findByText('Bump profile')); + await expect(await canvas.findByText('Profile bumped')).toBeInTheDocument(); + }, +}; diff --git a/src/features/Discovery/DiscoveryPage.tsx b/src/features/Discovery/DiscoveryPage.tsx index 34556ef..93ccd6e 100644 --- a/src/features/Discovery/DiscoveryPage.tsx +++ b/src/features/Discovery/DiscoveryPage.tsx @@ -12,6 +12,11 @@ import { ONBOARDING_DRAFT_STORAGE_KEY, type OnboardingDraft, } from '@/features/Onboarding/completion'; +import { + BumpProfileError, + type BumpProfileResponse, + bumpProfileRequest, +} from './bumpProfileRequest'; import { applyDiscoveryFilters, type DiscoveryFilterValues, @@ -44,6 +49,13 @@ type DiscoveryPageProps = { savedProfileIds?: string[]; currentProfileId?: string; userAvatarUrl?: string; + onBumpProfile?: () => Promise; +}; + +type BumpNotice = { + kind: 'success' | 'error'; + title: string; + description: string; }; const onboardingFieldLabelKeys: Record = { @@ -68,6 +80,7 @@ export const DiscoveryPage = ({ savedProfileIds, currentProfileId, userAvatarUrl, + onBumpProfile = bumpProfileRequest, }: DiscoveryPageProps) => { const router = useRouter(); const pathname = usePathname(); @@ -90,6 +103,12 @@ export const DiscoveryPage = ({ const [isSearching, setIsSearching] = useState(false); const [draft, setDraft] = useState({}); const [isPromptDismissed, setIsPromptDismissed] = useState(false); + const [profileItems, setProfileItems] = useState(profiles); + const [bumpNotice, setBumpNotice] = useState(null); + + useEffect(() => { + setProfileItems(profiles); + }, [profiles]); useEffect(() => { if (!needsOnboarding) { @@ -118,7 +137,7 @@ export const DiscoveryPage = ({ const completion = useMemo(() => getOnboardingCompletion(draft), [draft]); - const tagCounts = useMemo(() => buildTagCounts(profiles), [profiles]); + const tagCounts = useMemo(() => buildTagCounts(profileItems), [profileItems]); const hasActiveFilters = useMemo( () => @@ -135,7 +154,7 @@ export const DiscoveryPage = ({ applyDiscoverySort( applyTagFilter( applyDiscoverySearch( - applyDiscoveryFilters(profiles, filterValues), + applyDiscoveryFilters(profileItems, filterValues), searchQuery, locale, ), @@ -143,7 +162,7 @@ export const DiscoveryPage = ({ ), sortValue, ), - [profiles, filterValues, searchQuery, locale, selectedTags, sortValue], + [profileItems, filterValues, searchQuery, locale, selectedTags, sortValue], ); const totalPages = Math.max(1, Math.ceil(filteredProfiles.length / PER_PAGE)); @@ -227,6 +246,66 @@ export const DiscoveryPage = ({ }); }; + const formatRemaining = (ms: number) => { + const minutes = Math.max(1, Math.ceil(ms / 60000)); + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + + if (hours && rest) { + return t('bumpCooldownHoursMinutes', { hours, minutes: rest }); + } + + return hours + ? t('bumpCooldownHours', { count: hours }) + : t('bumpCooldownMinutes', { count: minutes }); + }; + + const handleBumpProfile = async () => { + if (!currentProfileId) { + setBumpNotice({ + kind: 'error', + title: t('bumpNeedsProfileTitle'), + description: t('bumpNeedsProfileDescription'), + }); + return; + } + + try { + const result = await onBumpProfile(); + setProfileItems((previous) => + previous.map((profile) => + profile.id === currentProfileId + ? { + ...profile, + bumpedMinutesAgo: 0, + lastBumpRelative: undefined, + lastBumpedAt: result.lastBumpedAt, + } + : profile, + ), + ); + setSortValue('bumped-desc'); + setBumpNotice({ + kind: 'success', + title: t('bumpSuccessTitle'), + description: t('bumpSuccessDescription'), + }); + } catch (error) { + const cooldown = + error instanceof BumpProfileError && error.status === 429 + ? error.remainingMs + : undefined; + + setBumpNotice({ + kind: 'error', + title: cooldown ? t('bumpCooldownTitle') : t('bumpErrorTitle'), + description: cooldown + ? t('bumpCooldownDescription', { time: formatRemaining(cooldown) }) + : t('bumpErrorDescription'), + }); + } + }; + return (
router.push(`/${locale}/profile`)} + onBumpProfileClick={handleBumpProfile} onSavedClick={() => router.push(`/${locale}/saved`)} onSettingsClick={() => router.push(`/${locale}/settings`)} onLogoutClick={() => @@ -366,6 +446,22 @@ export const DiscoveryPage = ({
) : null} + + {bumpNotice ? ( + + ) : null} ); }; diff --git a/src/features/Discovery/ProfileCard.tsx b/src/features/Discovery/ProfileCard.tsx index a8c7e9b..7c81459 100644 --- a/src/features/Discovery/ProfileCard.tsx +++ b/src/features/Discovery/ProfileCard.tsx @@ -57,6 +57,7 @@ export type DiscoveryProfile = { timezone?: IANATimezone | string; allowAnonymousCopy?: boolean; lastBumpRelative?: string; + lastBumpedAt?: string; bumpedMinutesAgo?: number; premium?: boolean; cardTheme?: CardTheme; @@ -105,6 +106,23 @@ const tintedSurface = const TIME_FORMAT_STORAGE_KEY = 'polycord_timeFormat'; +const getBumpAge = (value?: string) => { + if (!value) return null; + + const minutes = Math.max( + 0, + Math.floor((Date.now() - new Date(value).getTime()) / 60000), + ); + + if (minutes < 1) return { key: 'bumpedJustNow' as const }; + if (minutes < 60) return { key: 'bumpedMinutesAgo' as const, count: minutes }; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return { key: 'bumpedHoursAgo' as const, count: hours }; + + return { key: 'bumpedDaysAgo' as const, count: Math.floor(hours / 24) }; +}; + const getTimeFormat = (): TimeFormat => { if (typeof window === 'undefined') return '24hr'; const stored = localStorage.getItem(TIME_FORMAT_STORAGE_KEY); @@ -156,6 +174,10 @@ export const ProfileCard = ({ ? { '--card-tint': theme.tint, background: tintedSurface } : {}), } as React.CSSProperties; + const bumpAge = getBumpAge(profile.lastBumpedAt); + const lastBumpRelative = + profile.lastBumpRelative ?? + (bumpAge ? t(bumpAge.key, { count: bumpAge.count ?? 0 }) : undefined); const handleCopyUsername = async () => { if (!canCopyUsername || isCopying) return; @@ -389,9 +411,9 @@ export const ProfileCard = ({ style={{ background: theme.banner }} >
- {profile.lastBumpRelative && ( + {lastBumpRelative && ( - {profile.lastBumpRelative} + {lastBumpRelative} )} {!isPreview && ( diff --git a/src/features/Discovery/discoverySort.ts b/src/features/Discovery/discoverySort.ts index eebf73e..281b953 100644 --- a/src/features/Discovery/discoverySort.ts +++ b/src/features/Discovery/discoverySort.ts @@ -12,7 +12,15 @@ export type DiscoverySortValue = (typeof SORT_OPTIONS)[number]; export const DEFAULT_SORT: DiscoverySortValue = 'bumped-desc'; const bumpRank = (profile: DiscoveryProfile): number => - profile.bumpedMinutesAgo ?? Number.POSITIVE_INFINITY; + profile.bumpedMinutesAgo ?? + (profile.lastBumpedAt + ? Math.max( + 0, + Math.floor( + (Date.now() - new Date(profile.lastBumpedAt).getTime()) / 60000, + ), + ) + : Number.POSITIVE_INFINITY); export const applyDiscoverySort = ( profiles: DiscoveryProfile[], diff --git a/src/locales/en.json b/src/locales/en.json index 4d5c77c..986b71c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -367,7 +367,22 @@ "saveLoginTitle": "Log in to save profiles", "saveLoginDescription": "Sign in with Discord to keep profiles you want to revisit.", "saveError": "Couldn't update saved profiles", - "saveErrorDescription": "Something went wrong. Please try again." + "saveErrorDescription": "Something went wrong. Please try again.", + "bumpedJustNow": "just now", + "bumpedMinutesAgo": "{count, plural, one {# minute ago} other {# minutes ago}}", + "bumpedHoursAgo": "{count, plural, one {# hour ago} other {# hours ago}}", + "bumpedDaysAgo": "{count, plural, one {# day ago} other {# days ago}}", + "bumpSuccessTitle": "Profile bumped", + "bumpSuccessDescription": "You're back near the top of discovery.", + "bumpCooldownTitle": "Bump cooling down", + "bumpCooldownDescription": "Try again in {time}.", + "bumpCooldownMinutes": "{count, plural, one {# minute} other {# minutes}}", + "bumpCooldownHours": "{count, plural, one {# hour} other {# hours}}", + "bumpCooldownHoursMinutes": "{hours, plural, one {# hour} other {# hours}} {minutes, plural, one {# minute} other {# minutes}}", + "bumpNeedsProfileTitle": "Create a public profile first", + "bumpNeedsProfileDescription": "Finish your profile and make it public before bumping.", + "bumpErrorTitle": "Couldn't bump profile", + "bumpErrorDescription": "Something went wrong. Please try again." }, "Saved": { "eyebrow": "Saved", diff --git a/src/locales/ja.json b/src/locales/ja.json index 6912bff..a821ea9 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -367,7 +367,22 @@ "saveLoginTitle": "ログインして保存", "saveLoginDescription": "Discordでログインすると、気になるプロフィールを保存できます。", "saveError": "保存を更新できませんでした", - "saveErrorDescription": "問題が発生しました。もう一度お試しください。" + "saveErrorDescription": "問題が発生しました。もう一度お試しください。", + "bumpedJustNow": "たった今", + "bumpedMinutesAgo": "{count, plural, other {#分前}}", + "bumpedHoursAgo": "{count, plural, other {#時間前}}", + "bumpedDaysAgo": "{count, plural, other {#日前}}", + "bumpSuccessTitle": "プロフィールをバンプしました", + "bumpSuccessDescription": "ディスカバリーの上位に戻りました。", + "bumpCooldownTitle": "まだバンプできません", + "bumpCooldownDescription": "あと{time}で再試行できます。", + "bumpCooldownMinutes": "{count, plural, other {#分}}", + "bumpCooldownHours": "{count, plural, other {#時間}}", + "bumpCooldownHoursMinutes": "{hours, plural, other {#時間}}{minutes, plural, other {#分}}", + "bumpNeedsProfileTitle": "先に公開プロフィールを作成してください", + "bumpNeedsProfileDescription": "バンプする前にプロフィールを完成させ、公開してください。", + "bumpErrorTitle": "プロフィールをバンプできませんでした", + "bumpErrorDescription": "問題が発生しました。もう一度お試しください。" }, "Saved": { "eyebrow": "保存済み", From c509f0625630ab54d1f8adc31275ad5cdec8006c Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 09:25:33 -0500 Subject: [PATCH 03/10] refactor: extract reusable toast stack component --- src/components/Toast/ToastStack.tsx | 46 ++++++++++++++++++++++ src/components/Toast/index.ts | 1 + src/features/Discovery/ProfileGrid.tsx | 53 ++++++++------------------ 3 files changed, 63 insertions(+), 37 deletions(-) create mode 100644 src/components/Toast/ToastStack.tsx diff --git a/src/components/Toast/ToastStack.tsx b/src/components/Toast/ToastStack.tsx new file mode 100644 index 0000000..4a86961 --- /dev/null +++ b/src/components/Toast/ToastStack.tsx @@ -0,0 +1,46 @@ +'use client'; + +import React from 'react'; +import { type ToastData, useToast } from '@/hooks/useToast'; +import { Toast, ToastProvider, ToastViewport } from './Toast'; + +const ToastItem = React.memo( + ({ + toast, + onDismiss, + }: { + toast: ToastData; + onDismiss: (id: number) => void; + }) => { + const { open, onOpenChange, timerRef } = useToast({ toast, onDismiss }); + + if (!open && !timerRef.current) return null; + + return ( + + ); + }, +); +ToastItem.displayName = 'ToastItem'; + +type ToastStackProps = { + toasts: ToastData[]; + onDismiss: (id: number) => void; +}; + +export const ToastStack = ({ toasts, onDismiss }: ToastStackProps) => ( + + {toasts.map((toast) => ( + + ))} + + +); diff --git a/src/components/Toast/index.ts b/src/components/Toast/index.ts index 7907b3f..ee0f962 100644 --- a/src/components/Toast/index.ts +++ b/src/components/Toast/index.ts @@ -1 +1,2 @@ export { Toast, ToastProvider, ToastViewport } from './Toast'; +export { ToastStack } from './ToastStack'; diff --git a/src/features/Discovery/ProfileGrid.tsx b/src/features/Discovery/ProfileGrid.tsx index 2a2ccab..dbdebe4 100644 --- a/src/features/Discovery/ProfileGrid.tsx +++ b/src/features/Discovery/ProfileGrid.tsx @@ -1,14 +1,14 @@ 'use client'; import { useTranslations } from 'next-intl'; -import React, { type ReactNode, useCallback, useMemo, useState } from 'react'; -import { Toast, ToastProvider, ToastViewport } from '@/components/Toast'; +import { type ReactNode, useCallback, useMemo, useState } from 'react'; +import { ToastStack } from '@/components/Toast'; import { calculateMatchScore, type MatchCriteria, useProfileMatching, } from '@/hooks/useProfileMatching'; -import { type ToastData, useToast, useToastStack } from '@/hooks/useToast'; +import { type ToastData, useToastStack } from '@/hooks/useToast'; import { getFreeCardTheme } from './cardTheme'; import { type DiscoveryProfile, ProfileCard } from './ProfileCard'; @@ -35,6 +35,7 @@ type ProfileGridProps = { onReport?: (profileId: string) => void; onBlock?: (profileId: string) => void; onShare?: (profileId: string) => void; + addToast?: (toast: Omit) => void; }; type ProfileGridItem = { @@ -50,33 +51,6 @@ const splitIntoColumns = (items: T[], columnCount: number) => { ).filter((column) => column.length > 0); }; -const ToastComponent = React.memo( - ({ - toast, - onDismiss, - }: { - toast: ToastData; - onDismiss: (id: number) => void; - }) => { - const { open, onOpenChange, timerRef } = useToast({ toast, onDismiss }); - - if (!open && !timerRef.current) return null; - - return ( - - ); - }, -); -ToastComponent.displayName = 'ToastComponent'; - export const ProfileGrid = ({ profiles, emptyState, @@ -95,9 +69,12 @@ export const ProfileGrid = ({ onReport, onBlock, onShare, + addToast: externalAddToast, }: ProfileGridProps) => { const t = useTranslations('Discovery'); - const { toasts, addToast, dismissToast } = useToastStack(); + const localStack = useToastStack(); + const addToast = externalAddToast ?? localStack.addToast; + const ownsToastStack = !externalAddToast; const [savedIds, setSavedIds] = useState>( () => new Set(savedProfileIds), ); @@ -232,7 +209,7 @@ export const ProfileGrid = ({ ); return ( - + <>
{hasProfiles ? ( <> @@ -255,10 +232,12 @@ export const ProfileGrid = ({ )}
- {toasts.map((toast) => ( - - ))} - -
+ {ownsToastStack ? ( + + ) : null} + ); }; From d7c80a72173ddc8c2914eb09d1a69ffc9a4d56dd Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 09:25:35 -0500 Subject: [PATCH 04/10] fix: use toast component for profile bump feedback --- src/features/Discovery/DiscoveryPage.tsx | 50 +++++++++++------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/features/Discovery/DiscoveryPage.tsx b/src/features/Discovery/DiscoveryPage.tsx index 93ccd6e..9828915 100644 --- a/src/features/Discovery/DiscoveryPage.tsx +++ b/src/features/Discovery/DiscoveryPage.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { useEffect, useMemo, useRef, useState } from 'react'; import { MdClose } from 'react-icons/md'; import { FilterBar } from '@/components/Filter'; +import { ToastStack } from '@/components/Toast'; import { Navbar } from '@/features/Navbar'; import { getMissingRequiredFields, @@ -12,6 +13,7 @@ import { ONBOARDING_DRAFT_STORAGE_KEY, type OnboardingDraft, } from '@/features/Onboarding/completion'; +import { useToastStack } from '@/hooks/useToast'; import { BumpProfileError, type BumpProfileResponse, @@ -52,11 +54,7 @@ type DiscoveryPageProps = { onBumpProfile?: () => Promise; }; -type BumpNotice = { - kind: 'success' | 'error'; - title: string; - description: string; -}; +const BUMP_TOAST_DURATION = 4000; const onboardingFieldLabelKeys: Record = { availability: 'onboardingFieldAvailability', @@ -104,7 +102,8 @@ export const DiscoveryPage = ({ const [draft, setDraft] = useState({}); const [isPromptDismissed, setIsPromptDismissed] = useState(false); const [profileItems, setProfileItems] = useState(profiles); - const [bumpNotice, setBumpNotice] = useState(null); + const [isBumping, setIsBumping] = useState(false); + const { toasts, addToast, dismissToast } = useToastStack(); useEffect(() => { setProfileItems(profiles); @@ -261,15 +260,21 @@ export const DiscoveryPage = ({ }; const handleBumpProfile = async () => { + if (isBumping) { + return; + } + if (!currentProfileId) { - setBumpNotice({ - kind: 'error', + addToast({ title: t('bumpNeedsProfileTitle'), description: t('bumpNeedsProfileDescription'), + duration: BUMP_TOAST_DURATION, }); return; } + setIsBumping(true); + try { const result = await onBumpProfile(); setProfileItems((previous) => @@ -284,11 +289,11 @@ export const DiscoveryPage = ({ : profile, ), ); - setSortValue('bumped-desc'); - setBumpNotice({ - kind: 'success', + addToast({ title: t('bumpSuccessTitle'), description: t('bumpSuccessDescription'), + iconUrl: userAvatarUrl, + duration: BUMP_TOAST_DURATION, }); } catch (error) { const cooldown = @@ -296,13 +301,15 @@ export const DiscoveryPage = ({ ? error.remainingMs : undefined; - setBumpNotice({ - kind: 'error', + addToast({ title: cooldown ? t('bumpCooldownTitle') : t('bumpErrorTitle'), description: cooldown ? t('bumpCooldownDescription', { time: formatRemaining(cooldown) }) : t('bumpErrorDescription'), + duration: BUMP_TOAST_DURATION, }); + } finally { + setIsBumping(false); } }; @@ -390,6 +397,7 @@ export const DiscoveryPage = ({ savedProfileIds={savedProfileIds} currentProfileId={currentProfileId} onSaveProfile={saveProfileRequest} + addToast={addToast} emptyState={ hasActiveFilters ? undefined : t('emptyFeedDescription') } @@ -447,21 +455,7 @@ export const DiscoveryPage = ({ ) : null} - {bumpNotice ? ( - - ) : null} +
); }; From d4e338c691a066d9b7a8802dd9dd2e7a6ce6900c Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 09:25:36 -0500 Subject: [PATCH 05/10] fix: close user menu on item selection --- src/features/Navbar/UserMenu.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/features/Navbar/UserMenu.tsx b/src/features/Navbar/UserMenu.tsx index 68c7a45..430fcda 100644 --- a/src/features/Navbar/UserMenu.tsx +++ b/src/features/Navbar/UserMenu.tsx @@ -28,14 +28,16 @@ const MenuItem = ({ onClick?: () => void; children: React.ReactNode; }) => ( - + + + ); export const UserMenu: React.FC = ({ From 473698d1e7aa33b0644bf3ec5bdcfc5493a33f93 Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 09:36:56 -0500 Subject: [PATCH 06/10] feat: compute bump cooldown readiness on server --- src/app/[lang]/DiscoveryFeed.tsx | 3 +++ src/app/[lang]/page.tsx | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/app/[lang]/DiscoveryFeed.tsx b/src/app/[lang]/DiscoveryFeed.tsx index 6ee7c4d..d298dd1 100644 --- a/src/app/[lang]/DiscoveryFeed.tsx +++ b/src/app/[lang]/DiscoveryFeed.tsx @@ -9,6 +9,7 @@ type DiscoveryFeedProps = { needsOnboarding: boolean; savedProfileIds: string[]; currentProfileId?: string; + bumpReadyAt?: string; userAvatarUrl?: string; }; @@ -19,6 +20,7 @@ export const DiscoveryFeed = async ({ needsOnboarding, savedProfileIds, currentProfileId, + bumpReadyAt, userAvatarUrl, }: DiscoveryFeedProps) => { let profiles: DiscoveryProfile[] = []; @@ -41,6 +43,7 @@ export const DiscoveryFeed = async ({ profiles={profiles} savedProfileIds={savedProfileIds} currentProfileId={currentProfileId} + bumpReadyAt={bumpReadyAt} userAvatarUrl={userAvatarUrl} /> ); diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index 9631717..c24e0c4 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -5,7 +5,9 @@ import { upsertDiscordUser, } from '@/db'; import { DiscoveryPage } from '@/features/Discovery/DiscoveryPage'; +import { getBumpCooldown } from '@/features/Profile/bumpProfile'; import { getCurrentUser } from '@/lib/auth'; +import { hasPremiumEntitlement } from '@/lib/entitlements'; import { DiscoveryFeed } from './DiscoveryFeed'; export default async function Home({ @@ -21,6 +23,7 @@ export default async function Home({ let needsOnboarding = false; let savedProfileIds: string[] = []; let currentProfileId: string | undefined; + let bumpReadyAt: string | undefined; if (user) { const persistedUser = await upsertDiscordUser(user); @@ -28,6 +31,14 @@ export default async function Home({ needsOnboarding = !profile; currentProfileId = profile?.profile.id; savedProfileIds = await listSavedProfileIds(persistedUser.id); + + if (profile?.profile.isPublic) { + const { nextBumpAt } = getBumpCooldown( + profile.profile.lastBumpedAt, + hasPremiumEntitlement(user), + ); + bumpReadyAt = nextBumpAt.toISOString(); + } } const isLoggedIn = Boolean(user); @@ -42,6 +53,7 @@ export default async function Home({ locale={lang} needsOnboarding={needsOnboarding} currentProfileId={currentProfileId} + bumpReadyAt={bumpReadyAt} userAvatarUrl={user?.avatarUrl} /> } @@ -53,6 +65,7 @@ export default async function Home({ needsOnboarding={needsOnboarding} savedProfileIds={savedProfileIds} currentProfileId={currentProfileId} + bumpReadyAt={bumpReadyAt} userAvatarUrl={user?.avatarUrl} /> From 68a542a1e68495006cd9db20fd4115372f041aed Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 09:36:58 -0500 Subject: [PATCH 07/10] feat: track bump cooldown state in discovery page --- src/features/Discovery/DiscoveryPage.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/features/Discovery/DiscoveryPage.tsx b/src/features/Discovery/DiscoveryPage.tsx index 9828915..96137ff 100644 --- a/src/features/Discovery/DiscoveryPage.tsx +++ b/src/features/Discovery/DiscoveryPage.tsx @@ -50,6 +50,7 @@ type DiscoveryPageProps = { profiles?: DiscoveryProfile[]; savedProfileIds?: string[]; currentProfileId?: string; + bumpReadyAt?: string; userAvatarUrl?: string; onBumpProfile?: () => Promise; }; @@ -77,6 +78,7 @@ export const DiscoveryPage = ({ profiles = [], savedProfileIds, currentProfileId, + bumpReadyAt: initialBumpReadyAt, userAvatarUrl, onBumpProfile = bumpProfileRequest, }: DiscoveryPageProps) => { @@ -103,12 +105,17 @@ export const DiscoveryPage = ({ const [isPromptDismissed, setIsPromptDismissed] = useState(false); const [profileItems, setProfileItems] = useState(profiles); const [isBumping, setIsBumping] = useState(false); + const [bumpReadyAt, setBumpReadyAt] = useState(initialBumpReadyAt); const { toasts, addToast, dismissToast } = useToastStack(); useEffect(() => { setProfileItems(profiles); }, [profiles]); + useEffect(() => { + setBumpReadyAt(initialBumpReadyAt); + }, [initialBumpReadyAt]); + useEffect(() => { if (!needsOnboarding) { return; @@ -289,6 +296,7 @@ export const DiscoveryPage = ({ : profile, ), ); + setBumpReadyAt(result.nextBumpAt); addToast({ title: t('bumpSuccessTitle'), description: t('bumpSuccessDescription'), @@ -301,6 +309,10 @@ export const DiscoveryPage = ({ ? error.remainingMs : undefined; + if (cooldown) { + setBumpReadyAt(new Date(Date.now() + cooldown).toISOString()); + } + addToast({ title: cooldown ? t('bumpCooldownTitle') : t('bumpErrorTitle'), description: cooldown @@ -325,6 +337,7 @@ export const DiscoveryPage = ({ } onProfileClick={() => router.push(`/${locale}/profile`)} onBumpProfileClick={handleBumpProfile} + bumpReadyAt={bumpReadyAt} onSavedClick={() => router.push(`/${locale}/saved`)} onSettingsClick={() => router.push(`/${locale}/settings`)} onLogoutClick={() => From 902de5a8849d9b9c6092d9db681c4246f691c334 Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 09:37:00 -0500 Subject: [PATCH 08/10] feat: disable bump action during cooldown --- src/features/Navbar/Navbar.tsx | 3 ++ src/features/Navbar/UserMenu.tsx | 70 ++++++++++++++++++++++++++++---- src/locales/en.json | 4 ++ src/locales/ja.json | 4 ++ 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/features/Navbar/Navbar.tsx b/src/features/Navbar/Navbar.tsx index 21394ec..02dad1d 100644 --- a/src/features/Navbar/Navbar.tsx +++ b/src/features/Navbar/Navbar.tsx @@ -16,6 +16,7 @@ type NavbarProps = { onLoginClick: () => void; onProfileClick: () => void; onBumpProfileClick?: () => void; + bumpReadyAt?: string; onSavedClick?: () => void; onSettingsClick: () => void; onLogoutClick: () => void; @@ -30,6 +31,7 @@ export const Navbar: React.FC = ({ premium = false, onProfileClick, onBumpProfileClick, + bumpReadyAt, onSavedClick, onSettingsClick, onLogoutClick, @@ -66,6 +68,7 @@ export const Navbar: React.FC = ({ iconUrl={iconUrl} onProfileClick={onProfileClick} onBumpProfileClick={onBumpProfileClick} + bumpReadyAt={bumpReadyAt} onSavedClick={onSavedClick} onSettingsClick={onSettingsClick} onLogoutClick={onLogoutClick} diff --git a/src/features/Navbar/UserMenu.tsx b/src/features/Navbar/UserMenu.tsx index 430fcda..35e3465 100644 --- a/src/features/Navbar/UserMenu.tsx +++ b/src/features/Navbar/UserMenu.tsx @@ -14,6 +14,7 @@ type UserMenuProps = { iconUrl?: string; onProfileClick: () => void; onBumpProfileClick?: () => void; + bumpReadyAt?: string; onSavedClick?: () => void; onSettingsClick: () => void; onLogoutClick: () => void; @@ -22,39 +23,86 @@ type UserMenuProps = { const MenuItem = ({ icon: Icon, onClick, + disabled = false, children, }: { icon: React.ElementType; onClick?: () => void; + disabled?: boolean; children: React.ReactNode; -}) => ( - +}) => { + const button = ( - -); + ); + + return disabled ? button : {button}; +}; + +const formatBumpCooldown = ( + ms: number, + t: ReturnType, +) => { + const minutes = Math.max(1, Math.ceil(ms / 60000)); + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + + if (hours && rest) { + return t('bumpCooldownHoursMinutes', { hours, minutes: rest }); + } + + return hours + ? t('bumpCooldownHours', { count: hours }) + : t('bumpCooldownMinutes', { count: minutes }); +}; export const UserMenu: React.FC = ({ iconUrl, onProfileClick, onBumpProfileClick, + bumpReadyAt, onSavedClick, onSettingsClick, onLogoutClick, }) => { const t = useTranslations('UserMenu'); const [isMounted, setIsMounted] = useState(false); + const [now, setNow] = useState(() => Date.now()); useEffect(() => { setIsMounted(true); }, []); + const readyTime = bumpReadyAt ? new Date(bumpReadyAt).getTime() : 0; + const bumpRemainingMs = Math.max(0, readyTime - now); + const isBumpOnCooldown = bumpRemainingMs > 0; + + useEffect(() => { + if (readyTime <= Date.now()) { + return; + } + + setNow(Date.now()); + const interval = setInterval(() => { + const current = Date.now(); + setNow(current); + if (current >= readyTime) { + clearInterval(interval); + } + }, 1000); + + return () => clearInterval(interval); + }, [readyTime]); + const triggerButton = (