Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/components/common/LockedFeature.tsx
Original file line number Diff line number Diff line change
@@ -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 <Lock className={className} aria-hidden data-testid="lock-icon" />;
}

/**
* 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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="presentation"
data-track-exempt="backdrop: closing is tracked by the Not now button"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="upgrade-dialog-title"
className="w-full max-w-sm rounded-2xl bg-surface border border-border p-5 space-y-3 text-text"
onClick={(e) => e.stopPropagation()}
data-track-exempt="backdrop: only stops clicks inside the dialog from closing it"
data-testid="upgrade-dialog"
>
<div className="flex items-center gap-2">
<LockIcon className="w-4 h-4" />
<h2 id="upgrade-dialog-title" className="text-sm font-black">
{feature} is a {UNLOCK_TIER_NAME} feature
</h2>
</div>
<p className="text-xs text-text-secondary">
{detail ?? `Upgrade to ${UNLOCK_TIER_NAME} or above to unlock it.`}
</p>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
{...trackProps(element, { action: 'dismiss' })}
className="px-3 py-1.5 rounded-lg border border-border text-xs font-semibold cursor-pointer"
>
Not now
</button>
<button
type="button"
onClick={() => {
onClose();
navigate('/pricing');
}}
{...trackProps(element)}
className="px-3 py-1.5 rounded-lg bg-ig-blue text-white text-xs font-semibold cursor-pointer"
>
See plans
</button>
</div>
</div>
</div>
);
}
28 changes: 28 additions & 0 deletions src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
HistoryLockedBanner,
LockedMessageNotice,
} from '../../components/chat/LockedMessage';
import { LockIcon, UpgradeDialog } from '../../components/common/LockedFeature';
import {
useChatWallpaper,
WALLPAPER_SCRIM_CLASS,
Expand Down Expand Up @@ -1659,6 +1660,7 @@ function MrOneHookChatBody({

return (
<>
{!canSaveChats && <LockedSavedChatsBar />}
{canSaveChats && (
<SavedChatsBar
chat={chat}
Expand Down Expand Up @@ -1789,6 +1791,32 @@ function MrOneHookChatBody({
* Super and above: the member's saved Mr. OneHook chats (at most 3 in total, across product and match
* chats). Resume a product chat here, delete any, or start a new one.
*/
/** Saved chats for a Must member: visible with a lock; a tap explains the upgrade. */
function LockedSavedChatsBar() {
const [open, setOpen] = useState(false);
return (
<div className="shrink-0 px-4 md:px-6 py-2 border-b border-border/60 bg-surface-card/40" data-testid="saved-chats-locked">
<button
type="button"
onClick={() => setOpen(true)}
{...trackProps(UiElement.CHAT_MR_ONEHOOK_SESSIONS_OPEN)}
aria-label="Saved chats, locked"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full border border-dashed border-border text-xs font-semibold text-text-muted cursor-pointer"
>
<LockIcon /> Saved chats
</button>
{open && (
<UpgradeDialog
feature="Saved chats"
detail="Keep your Mr. OneHook conversations and pick them up again later, on any device."
element={UiElement.CHAT_MR_ONEHOOK_SESSIONS_OPEN}
onClose={() => setOpen(false)}
/>
)}
</div>
);
}

function SavedChatsBar({
chat,
scopeType,
Expand Down
93 changes: 65 additions & 28 deletions src/features/discovery/DiscoveryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ? (
<CategorySelector value={category} options={availableCategories} onChange={onCategoryChange} />
) : 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 ? (
<CategorySelector value={category} available={availableCategories} onChange={onCategoryChange} />
) : null;

if (loading) {
return (
Expand Down Expand Up @@ -226,35 +227,71 @@ const CATEGORY_LABELS: Record<DiscoveryCategory, string> = {
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<DiscoveryCategory | null>(null);
const open = new Set<DiscoveryCategory>(available.length ? available : ['ALL']);
const options = [...ALL_CATEGORIES, ...available.filter((c) => !ALL_CATEGORIES.includes(c))];
return (
<div role="tablist" aria-label="Discovery category" className="flex gap-2 overflow-x-auto no-scrollbar">
{options.map((option) => (
<button
key={option}
type="button"
role="tab"
aria-selected={option === value}
onClick={() => option !== value && onChange(option)}
{...trackProps(UiElement.DISCOVER_CATEGORY_SELECT, { value: option.toLowerCase() })}
className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors cursor-pointer ${
option === value
? 'bg-ig-blue text-white border-ig-blue'
: 'bg-surface border-border text-text-secondary hover:text-text'
}`}
>
{CATEGORY_LABELS[option] ?? option}
</button>
))}
</div>
<>
<div role="tablist" aria-label="Discovery category" className="flex gap-2 overflow-x-auto no-scrollbar">
{options.map((option) =>
open.has(option) ? (
<button
key={option}
type="button"
role="tab"
aria-selected={option === value}
onClick={() => option !== value && onChange(option)}
{...trackProps(UiElement.DISCOVER_CATEGORY_SELECT, { value: option.toLowerCase() })}
className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors cursor-pointer ${
option === value
? 'bg-ig-blue text-white border-ig-blue'
: 'bg-surface border-border text-text-secondary hover:text-text'
}`}
>
{CATEGORY_LABELS[option] ?? option}
</button>
) : (
<button
key={option}
type="button"
role="tab"
aria-selected={false}
aria-label={`${CATEGORY_LABELS[option] ?? option}, locked`}
data-locked="true"
onClick={() => setLockedPick(option)}
{...trackProps(UiElement.DISCOVER_UPGRADE_OPEN)}
className="shrink-0 inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-semibold border border-dashed border-border text-text-muted bg-surface cursor-pointer"
>
<LockIcon />
{CATEGORY_LABELS[option] ?? option}
</button>
)
)}
</div>
{lockedPick && (
<UpgradeDialog
feature={CATEGORY_LABELS[lockedPick] ?? lockedPick}
detail="Narrow your deck to one kind of match: people who fit your preferences, Mr. OneHook's picks, or people like the ones you've liked."
element={UiElement.DISCOVER_UPGRADE_OPEN}
onClose={() => setLockedPick(null)}
/>
)}
</>
);
}
33 changes: 31 additions & 2 deletions src/features/profile/CurrentSuggestionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<MemberSuggestions>({});
Expand All @@ -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 (
<div className="space-y-2" data-testid="suggestions-locked">
<button
type="button"
onClick={() => setUpgradeOpen(true)}
{...trackProps(UiElement.PREFERENCES_LOCKED_FILTER_TAP)}
className="w-full text-left border border-dashed border-border p-3 space-y-1 cursor-pointer opacity-80"
aria-label="Tell OneHook who you're looking for, locked"
>
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-[0.3em] font-black opacity-60">
<LockIcon /> Tell OneHook who you're looking for
</span>
<span className="block text-xs text-text-secondary">
Describe your kind of person and OneHook adds people like that to Discover. Mr. OneHook can save one for you too.
</span>
</button>
{upgradeOpen && (
<UpgradeDialog
feature="Your own suggestion"
detail="Tell OneHook who you're looking for, and keep the request Mr. OneHook saves for you."
element={UiElement.PREFERENCES_UPGRADE_OPEN}
onClose={() => setUpgradeOpen(false)}
/>
)}
</div>
);
}

return (
<div className="space-y-3" data-testid="suggestions-section">
Expand Down
14 changes: 7 additions & 7 deletions src/lib/analytics/generated/telemetryCatalog.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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"] },
Expand Down Expand Up @@ -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"] },
Expand Down
41 changes: 41 additions & 0 deletions src/tests/locked-features.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MemoryRouter>{el}</MemoryRouter>);

describe('gated features stay visible, locked', () => {
it('a free member sees every category; the Super ones carry a lock', () => {
const html = render(<CategorySelector value="ALL" available={['ALL']} onChange={() => {}} />);
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(
<CategorySelector
value="ALL"
available={['ALL', 'PROFILE_MATCH', 'ONEHOOK_SUGGESTED', 'SIMILAR']}
onChange={() => {}}
/>
);
expect(html).not.toContain('data-locked');
});

it('suggestions show locked for a free member instead of disappearing', () => {
const html = render(<SuggestionsSection paid={false} />);
expect(html).toContain('suggestions-locked');
expect(html).toContain('Tell OneHook who you&#x27;re looking for');
});
});
Loading