From a3f0f2cdb243209d123a51bceb9bf7d63d7b70df Mon Sep 17 00:00:00 2001 From: Pushp Date: Wed, 28 Oct 2026 09:32:21 +0530 Subject: [PATCH] feat(mr-onehook): free-tier daily limit and remaining count; current suggestion card - onehook-api-client 1.0.33. - Mr OneHook is for every member: 402 MESSAGE_LIMIT_REACHED becomes LIMIT_REACHED ('You've used today's 10 free messages... upgrade to Super or come back tomorrow'); the 'rolling out gradually' not-entitled path is gone. - Composer footer shows 'N free messages left today' (remainingMessages). - Match settings show the member's current Mr. OneHook request with Remove (DELETE /matching/suggestions, preferences.suggestion_remove). --- package-lock.json | 8 +-- package.json | 2 +- src/api/rest.ts | 11 +++ src/features/chat/ChatView.tsx | 10 ++- .../profile/CurrentSuggestionCard.tsx | 67 +++++++++++++++++++ src/features/profile/ProfileView.tsx | 8 ++- .../analytics/generated/telemetryCatalog.ts | 10 +-- src/lib/mr-onehook-contract.ts | 23 ++++--- src/lib/mr-onehook.ts | 13 ++-- src/tests/mr-onehook-contract.test.ts | 17 +++-- 10 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 src/features/profile/CurrentSuggestionCard.tsx diff --git a/package-lock.json b/package-lock.json index 3b93437..9d90b68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "jsqr": "1.4.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", - "onehook-api-client": "^1.0.32", + "onehook-api-client": "^1.0.33", "qrcode.react": "^4.2.0", "react": "^19.0.1", "react-dom": "^19.0.1", @@ -5704,9 +5704,9 @@ } }, "node_modules/onehook-api-client": { - "version": "1.0.32", - "resolved": "https://registry.npmjs.org/onehook-api-client/-/onehook-api-client-1.0.32.tgz", - "integrity": "sha512-7hNQH0WATbaDbC2zjhqin9Phw4t0R9GrTtqND53KBW/kGB6YjUUMKUE42nannxl4jDZSVtW24Wcg1+/T3mQlXA==", + "version": "1.0.33", + "resolved": "https://registry.npmjs.org/onehook-api-client/-/onehook-api-client-1.0.33.tgz", + "integrity": "sha512-mtGm/lOW5GpDHkArNfdKGckv/CR1azKQm8ImIX63hHI3yv088rB3vXRmCObqdOKSvp2j+QdkbWmaYBbnl8MLPQ==", "license": "UNLICENSED", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", diff --git a/package.json b/package.json index 9b3b9af..3236c7a 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "jsqr": "1.4.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", - "onehook-api-client": "^1.0.32", + "onehook-api-client": "^1.0.33", "qrcode.react": "^4.2.0", "react": "^19.0.1", "react-dom": "^19.0.1", diff --git a/src/api/rest.ts b/src/api/rest.ts index 1d952c2..49c8e37 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -165,6 +165,17 @@ export const BlocksApi = { request('DELETE', `/matching/blocks/${encodeURIComponent(blockedUserId)}`), }; +// ── Mr. OneHook suggestion ──────────────────────────────────────────────────── + +/** + * The member's own OneHook suggestion (what they last asked Mr. OneHook for). One at a time: a new request + * replaces it, and only the member removes it. The current one comes with the preferences + * (`currentSuggestion`). + */ +export const SuggestionApi = { + remove: (): Promise => request('DELETE', '/matching/suggestions'), +}; + // ── Chat settings ───────────────────────────────────────────────────────────── export type LastSeenVisibility = 'EVERYONE' | 'MATCHES' | 'NOBODY'; diff --git a/src/features/chat/ChatView.tsx b/src/features/chat/ChatView.tsx index ab03693..4e943d3 100644 --- a/src/features/chat/ChatView.tsx +++ b/src/features/chat/ChatView.tsx @@ -1717,8 +1717,8 @@ function MrOneHookChatBody({ disabled={loading || pendingReview !== null || unavailable} placeholder={ unavailable - ? availability === 'NOT_ENTITLED' - ? 'Mr.OneHook isn’t available on your account yet.' + ? availability === 'LIMIT_REACHED' + ? 'Today’s free messages are used up. Upgrade to Super or come back tomorrow.' : availability === 'NOT_AUTHENTICATED' ? 'Sign in again to chat with Mr.OneHook.' : 'Mr.OneHook is temporarily unavailable.' @@ -1726,7 +1726,11 @@ function MrOneHookChatBody({ ? 'Review the excerpts above first…' : 'Say something to Mr.OneHook…' } - footerRight="OneHook AI · Always Here" + footerRight={ + typeof chat.remainingMessages === 'number' + ? `${chat.remainingMessages} free message${chat.remainingMessages === 1 ? '' : 's'} left today` + : 'OneHook AI · Always Here' + } /> ); diff --git a/src/features/profile/CurrentSuggestionCard.tsx b/src/features/profile/CurrentSuggestionCard.tsx new file mode 100644 index 0000000..dbda403 --- /dev/null +++ b/src/features/profile/CurrentSuggestionCard.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { Sparkles } from 'lucide-react'; +import { SuggestionApi } from '../../api/rest'; +import { trackProps, UiElement } from '../../lib/analytics/analytics'; + +export interface CurrentSuggestion { + suggestionId: string; + reasoning: string; + createdAt: string; +} + +/** + * The member's current Mr. OneHook request, shown in their match settings. One at a time: asking Mr. OneHook + * again replaces it; Remove is the only other way it goes away. Nothing renders when there is none. + */ +export function CurrentSuggestionCard({ + suggestion, + onRemoved, +}: { + suggestion?: CurrentSuggestion | null; + onRemoved: () => void; +}) { + const [removing, setRemoving] = useState(false); + const [error, setError] = useState(null); + if (!suggestion) return null; + + const since = Number.isFinite(Date.parse(suggestion.createdAt)) + ? new Date(suggestion.createdAt).toLocaleDateString() + : null; + + const remove = async () => { + setRemoving(true); + setError(null); + try { + await SuggestionApi.remove(); + onRemoved(); + } catch { + setError("Couldn't remove it right now. Please try again."); + } finally { + setRemoving(false); + } + }; + + return ( +
+
+ Your Mr. OneHook request +
+

{suggestion.reasoning}

+
+ + {since ? `Since ${since}. ` : ''}Asking Mr. OneHook again replaces it. + + +
+ {error &&

{error}

} +
+ ); +} diff --git a/src/features/profile/ProfileView.tsx b/src/features/profile/ProfileView.tsx index 20ba9ce..eb9c437 100644 --- a/src/features/profile/ProfileView.tsx +++ b/src/features/profile/ProfileView.tsx @@ -20,6 +20,7 @@ import { } from 'lucide-react'; import type { UserProfile } from '../../types'; import { ProfileApi } from '../../api/profile'; +import { CurrentSuggestionCard } from './CurrentSuggestionCard'; import { usePreferences } from '../../hooks/use-api'; import { useAppStore } from '../../store/app-store'; import { useToast } from '../../components/common/Toast'; @@ -174,7 +175,7 @@ export function ProfileView({ ? subscriptionTierDisplayName(userState.subscriptionTier) : '—'; const { showToast } = useToast(); - const { prefs, save: savePreferences, saving } = usePreferences(user.id); + const { prefs, save: savePreferences, saving, refetch: refetchPreferences } = usePreferences(user.id); const fieldStatus = user.fieldModerationStatus || {}; // Owner-only photo stats (identity from the JWT server-side) + full-screen viewer for own photos. @@ -1381,6 +1382,11 @@ export function ProfileView({ + void refetchPreferences()} + /> +