From accfc8be757f620ef53407c7d20f2426557f5c35 Mon Sep 17 00:00:00 2001 From: Pushp Date: Wed, 4 Nov 2026 17:39:34 +0530 Subject: [PATCH] feat(discover): swipeable 'why we matched you' cards; per-tradition opt-out in profile - One card per match highlight (OneHook Intelligence, Mr. OneHook, Kundali, Bazi, ...), swipe or arrows, dots; nothing when the list is empty - Profile: Traditional matching section to switch traditions off (GET/PUT /matching/traditions) - onehook-api-client 1.0.36, telemetry catalog 1.12.0 --- package-lock.json | 8 +- package.json | 2 +- src/api/rest.ts | 18 +++ src/features/discovery/DiscoveryCard.tsx | 7 +- .../discovery/MatchHighlightCards.tsx | 127 ++++++++++++++++++ src/features/profile/ProfileView.tsx | 3 + src/features/profile/TraditionsSection.tsx | 82 +++++++++++ src/hooks/use-api.ts | 1 + .../analytics/generated/telemetryCatalog.ts | 12 +- src/tests/match-highlights.test.tsx | 38 ++++++ 10 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 src/features/discovery/MatchHighlightCards.tsx create mode 100644 src/features/profile/TraditionsSection.tsx create mode 100644 src/tests/match-highlights.test.tsx diff --git a/package-lock.json b/package-lock.json index e8da791..01ea81c 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.35", + "onehook-api-client": "^1.0.36", "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.35", - "resolved": "https://registry.npmjs.org/onehook-api-client/-/onehook-api-client-1.0.35.tgz", - "integrity": "sha512-/Bdi78ttoCUtjrMvS4vB2jIGPGIEmrAT0GEPNmcLL9w2V9dvUQYtLzWf6KGweUs8rudxKL0TeTsBokZET6LZBw==", + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/onehook-api-client/-/onehook-api-client-1.0.36.tgz", + "integrity": "sha512-v5KPG1QBo3tb5do0O3+m907yU34yT+rU1kqTx3yETE7aVio6w6uKes3C+efxuwMt2PZDbApQ35+Colryv4VH/A==", "license": "UNLICENSED", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", diff --git a/package.json b/package.json index a783c80..2675758 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.35", + "onehook-api-client": "^1.0.36", "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 1847ab2..08b3a2e 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -194,6 +194,24 @@ export const SuggestionApi = { removeOneHook: (): Promise => request('DELETE', '/matching/suggestions/onehook'), }; +// ── Traditional matching systems (opt out per tradition) ────────────────────── + +export interface TraditionSetting { + id: string; + label: string; + description: string; + enabled: boolean; + /** Least birth data it uses: DATE, TIME or PLACE. */ + needs: 'DATE' | 'TIME' | 'PLACE' | string; +} + +export const TraditionApi = { + get: (): Promise<{ traditions: TraditionSetting[] }> => request('GET', '/matching/traditions'), + /** Replaces the opt-outs; an empty list re-enrols in every tradition. */ + setDisabled: (disabled: string[]): Promise<{ traditions: TraditionSetting[] }> => + request('PUT', '/matching/traditions', { disabled }), +}; + // ── Chat settings ───────────────────────────────────────────────────────────── export type LastSeenVisibility = 'EVERYONE' | 'MATCHES' | 'NOBODY'; diff --git a/src/features/discovery/DiscoveryCard.tsx b/src/features/discovery/DiscoveryCard.tsx index 0b82989..c3e9df8 100644 --- a/src/features/discovery/DiscoveryCard.tsx +++ b/src/features/discovery/DiscoveryCard.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { Heart, MapPin, CheckCircle2, Volume2, Play, Pause } from 'lucide-react'; +import { MatchHighlightCards } from './MatchHighlightCards'; import { MediaImage } from '../../components/common/MediaImage'; import { useMediaSrc } from '../../utils/media-url'; import { pictureTransformStyle } from '../../utils/photo-transform'; @@ -40,6 +41,8 @@ export interface DiscoveryProfileData { matchFinderLabel?: string; /** Why this candidate is shown (from the discover API). */ reasoning?: string; + /** Every reason this is a good match, one per source; empty when none (then no cards show). */ + matchHighlights?: import('onehook-api-client').MatchHighlight[]; photos?: string[]; pictures?: string[]; pictureTransforms?: Record; @@ -637,7 +640,9 @@ export function DiscoveryCard({
{/* Match finder: who found this person and why. Mr. OneHook = from a OneHook suggestion (asked or inferred); OneHook Intelligence = profile details match the member's preferences. */} - {(candidate.matchFinderLabel || candidate.reasoning || candidate.recommendationReason) && ( + {candidate.matchHighlights !== undefined ? ( + + ) : (candidate.matchFinderLabel || candidate.reasoning || candidate.recommendationReason) && (
(null); + if (!highlights || highlights.length === 0) return null; + + const go = (i: number) => { + const next = Math.max(0, Math.min(highlights.length - 1, i)); + const el = track.current; + if (el) el.scrollTo({ left: next * el.clientWidth, behavior: 'smooth' }); + if (next !== index) report(next); + }; + const report = (i: number) => { + setIndex(i); + // Only which kind of reason was viewed; never the reason text. + trackUi(UiElement.DISCOVER_HIGHLIGHT_SWIPE, { + value: String(highlights[i].source).toLowerCase(), + }); + }; + const onScroll = () => { + const el = track.current; + if (!el) return; + const i = Math.round(el.scrollLeft / Math.max(1, el.clientWidth)); + if (i !== index && i >= 0 && i < highlights.length) report(i); + }; + + return ( +
+
+ + Why we matched you + + {highlights.length > 1 && ( + + {index + 1} / {highlights.length} + + )} +
+
+
+ {highlights.map((h, i) => ( +
+
+ {h.verdict === 'EXCELLENT' ? ( + + ) : ( + + )} + {h.label} + + {h.verdict === 'EXCELLENT' ? 'Excellent match' : 'Good match'} + +
+

{h.reason}

+
+ ))} +
+ {highlights.length > 1 && ( + <> + {index > 0 && ( + + )} + {index < highlights.length - 1 && ( + + )} + + )} +
+ {highlights.length > 1 && ( +
+ {highlights.map((h, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/features/profile/ProfileView.tsx b/src/features/profile/ProfileView.tsx index 106ea76..4c02abd 100644 --- a/src/features/profile/ProfileView.tsx +++ b/src/features/profile/ProfileView.tsx @@ -21,6 +21,7 @@ import { import type { UserProfile } from '../../types'; import { ProfileApi } from '../../api/profile'; import { SuggestionsSection } from './CurrentSuggestionCard'; +import { TraditionsSection } from './TraditionsSection'; import { isPaidSubscriptionTier } from 'onehook-api-client/membership'; import { usePreferences } from '../../hooks/use-api'; import { useAppStore } from '../../store/app-store'; @@ -1385,6 +1386,8 @@ export function ProfileView({ + +
toggle(t)} + data-track-exempt="duplicate: toggle() emits preferences.tradition_toggle" + className="w-4 h-4 accent-ig-blue" + /> + {t.enabled ? 'On' : 'Off'} + + + ))} + + + ); +} diff --git a/src/hooks/use-api.ts b/src/hooks/use-api.ts index 4031e83..950ea7b 100644 --- a/src/hooks/use-api.ts +++ b/src/hooks/use-api.ts @@ -182,6 +182,7 @@ function finderOf(candidate: DiscoverResponse['candidates'][number]) { reasoning: candidate.reasoning, suggestionBasis: candidate.suggestionBasis, suggestionId: candidate.suggestionId, + matchHighlights: candidate.matchHighlights, }; } diff --git a/src/lib/analytics/generated/telemetryCatalog.ts b/src/lib/analytics/generated/telemetryCatalog.ts index 1342d56..a90811d 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 b5cf11db437b7aa9a64b15eff135dfa95ceaa6550b2664a771d603d0e9f1eb5f +// telemetry-integrity: sha256 0de3f1cb1aae5814dd53bc7f5bebcbe1efe5db8df08450ca83b3d11d422ab31c // Source of truth: OneHookBackend/packages/telemetry/src/catalog.ts // Regenerate: (OneHookBackend) npm run telemetry:generate && npm run telemetry:sync -// Catalog version 1.11.0, hash 12c7d6d70211f79c. +// Catalog version 1.12.0, hash 04677b92bd555132. // // 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.11.0"; -export const TELEMETRY_CATALOG_HASH = "12c7d6d70211f79c"; +export const TELEMETRY_CATALOG_VERSION = "1.12.0"; +export const TELEMETRY_CATALOG_HASH = "04677b92bd555132"; /** Every signal type the ingest accepts. */ export const SignalType = { @@ -336,6 +336,7 @@ export const UiElement = { DISCOVER_ADD_HOOK_OPEN: "discover.add_hook_open", DISCOVER_RETRY: "discover.retry", DISCOVER_CATEGORY_SELECT: "discover.category_select", + DISCOVER_HIGHLIGHT_SWIPE: "discover.highlight_swipe", DISCOVER_ADD_HOOK_SUBMIT: "discover.add_hook_submit", DISCOVER_UPGRADE_OPEN: "discover.upgrade_open", LIKE_COMPOSER_SEND: "like_composer.send", @@ -459,6 +460,7 @@ export const UiElement = { PREFERENCES_LOCKED_FILTER_TAP: "preferences.locked_filter_tap", PREFERENCES_SUGGESTION_REMOVE: "preferences.suggestion_remove", PREFERENCES_OWN_SUGGESTION_SAVE: "preferences.own_suggestion_save", + PREFERENCES_TRADITION_TOGGLE: "preferences.tradition_toggle", PREFERENCES_OWN_SUGGESTION_REMOVE: "preferences.own_suggestion_remove", SETTINGS_EMAIL_MANAGE: "settings.email_manage", SETTINGS_PHONE_UPDATE: "settings.phone_update", @@ -601,6 +603,7 @@ export const UI_ELEMENTS: { readonly [K in UiElementId]: UiElementSpec } = { "discover.add_hook_open": { screen: "DISCOVER", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["ios"] }, "discover.retry": { screen: "DISCOVER", kind: "button", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] }, "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"] }, "like_composer.send": { screen: "LIKE_COMPOSER", kind: "button", actions: ["submit"], values: null, platforms: ["web", "ios", "android"] }, @@ -724,6 +727,7 @@ export const UI_ELEMENTS: { readonly [K in UiElementId]: UiElementSpec } = { "preferences.locked_filter_tap": { screen: "PREFERENCES", kind: "button", actions: ["tap"], values: null, platforms: ["ios"] }, "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"] }, "preferences.own_suggestion_remove": { screen: "PREFERENCES", kind: "button", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] }, "settings.email_manage": { screen: "SETTINGS", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["web", "ios", "android"] }, "settings.phone_update": { screen: "SETTINGS", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["ios", "android"] }, diff --git a/src/tests/match-highlights.test.tsx b/src/tests/match-highlights.test.tsx new file mode 100644 index 0000000..6a87290 --- /dev/null +++ b/src/tests/match-highlights.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MatchHighlightCards } from '../features/discovery/MatchHighlightCards'; + +describe('MatchHighlightCards', () => { + it('renders nothing when there are no reasons', () => { + expect(renderToStaticMarkup()).toBe(''); + expect(renderToStaticMarkup()).toBe(''); + }); + it('renders one swipeable card per reason, in order', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Why we matched you'); + expect(html).toContain('1 / 2'); + expect(html.indexOf('Kundali')).toBeLessThan(html.indexOf('Mr. OneHook')); + expect(html).toContain('Excellent match'); + expect(html).toContain('Good match'); + expect((html.match(/data-source=/g) || []).length).toBe(2); + }); +});