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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions src/api/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,24 @@ export const SuggestionApi = {
removeOneHook: (): Promise<void> => 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';
Expand Down
7 changes: 6 additions & 1 deletion src/features/discovery/DiscoveryCard.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, string>;
Expand Down Expand Up @@ -637,7 +640,9 @@ export function DiscoveryCard({
<article className={`w-full max-w-[470px] mx-auto bg-surface-card sm:border sm:border-border sm:rounded-xl flex flex-col overflow-hidden select-none ${className}`}>
{/* 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 ? (
<MatchHighlightCards highlights={candidate.matchHighlights} />
) : (candidate.matchFinderLabel || candidate.reasoning || candidate.recommendationReason) && (
<div
className={`border-b border-border px-4 py-3 text-[11px] leading-relaxed ${
candidate.matchFinder === 'MR_ONEHOOK' ? 'bg-ig-blue/10 text-text' : 'bg-ig-blue/5 text-text-secondary'
Expand Down
127 changes: 127 additions & 0 deletions src/features/discovery/MatchHighlightCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Sparkles, Star } from 'lucide-react';
import type { MatchHighlight } from 'onehook-api-client';
import { trackUi, UiElement } from '../../lib/analytics/analytics';

/**
* "Why we matched you": one card per reason (OneHook Intelligence, Mr. OneHook, Kundali, Bazi, ...),
* swipeable sideways (scroll-snap) with arrows and dots. Each tradition is its own judgement; nothing
* renders when the list is empty.
*/
export function MatchHighlightCards({ highlights }: { highlights?: MatchHighlight[] | null }) {
const [index, setIndex] = useState(0);
const track = useRef<HTMLDivElement>(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 (
<section
aria-label="Why we matched you"
className="border-b border-border bg-ig-blue/5"
data-testid="match-highlights"
>
<div className="flex items-center justify-between px-4 pt-3">
<span className="text-[10px] uppercase tracking-[0.2em] font-black opacity-60">
Why we matched you
</span>
{highlights.length > 1 && (
<span className="text-[10px] opacity-60">
{index + 1} / {highlights.length}
</span>
)}
</div>
<div className="relative">
<div
ref={track}
onScroll={onScroll}
className="flex overflow-x-auto snap-x snap-mandatory scrollbar-none"
style={{ scrollbarWidth: 'none' }}
>
{highlights.map((h, i) => (
<article
key={`${h.source}-${i}`}
className="snap-start shrink-0 w-full px-4 py-3"
aria-roledescription="slide"
aria-label={`${h.label}: ${h.reason}`}
data-source={h.source}
>
<div className="flex items-center gap-2 mb-1">
{h.verdict === 'EXCELLENT' ? (
<Star className="w-3.5 h-3.5 text-amber-500" aria-hidden />
) : (
<Sparkles className="w-3.5 h-3.5 text-ig-blue" aria-hidden />
)}
<span className="text-xs font-semibold">{h.label}</span>
<span
className={`text-[9px] uppercase tracking-wider font-black px-1.5 py-0.5 rounded ${
h.verdict === 'EXCELLENT'
? 'bg-amber-100 text-amber-800'
: 'bg-ig-blue/10 text-ig-blue'
}`}
>
{h.verdict === 'EXCELLENT' ? 'Excellent match' : 'Good match'}
</span>
</div>
<p className="text-[12px] leading-relaxed text-text-secondary">{h.reason}</p>
</article>
))}
</div>
{highlights.length > 1 && (
<>
{index > 0 && (
<button
type="button"
aria-label="Previous reason"
className="absolute left-0 top-1/2 -translate-y-1/2 p-1 opacity-60 hover:opacity-100"
data-track-exempt="duplicate: the scroll handler emits discover.highlight_swipe"
onClick={() => go(index - 1)}
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
{index < highlights.length - 1 && (
<button
type="button"
aria-label="Next reason"
className="absolute right-0 top-1/2 -translate-y-1/2 p-1 opacity-60 hover:opacity-100"
data-track-exempt="duplicate: the scroll handler emits discover.highlight_swipe"
onClick={() => go(index + 1)}
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</>
)}
</div>
{highlights.length > 1 && (
<div className="flex justify-center gap-1 pb-2" aria-hidden>
{highlights.map((h, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-all ${i === index ? 'w-4 bg-ig-blue' : 'w-1.5 bg-border'}`}
/>
))}
</div>
)}
</section>
);
}
3 changes: 3 additions & 0 deletions src/features/profile/ProfileView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1385,6 +1386,8 @@ export function ProfileView({

<SuggestionsSection paid={isPaidSubscriptionTier(userState?.subscriptionTier)} />

<TraditionsSection />

<div className="grid grid-cols-2 gap-4">
<Field label="Relationship Goal">
<select
Expand Down
82 changes: 82 additions & 0 deletions src/features/profile/TraditionsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { TraditionApi, type TraditionSetting } from '../../api/rest';
import { trackUi, UiElement } from '../../lib/analytics/analytics';

const NEEDS: Record<string, string> = {
DATE: 'Uses your date of birth',
TIME: 'Needs your birth time',
PLACE: 'Needs your birth time and place',
};

/**
* Traditional matching systems the member takes part in. All are on by default. Turning one off means
* it is never computed or shown, for you or for anyone you are matched with.
*/
export function TraditionsSection() {
const [items, setItems] = useState<TraditionSetting[] | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
TraditionApi.get()
.then((r) => setItems(r.traditions))
.catch(() => setError('Could not load traditions.'));
}, []);

const toggle = async (t: TraditionSetting) => {
if (!items) return;
const next = items.map((x) => (x.id === t.id ? { ...x, enabled: !x.enabled } : x));
setSaving(t.id);
setError(null);
trackUi(UiElement.PREFERENCES_TRADITION_TOGGLE, { value: t.id.toLowerCase() });
try {
const r = await TraditionApi.setDisabled(next.filter((x) => !x.enabled).map((x) => x.id));
setItems(r.traditions);
} catch {
setError('Could not save. Please try again.');
} finally {
setSaving(null);
}
};

return (
<section className="space-y-3" data-testid="traditions-section">
<div>
<h3 className="text-[10px] uppercase tracking-[0.3em] font-black opacity-50">
Traditional matching
</h3>
<p className="text-xs opacity-70 mt-1">
Each tradition judges a match on its own and only appears on a profile when it rates the
match well. Turn off any you do not follow: it will not be calculated or shown for you or
your matches.
</p>
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
{!items && !error && <p className="text-xs opacity-60">Loading…</p>}
<ul className="divide-y divide-border border border-border">
{items?.map((t) => (
<li key={t.id} className="flex items-start justify-between gap-3 p-3">
<div className="min-w-0">
<p className="text-sm font-semibold">{t.label}</p>
<p className="text-xs opacity-70">{t.description}</p>
<p className="text-[10px] opacity-50 mt-0.5">{NEEDS[t.needs] ?? ''}</p>
</div>
<label className="inline-flex items-center gap-2 shrink-0 cursor-pointer">
<input
type="checkbox"
role="switch"
aria-label={`${t.label} matching`}
checked={t.enabled}
disabled={saving === t.id}
onChange={() => toggle(t)}
data-track-exempt="duplicate: toggle() emits preferences.tradition_toggle"
className="w-4 h-4 accent-ig-blue"
/>
<span className="text-xs">{t.enabled ? 'On' : 'Off'}</span>
</label>
</li>
))}
</ul>
</section>
);
}
1 change: 1 addition & 0 deletions src/hooks/use-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ function finderOf(candidate: DiscoverResponse['candidates'][number]) {
reasoning: candidate.reasoning,
suggestionBasis: candidate.suggestionBasis,
suggestionId: candidate.suggestionId,
matchHighlights: candidate.matchHighlights,
};
}

Expand Down
12 changes: 8 additions & 4 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 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 = {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"] },
Expand Down Expand Up @@ -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"] },
Expand Down
Loading
Loading