diff --git a/drizzle/0005_cool_stryfe.sql b/drizzle/0005_cool_stryfe.sql new file mode 100644 index 0000000..acfe476 --- /dev/null +++ b/drizzle/0005_cool_stryfe.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/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..8163b24 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,624 @@ +{ + "id": "b389cb57-c04b-4c8e-be35-b9780eac65db", + "prevId": "7c945a3f-a20e-44ea-aba7-c831143d15fb", + "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 + }, + "display_availability": { + "name": "display_availability", + "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'" + }, + "availability_days": { + "name": "availability_days", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "availability_from": { + "name": "availability_from", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "availability_to": { + "name": "availability_to", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "availability_any_time": { + "name": "availability_any_time", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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_availability_days_check": { + "name": "profiles_availability_days_check", + "value": "\"profiles\".\"availability_days\" is null or \"profiles\".\"availability_days\" in ('any', 'weekdays', 'weekends')" + }, + "profiles_availability_from_check": { + "name": "profiles_availability_from_check", + "value": "\"profiles\".\"availability_from\" is null or \"profiles\".\"availability_from\" ~ '^[0-2][0-9]:[0-5][0-9]$'" + }, + "profiles_availability_to_check": { + "name": "profiles_availability_to_check", + "value": "\"profiles\".\"availability_to\" is null or \"profiles\".\"availability_to\" ~ '^[0-2][0-9]:[0-5][0-9]$'" + }, + "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 e54dec7..6490e30 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1781934704351, "tag": "0004_availability_overlap", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1781966823877, + "tag": "0005_cool_stryfe", + "breakpoints": true } ] } diff --git a/src/app/[lang]/DiscoveryFeed.tsx b/src/app/[lang]/DiscoveryFeed.tsx index 0ff2403..5c4e21c 100644 --- a/src/app/[lang]/DiscoveryFeed.tsx +++ b/src/app/[lang]/DiscoveryFeed.tsx @@ -10,6 +10,7 @@ type DiscoveryFeedProps = { needsOnboarding: boolean; savedProfileIds: string[]; currentProfileId?: string; + bumpReadyAt?: string; viewerTimezone?: string; viewerAvailability?: AvailabilityPattern; userAvatarUrl?: string; @@ -22,6 +23,7 @@ export const DiscoveryFeed = async ({ needsOnboarding, savedProfileIds, currentProfileId, + bumpReadyAt, viewerTimezone, viewerAvailability, userAvatarUrl, @@ -46,6 +48,7 @@ export const DiscoveryFeed = async ({ profiles={profiles} savedProfileIds={savedProfileIds} currentProfileId={currentProfileId} + bumpReadyAt={bumpReadyAt} viewerTimezone={viewerTimezone} viewerAvailability={viewerAvailability} userAvatarUrl={userAvatarUrl} diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index bc4deb2..ed6bef0 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -7,7 +7,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({ @@ -23,6 +25,7 @@ export default async function Home({ let needsOnboarding = false; let savedProfileIds: string[] = []; let currentProfileId: string | undefined; + let bumpReadyAt: string | undefined; let viewerTimezone: string | undefined; let viewerAvailability: AvailabilityPattern | undefined; @@ -37,6 +40,14 @@ export default async function Home({ const viewer = toViewerAvailabilityContext(profile.profile); viewerTimezone = viewer.timezone; viewerAvailability = viewer.availability; + + if (profile.profile.isPublic) { + const { nextBumpAt } = getBumpCooldown( + profile.profile.lastBumpedAt, + hasPremiumEntitlement(user), + ); + bumpReadyAt = nextBumpAt.toISOString(); + } } } @@ -52,6 +63,7 @@ export default async function Home({ locale={lang} needsOnboarding={needsOnboarding} currentProfileId={currentProfileId} + bumpReadyAt={bumpReadyAt} viewerTimezone={viewerTimezone} viewerAvailability={viewerAvailability} userAvatarUrl={user?.avatarUrl} @@ -65,6 +77,7 @@ export default async function Home({ needsOnboarding={needsOnboarding} savedProfileIds={savedProfileIds} currentProfileId={currentProfileId} + bumpReadyAt={bumpReadyAt} viewerTimezone={viewerTimezone} viewerAvailability={viewerAvailability} userAvatarUrl={user?.avatarUrl} 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/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/db/profiles.ts b/src/db/profiles.ts index 2c6fcd3..475d8b1 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 { type AvailabilityPattern, availabilityPatternToPreset, @@ -118,6 +118,13 @@ const toDiscoveryProfile = ({ ? toAvailabilityPattern(profile) : undefined, allowAnonymousCopy: profile.allowAnonymousCopy, + lastBumpedAt: profile.lastBumpedAt?.toISOString(), + bumpedMinutesAgo: profile.lastBumpedAt + ? Math.max( + 0, + Math.floor((Date.now() - profile.lastBumpedAt.getTime()) / 60000), + ) + : undefined, }); export type ViewerAvailabilityContext = { @@ -281,7 +288,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), @@ -396,5 +406,18 @@ 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; export const mapProfileAvailability = toAvailabilityPattern; diff --git a/src/db/schema.ts b/src/db/schema.ts index 284e7b3..311bdfd 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -68,6 +68,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(), @@ -81,6 +82,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/DiscoveryPage.stories.tsx b/src/features/Discovery/DiscoveryPage.stories.tsx index 934b865..829630e 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'; @@ -360,3 +368,64 @@ 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 imageButtons = Array.from( + canvasElement.querySelectorAll('nav button'), + ).filter((button) => button.querySelector('img')); + const avatarButton = imageButtons.at(-1); + + 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(); + }, +}; + +export const BumpProfileCooldown: Story = { + render: (args) => { + const t = useTranslations('DiscoveryStories'); + + return ( + + ); + }, + play: async ({ canvasElement }) => { + const imageButtons = Array.from( + canvasElement.querySelectorAll('nav button'), + ).filter((button) => button.querySelector('img')); + const avatarButton = imageButtons.at(-1); + + if (!avatarButton) throw new Error('User menu trigger not found'); + + await userEvent.click(avatarButton); + const cooldownItem = await screen.findByText(/Bump in/); + await expect(cooldownItem.closest('button')).toBeDisabled(); + }, +}; diff --git a/src/features/Discovery/DiscoveryPage.tsx b/src/features/Discovery/DiscoveryPage.tsx index a357c4b..c788c34 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 type { AvailabilityPattern } from '@/constants/availability'; import { Navbar } from '@/features/Navbar'; import { @@ -13,6 +14,12 @@ import { ONBOARDING_DRAFT_STORAGE_KEY, type OnboardingDraft, } from '@/features/Onboarding/completion'; +import { useToastStack } from '@/hooks/useToast'; +import { + BumpProfileError, + type BumpProfileResponse, + bumpProfileRequest, +} from './bumpProfileRequest'; import { applyDiscoveryFilters, type DiscoveryFilterValues, @@ -48,11 +55,15 @@ type DiscoveryPageProps = { profiles?: DiscoveryProfile[]; savedProfileIds?: string[]; currentProfileId?: string; + bumpReadyAt?: string; viewerTimezone?: string; viewerAvailability?: AvailabilityPattern | null; userAvatarUrl?: string; + onBumpProfile?: () => Promise; }; +const BUMP_TOAST_DURATION = 4000; + const onboardingFieldLabelKeys: Record = { availability: 'onboardingFieldAvailability', bio: 'onboardingFieldBio', @@ -74,9 +85,11 @@ export const DiscoveryPage = ({ profiles = [], savedProfileIds, currentProfileId, + bumpReadyAt: initialBumpReadyAt, viewerTimezone, viewerAvailability, userAvatarUrl, + onBumpProfile = bumpProfileRequest, }: DiscoveryPageProps) => { const router = useRouter(); const pathname = usePathname(); @@ -114,6 +127,18 @@ export const DiscoveryPage = ({ const [isSearching, setIsSearching] = useState(false); const [draft, setDraft] = useState({}); 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) { @@ -142,7 +167,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( () => @@ -159,7 +184,7 @@ export const DiscoveryPage = ({ applyDiscoverySort( applyTagFilter( applyDiscoverySearch( - applyDiscoveryFilters(profiles, filterValues, viewerContext), + applyDiscoveryFilters(profileItems, filterValues, viewerContext), searchQuery, locale, ), @@ -169,7 +194,7 @@ export const DiscoveryPage = ({ viewerContext, ), [ - profiles, + profileItems, filterValues, searchQuery, locale, @@ -260,6 +285,79 @@ 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 (isBumping) { + return; + } + + if (!currentProfileId) { + addToast({ + title: t('bumpNeedsProfileTitle'), + description: t('bumpNeedsProfileDescription'), + duration: BUMP_TOAST_DURATION, + }); + return; + } + + setIsBumping(true); + + try { + const result = await onBumpProfile(); + setProfileItems((previous) => + previous.map((profile) => + profile.id === currentProfileId + ? { + ...profile, + bumpedMinutesAgo: 0, + lastBumpRelative: undefined, + lastBumpedAt: result.lastBumpedAt, + } + : profile, + ), + ); + setBumpReadyAt(result.nextBumpAt); + addToast({ + title: t('bumpSuccessTitle'), + description: t('bumpSuccessDescription'), + iconUrl: userAvatarUrl, + duration: BUMP_TOAST_DURATION, + }); + } catch (error) { + const cooldown = + error instanceof BumpProfileError && error.status === 429 + ? error.remainingMs + : undefined; + + if (cooldown) { + setBumpReadyAt(new Date(Date.now() + cooldown).toISOString()); + } + + addToast({ + title: cooldown ? t('bumpCooldownTitle') : t('bumpErrorTitle'), + description: cooldown + ? t('bumpCooldownDescription', { time: formatRemaining(cooldown) }) + : t('bumpErrorDescription'), + duration: BUMP_TOAST_DURATION, + }); + } finally { + setIsBumping(false); + } + }; + return (
router.push(`/${locale}/profile`)} + onBumpProfileClick={handleBumpProfile} + bumpReadyAt={bumpReadyAt} onSavedClick={() => router.push(`/${locale}/saved`)} onSettingsClick={() => router.push(`/${locale}/settings`)} onLogoutClick={() => @@ -348,6 +448,7 @@ export const DiscoveryPage = ({ currentProfileId={currentProfileId} viewerTimezone={viewerTimezone} onSaveProfile={saveProfileRequest} + addToast={addToast} emptyState={ hasActiveFilters ? undefined : t('emptyFeedDescription') } @@ -404,6 +505,8 @@ export const DiscoveryPage = ({
) : 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/ProfileGrid.tsx b/src/features/Discovery/ProfileGrid.tsx index 8c13094..026aa42 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'; @@ -36,6 +36,7 @@ type ProfileGridProps = { onReport?: (profileId: string) => void; onBlock?: (profileId: string) => void; onShare?: (profileId: string) => void; + addToast?: (toast: Omit) => void; }; type ProfileGridItem = { @@ -51,33 +52,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, @@ -97,9 +71,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), ); @@ -235,7 +212,7 @@ export const ProfileGrid = ({ ); return ( - + <>
{hasProfiles ? ( <> @@ -258,10 +235,12 @@ export const ProfileGrid = ({ )}
- {toasts.map((toast) => ( - - ))} - -
+ {ownsToastStack ? ( + + ) : null} + ); }; 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/Discovery/discoverySort.ts b/src/features/Discovery/discoverySort.ts index c51b53a..78ee704 100644 --- a/src/features/Discovery/discoverySort.ts +++ b/src/features/Discovery/discoverySort.ts @@ -17,7 +17,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/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 68c7a45..a4d763c 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,37 +23,79 @@ 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) => { + const totalSeconds = Math.ceil(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const pad = (value: number) => String(value).padStart(2, '0'); + + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; +}; 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 = (