- {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 = (