From 86d2d1ae2c1a7b3992b81eec090fd69e912479c1 Mon Sep 17 00:00:00 2001 From: Pushp Date: Wed, 4 Nov 2026 19:46:41 +0530 Subject: [PATCH] feat: show gated features locked instead of hidden - Discovery categories: every member sees all four; ones outside their tier carry a lock and open an upgrade prompt (See plans -> /pricing) - Match settings: 'Tell OneHook who you're looking for' shown locked for Must - Mr. OneHook chat: 'Saved chats' shown locked for Must - Telemetry catalog 1.13.0 --- src/components/common/LockedFeature.tsx | 88 ++++++++++++++++++ src/features/chat/ChatView.tsx | 28 ++++++ src/features/discovery/DiscoveryView.tsx | 93 +++++++++++++------ .../profile/CurrentSuggestionCard.tsx | 33 ++++++- .../analytics/generated/telemetryCatalog.ts | 14 +-- src/tests/locked-features.test.tsx | 41 ++++++++ 6 files changed, 260 insertions(+), 37 deletions(-) create mode 100644 src/components/common/LockedFeature.tsx create mode 100644 src/tests/locked-features.test.tsx diff --git a/src/components/common/LockedFeature.tsx b/src/components/common/LockedFeature.tsx new file mode 100644 index 0000000..206104e --- /dev/null +++ b/src/components/common/LockedFeature.tsx @@ -0,0 +1,88 @@ +import { useEffect } from 'react'; +import { Lock } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { MOST_POPULAR_TIER } from '../../config/membership.config'; +import { trackProps, type UiElementId } from '../../lib/analytics/analytics'; + +/** Name of the lowest tier that unlocks gated features (Super). */ +export const UNLOCK_TIER_NAME = MOST_POPULAR_TIER.displayName; + +/** Small lock shown next to a feature the member's tier does not include. */ +export function LockIcon({ className = 'w-3 h-3' }: { className?: string }) { + return ; +} + +/** + * Explains a locked feature and offers the plans. The feature stays visible everywhere so members can + * see what a higher tier adds; tapping it opens this instead of doing nothing or hiding it. + */ +export function UpgradeDialog({ + feature, + detail, + element, + onClose, +}: { + /** e.g. "Mr. OneHook picks". */ + feature: string; + detail?: string; + /** Telemetry element for the "See plans" / "Not now" buttons. */ + element: UiElementId; + onClose: () => void; +}) { + const navigate = useNavigate(); + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + data-track-exempt="backdrop: only stops clicks inside the dialog from closing it" + data-testid="upgrade-dialog" + > +
+ +

+ {feature} is a {UNLOCK_TIER_NAME} feature +

+
+

+ {detail ?? `Upgrade to ${UNLOCK_TIER_NAME} or above to unlock it.`} +

+
+ + +
+
+
+ ); +} diff --git a/src/features/chat/ChatView.tsx b/src/features/chat/ChatView.tsx index cb94469..9dade52 100644 --- a/src/features/chat/ChatView.tsx +++ b/src/features/chat/ChatView.tsx @@ -46,6 +46,7 @@ import { HistoryLockedBanner, LockedMessageNotice, } from '../../components/chat/LockedMessage'; +import { LockIcon, UpgradeDialog } from '../../components/common/LockedFeature'; import { useChatWallpaper, WALLPAPER_SCRIM_CLASS, @@ -1659,6 +1660,7 @@ function MrOneHookChatBody({ return ( <> + {!canSaveChats && } {canSaveChats && ( + + {open && ( + setOpen(false)} + /> + )} + + ); +} + function SavedChatsBar({ chat, scopeType, diff --git a/src/features/discovery/DiscoveryView.tsx b/src/features/discovery/DiscoveryView.tsx index 66ae4c4..8291a5a 100644 --- a/src/features/discovery/DiscoveryView.tsx +++ b/src/features/discovery/DiscoveryView.tsx @@ -5,6 +5,7 @@ import type { ApiError } from '../../lib/api-client'; import type { LikeTargetType } from '../../api/rest'; import { MatchingApi, type DiscoveryCategory } from '../../api/matching'; import { LoadingSpinner } from '../../components/common/LoadingSpinner'; +import { LockIcon, UpgradeDialog } from '../../components/common/LockedFeature'; import { DiscoveryCard } from './DiscoveryCard'; import { CommentComposer } from './CommentComposer'; import type { LikeTarget } from './like-target'; @@ -136,11 +137,11 @@ export function DiscoveryView({ advance(candidateId); }; - // Super+ members get a category selector; free members (availableCategories = ['ALL']) see none. - const selector = - onCategoryChange && availableCategories.length > 1 ? ( - - ) : null; + // Every member sees every category. The ones their tier does not include carry a lock and open the + // upgrade prompt; the backend's availableCategories decides which are open. + const selector = onCategoryChange ? ( + + ) : null; if (loading) { return ( @@ -226,35 +227,71 @@ const CATEGORY_LABELS: Record = { SIMILAR: 'Similar to your likes', }; -/** Discovery category chips (Super+). Options come from the backend's availableCategories. */ -function CategorySelector({ +/** Every discovery category, in display order. */ +export const ALL_CATEGORIES: DiscoveryCategory[] = ['ALL', 'PROFILE_MATCH', 'ONEHOOK_SUGGESTED', 'SIMILAR']; + +/** + * Discovery category chips. Categories outside `available` (from the backend, by tier) stay visible + * with a lock; tapping one explains the upgrade instead of switching. + */ +export function CategorySelector({ value, - options, + available, onChange, }: { value: DiscoveryCategory; - options: DiscoveryCategory[]; + available: DiscoveryCategory[]; onChange: (c: DiscoveryCategory) => void; }) { + const [lockedPick, setLockedPick] = useState(null); + const open = new Set(available.length ? available : ['ALL']); + const options = [...ALL_CATEGORIES, ...available.filter((c) => !ALL_CATEGORIES.includes(c))]; return ( -
- {options.map((option) => ( - - ))} -
+ <> +
+ {options.map((option) => + open.has(option) ? ( + + ) : ( + + ) + )} +
+ {lockedPick && ( + setLockedPick(null)} + /> + )} + ); } diff --git a/src/features/profile/CurrentSuggestionCard.tsx b/src/features/profile/CurrentSuggestionCard.tsx index 6a96c9e..2d457b6 100644 --- a/src/features/profile/CurrentSuggestionCard.tsx +++ b/src/features/profile/CurrentSuggestionCard.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Sparkles, UserRound } from 'lucide-react'; import { SuggestionApi, type MemberSuggestions, type SuggestionView } from '../../api/rest'; import { trackProps, UiElement, type UiElementId } from '../../lib/analytics/analytics'; +import { LockIcon, UpgradeDialog } from '../../components/common/LockedFeature'; export type CurrentSuggestion = SuggestionView; @@ -148,7 +149,7 @@ export function OwnSuggestionForm({ onSaved, replacing }: { onSaved: () => void; /** * The member's suggestions in match settings: their own (with the form to add or replace it) and - * Mr. OneHook's. Super and above; Must sees nothing here. + * Mr. OneHook's. Super and above; Must sees it locked, with the upgrade prompt. */ export function SuggestionsSection({ paid }: { paid: boolean }) { const [data, setData] = useState({}); @@ -162,7 +163,35 @@ export function SuggestionsSection({ paid }: { paid: boolean }) { useEffect(() => { if (paid) void load(); }, [paid, load]); - if (!paid) return null; + const [upgradeOpen, setUpgradeOpen] = useState(false); + if (!paid) { + return ( +
+ + {upgradeOpen && ( + setUpgradeOpen(false)} + /> + )} +
+ ); + } return (
diff --git a/src/lib/analytics/generated/telemetryCatalog.ts b/src/lib/analytics/generated/telemetryCatalog.ts index a90811d..b5064a7 100644 --- a/src/lib/analytics/generated/telemetryCatalog.ts +++ b/src/lib/analytics/generated/telemetryCatalog.ts @@ -1,15 +1,15 @@ // GENERATED FILE - DO NOT EDIT. -// telemetry-integrity: sha256 0de3f1cb1aae5814dd53bc7f5bebcbe1efe5db8df08450ca83b3d11d422ab31c +// telemetry-integrity: sha256 892250b64cde2852dba08e06d6e5df25d28155d202de62331ee0ce12d53e6d88 // Source of truth: OneHookBackend/packages/telemetry/src/catalog.ts // Regenerate: (OneHookBackend) npm run telemetry:generate && npm run telemetry:sync -// Catalog version 1.12.0, hash 04677b92bd555132. +// Catalog version 1.13.0, hash 67439546f8614319. // // HARD RULE: every user interaction MUST emit ui_interaction with an id from THIS file, and // ids are controlled by the backend. Read TELEMETRY_CONTRACT.txt (printed by every build). /* eslint-disable */ -export const TELEMETRY_CATALOG_VERSION = "1.12.0"; -export const TELEMETRY_CATALOG_HASH = "04677b92bd555132"; +export const TELEMETRY_CATALOG_VERSION = "1.13.0"; +export const TELEMETRY_CATALOG_HASH = "67439546f8614319"; /** Every signal type the ingest accepts. */ export const SignalType = { @@ -605,7 +605,7 @@ export const UI_ELEMENTS: { readonly [K in UiElementId]: UiElementSpec } = { "discover.category_select": { screen: "DISCOVER", kind: "button", actions: ["tap"], values: ["all", "profile_match", "onehook_suggested", "similar"], platforms: ["web", "ios", "android"] }, "discover.highlight_swipe": { screen: "DISCOVER", kind: "gesture", actions: ["tap"], values: ["onehook_intelligence", "mr_onehook", "kundali", "western", "bazi", "zi_wei_dou_shu", "tzolkin", "tibetan", "hebrew", "arabic_parts", "buddhist", "celtic", "egyptian", "hellenistic", "babylonian"], platforms: ["web", "ios", "android"] }, "discover.add_hook_submit": { screen: "DISCOVER", kind: "button", actions: ["submit"], values: null, platforms: ["web", "ios"] }, - "discover.upgrade_open": { screen: "DISCOVER", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["ios"] }, + "discover.upgrade_open": { screen: "DISCOVER", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["web", "ios", "android"] }, "like_composer.send": { screen: "LIKE_COMPOSER", kind: "button", actions: ["submit"], values: null, platforms: ["web", "ios", "android"] }, "like_composer.send_without_comment": { screen: "LIKE_COMPOSER", kind: "button", actions: ["submit"], values: null, platforms: ["ios"] }, "like_composer.cancel": { screen: "LIKE_COMPOSER", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["web", "ios", "android"] }, @@ -723,8 +723,8 @@ export const UI_ELEMENTS: { readonly [K in UiElementId]: UiElementSpec } = { "profile_edit.voice_remove": { screen: "PROFILE_EDIT", kind: "button", actions: ["tap"], values: null, platforms: ["web", "ios"] }, "profile_edit.choice_select": { screen: "PROFILE_EDIT", kind: "segmented", actions: ["select", "tap"], values: null, platforms: ["web", "ios", "android"] }, "preferences.save": { screen: "PREFERENCES", kind: "button", actions: ["submit"], values: null, platforms: ["ios", "android"] }, - "preferences.upgrade_open": { screen: "PREFERENCES", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["ios"] }, - "preferences.locked_filter_tap": { screen: "PREFERENCES", kind: "button", actions: ["tap"], values: null, platforms: ["ios"] }, + "preferences.upgrade_open": { screen: "PREFERENCES", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["web", "ios", "android"] }, + "preferences.locked_filter_tap": { screen: "PREFERENCES", kind: "button", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] }, "preferences.suggestion_remove": { screen: "PREFERENCES", kind: "button", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] }, "preferences.own_suggestion_save": { screen: "PREFERENCES", kind: "button", actions: ["submit"], values: null, platforms: ["web", "ios", "android"] }, "preferences.tradition_toggle": { screen: "PREFERENCES", kind: "toggle", actions: ["toggle"], values: ["kundali", "western", "bazi", "zi_wei_dou_shu", "tzolkin", "tibetan", "hebrew", "arabic_parts", "buddhist", "celtic", "egyptian", "hellenistic", "babylonian"], platforms: ["web", "ios", "android"] }, diff --git a/src/tests/locked-features.test.tsx b/src/tests/locked-features.test.tsx new file mode 100644 index 0000000..b88e9a6 --- /dev/null +++ b/src/tests/locked-features.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom'; +import { CategorySelector } from '../features/discovery/DiscoveryView'; +import { SuggestionsSection } from '../features/profile/CurrentSuggestionCard'; + +const render = (el: import('react').ReactElement) => + renderToStaticMarkup({el}); + +describe('gated features stay visible, locked', () => { + it('a free member sees every category; the Super ones carry a lock', () => { + const html = render( {}} />); + for (const label of [ + 'For you', + 'Matches your preferences', + 'Mr. OneHook picks', + 'Similar to your likes', + ]) { + expect(html).toContain(label); + } + expect((html.match(/data-locked="true"/g) || []).length).toBe(3); + expect((html.match(/data-testid="lock-icon"/g) || []).length).toBe(3); + }); + + it('a Super member sees no locks', () => { + const html = render( + {}} + /> + ); + expect(html).not.toContain('data-locked'); + }); + + it('suggestions show locked for a free member instead of disappearing', () => { + const html = render(); + expect(html).toContain('suggestions-locked'); + expect(html).toContain('Tell OneHook who you're looking for'); + }); +});