Add Matches page, OkCupid integration, and major UI/feature updates

- New Matches page with match scoring system
- New OkCupid page and API integration
- Enhanced Likes page with scanner improvements and enrichment
- Updated Settings, Discover, Messages, and Chat pages
- Improved auth, GraphQL client, and Stream Chat setup
- Added new backend endpoints (matchScoring.js)
- Removed old Proxyman capture logs
- Updated nginx config and Vite proxy settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-16 07:11:21 -05:00
parent 0a725508d2
commit f84786e654
176 changed files with 6828 additions and 1177 deletions

View File

@@ -3,8 +3,12 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1, user-scalable=no" />
<meta name="theme-color" content="#121212" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-capable" content="yes" />
<title>Feeld</title>
</head>
<body>
<div id="root"></div>

View File

@@ -39,6 +39,15 @@ http {
proxy_set_header Host $host;
}
# OKCupid API proxy (goes through backend to bypass Cloudflare)
location /api/okcupid/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Auth endpoints
location /api/auth/ {
proxy_pass http://backend;
@@ -102,6 +111,24 @@ http {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Emulate app open endpoint
location /api/emulate-open {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Match scoring endpoints
location /api/matches {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Everything else (including /api/graphql, /api/firebase, etc.)
# goes to Vite which handles its own proxying
location / {

View File

@@ -4,6 +4,7 @@ import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
import { DEFAULT_WEIGHTS, scoreProfile, safeStr as scoreSafeStr } from './matchScoring.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Always use ../data (relative to server/) so Docker and local dev use the same directory
@@ -395,6 +396,19 @@ app.get('/api/discovered-profiles', (req, res) => {
}
});
// GET /api/discovered-profiles/lookup/:id - Get a single cached profile
app.get('/api/discovered-profiles/lookup/:id', (req, res) => {
const filePath = path.join(DATA_DIR, 'discoveredProfiles.json');
if (!fs.existsSync(filePath)) return res.json({ profile: null });
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const target = (data.profiles || []).find(p => p.id === req.params.id);
res.json({ profile: target || null });
} catch (e) {
res.json({ profile: null });
}
});
// POST /api/discovered-profiles/batch - Batch upsert discovered profiles
app.post('/api/discovered-profiles/batch', (req, res) => {
const { profiles: incoming } = req.body;
@@ -421,9 +435,12 @@ app.post('/api/discovered-profiles/batch', (req, res) => {
if (!profile.id) continue;
const existing = existingMap.get(profile.id);
if (existing) {
// Update but preserve original discoveredAt
// Last-seen wins for discoveredLocation; fall back to prior value if
// this batch didn't carry one.
existingMap.set(profile.id, {
...existing,
...profile,
discoveredLocation: profile.discoveredLocation ?? existing.discoveredLocation ?? null,
discoveredAt: existing.discoveredAt,
updatedAt: new Date().toISOString(),
});
@@ -431,6 +448,7 @@ app.post('/api/discovered-profiles/batch', (req, res) => {
} else {
existingMap.set(profile.id, {
...profile,
discoveredLocation: profile.discoveredLocation ?? null,
discoveredAt: profile.discoveredAt || new Date().toISOString(),
});
added++;
@@ -479,6 +497,288 @@ app.delete('/api/discovered-profiles/:profileId', (req, res) => {
}
});
// PUT /api/discovered-profiles/update-photos - Update photos for a cached profile
app.put('/api/discovered-profiles/update-photos', (req, res) => {
const { profileId, photos } = req.body;
if (!profileId || !Array.isArray(photos)) {
return res.status(400).json({ success: false, error: 'profileId and photos array required' });
}
const filePath = path.join(DATA_DIR, 'discoveredProfiles.json');
if (fs.existsSync(filePath)) {
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const idx = (data.profiles || []).findIndex(p => p.id === profileId);
if (idx >= 0) {
data.profiles[idx].photos = photos;
data.profiles[idx].photosRefreshedAt = new Date().toISOString();
data.updatedAt = new Date().toISOString();
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
return res.json({ success: true, updated: true });
}
return res.json({ success: true, updated: false, reason: 'profile not found' });
} catch (e) {
console.error('Failed to update photos:', e);
return res.status(500).json({ success: false, error: e.message });
}
}
res.json({ success: true, updated: false, reason: 'no cache file' });
});
// ============================================================
// Smart Matches Endpoints
// ============================================================
const MATCH_WEIGHTS_FILE = path.join(DATA_DIR, 'matchWeights.json');
function readMatchWeights() {
if (fs.existsSync(MATCH_WEIGHTS_FILE)) {
try {
return { ...DEFAULT_WEIGHTS, ...JSON.parse(fs.readFileSync(MATCH_WEIGHTS_FILE, 'utf8')) };
} catch (e) {
console.error('Failed to read matchWeights.json:', e);
}
}
return { ...DEFAULT_WEIGHTS };
}
function readJsonFile(filename, fallback) {
const filePath = path.join(DATA_DIR, filename);
if (fs.existsSync(filePath)) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (e) {
console.error(`Failed to read ${filename}:`, e);
}
}
return fallback;
}
// GET /api/matches — Score, filter, and rank discovered profiles
app.get('/api/matches', (req, res) => {
try {
const {
minAge, maxAge, maxDistance, gender, sexuality, desires,
verifiedOnly, search, theyLikedOnly, sort,
limit = '50', offset = '0',
} = req.query;
const limitNum = Math.min(parseInt(limit) || 50, 200);
const offsetNum = parseInt(offset) || 0;
// 1. Read discovered profiles
const discovered = readJsonFile('discoveredProfiles.json', { profiles: [] });
let profiles = discovered.profiles || [];
// 2. Build exclusion sets
const disliked = readJsonFile('dislikedProfiles.json', { profiles: [] });
const dislikedIds = new Set((disliked.profiles || []).map(p => p.id));
const sentPings = readJsonFile('sentPings.json', { pings: [] });
const sentPingIds = new Set((sentPings.pings || []).map(p => p.targetProfileId));
// Read user.json to get liked profiles
const userFiles = fs.readdirSync(DATA_DIR).filter(f => f.endsWith('.json') && !['discoveredProfiles', 'dislikedProfiles', 'sentPings', 'whoLikedYou', 'auth', 'auth-tokens', 'locationRotation', 'savedLocations', 'matchWeights'].some(n => f.startsWith(n)));
let likedIds = new Set();
for (const file of userFiles) {
try {
const data = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), 'utf8'));
if (data.likedProfiles) {
data.likedProfiles.forEach(p => likedIds.add(p.id));
}
} catch (e) { /* skip */ }
}
// 3. Filter out excluded profiles
profiles = profiles.filter(p => !dislikedIds.has(p.id) && !sentPingIds.has(p.id) && !likedIds.has(p.id));
// 4. Apply query param filters
if (minAge) {
const min = parseInt(minAge);
profiles = profiles.filter(p => typeof p.age === 'number' && p.age >= min);
}
if (maxAge) {
const max = parseInt(maxAge);
profiles = profiles.filter(p => typeof p.age === 'number' && p.age <= max);
}
if (maxDistance) {
const maxDist = parseInt(maxDistance);
profiles = profiles.filter(p => {
const mi = p.distance?.mi;
return typeof mi === 'number' && mi <= maxDist;
});
}
if (gender) {
const genders = gender.split(',').map(g => g.trim().toUpperCase());
profiles = profiles.filter(p => {
const pg = (typeof p.gender === 'string' ? p.gender : '').toUpperCase();
return genders.includes(pg);
});
}
if (sexuality) {
const sexualities = sexuality.split(',').map(s => s.trim().toUpperCase());
profiles = profiles.filter(p => {
const ps = (typeof p.sexuality === 'string' ? p.sexuality : '').toUpperCase();
return sexualities.includes(ps);
});
}
if (desires) {
const desireList = desires.split(',').map(d => d.trim().toUpperCase());
profiles = profiles.filter(p => {
const pd = Array.isArray(p.desires) ? p.desires.map(d => (typeof d === 'string' ? d : '').toUpperCase()) : [];
return desireList.some(d => pd.includes(d));
});
}
if (verifiedOnly === 'true') {
profiles = profiles.filter(p => {
const vs = typeof p.verificationStatus === 'string' ? p.verificationStatus : '';
return vs.toUpperCase() === 'VERIFIED';
});
}
if (theyLikedOnly === 'true') {
profiles = profiles.filter(p => p.interactionStatus?.theirs === 'LIKED');
}
// 5. Text search on name + bio
if (search) {
const searchLower = search.toLowerCase();
profiles = profiles.filter(p => {
const name = (typeof p.imaginaryName === 'string' ? p.imaginaryName : '').toLowerCase();
const bio = (typeof p.bio === 'string' ? p.bio : '').toLowerCase();
return name.includes(searchLower) || bio.includes(searchLower);
});
}
// 6. Score each profile
const weights = readMatchWeights();
const scored = profiles.map(p => {
const { total, breakdown } = scoreProfile(p, weights);
return { ...p, _score: total, _scoreBreakdown: breakdown };
});
// 7. Sort
if (sort === 'distance') {
scored.sort((a, b) => (a.distance?.mi ?? 9999) - (b.distance?.mi ?? 9999));
} else if (sort === 'recent') {
scored.sort((a, b) => {
const da = a.discoveredAt ? new Date(a.discoveredAt).getTime() : 0;
const db = b.discoveredAt ? new Date(b.discoveredAt).getTime() : 0;
return db - da;
});
} else {
// Default: score descending
scored.sort((a, b) => b._score - a._score);
}
// 8. Paginate
const total = scored.length;
const paginated = scored.slice(offsetNum, offsetNum + limitNum);
res.json({
matches: paginated,
total,
filters: { minAge, maxAge, maxDistance, gender, sexuality, desires, verifiedOnly, search, theyLikedOnly, sort },
updatedAt: discovered.updatedAt,
});
} catch (e) {
console.error('Failed to compute matches:', e);
res.status(500).json({ error: e.message });
}
});
// GET /api/matches/weights — Return current scoring weights
app.get('/api/matches/weights', (req, res) => {
res.json({ weights: readMatchWeights(), updatedAt: new Date().toISOString() });
});
// PUT /api/matches/weights — Update scoring weights
app.put('/api/matches/weights', (req, res) => {
const { weights } = req.body;
if (!weights || typeof weights !== 'object') {
return res.status(400).json({ success: false, error: 'weights object required' });
}
const merged = { ...DEFAULT_WEIGHTS, ...weights };
fs.writeFileSync(MATCH_WEIGHTS_FILE, JSON.stringify(merged, null, 2));
res.json({ success: true, weights: merged });
});
// GET /api/matches/summary — Discord-friendly text summary
app.get('/api/matches/summary', (req, res) => {
try {
const { limit = '5', ...filterParams } = req.query;
// Reuse the matches logic by building the same pipeline
const discovered = readJsonFile('discoveredProfiles.json', { profiles: [] });
let profiles = discovered.profiles || [];
// Exclusions
const disliked = readJsonFile('dislikedProfiles.json', { profiles: [] });
const dislikedIds = new Set((disliked.profiles || []).map(p => p.id));
const sentPings = readJsonFile('sentPings.json', { pings: [] });
const sentPingIds = new Set((sentPings.pings || []).map(p => p.targetProfileId));
profiles = profiles.filter(p => !dislikedIds.has(p.id) && !sentPingIds.has(p.id));
// Apply filters
if (filterParams.theyLikedOnly === 'true') {
profiles = profiles.filter(p => p.interactionStatus?.theirs === 'LIKED');
}
if (filterParams.verifiedOnly === 'true') {
profiles = profiles.filter(p => (typeof p.verificationStatus === 'string' ? p.verificationStatus : '').toUpperCase() === 'VERIFIED');
}
if (filterParams.maxAge) {
profiles = profiles.filter(p => typeof p.age === 'number' && p.age <= parseInt(filterParams.maxAge));
}
if (filterParams.minAge) {
profiles = profiles.filter(p => typeof p.age === 'number' && p.age >= parseInt(filterParams.minAge));
}
if (filterParams.maxDistance) {
profiles = profiles.filter(p => typeof p.distance?.mi === 'number' && p.distance.mi <= parseInt(filterParams.maxDistance));
}
// Score & sort
const weights = readMatchWeights();
const scored = profiles.map(p => ({
...p,
_score: scoreProfile(p, weights).total,
}));
scored.sort((a, b) => b._score - a._score);
const topN = scored.slice(0, parseInt(limit) || 5);
const total = scored.length;
// Build summary text
const lines = topN.map((p, i) => {
const name = typeof p.imaginaryName === 'string' ? p.imaginaryName : 'Unknown';
const age = p.age || '?';
const dist = p.distance?.mi != null ? `${Math.round(p.distance.mi)}mi` : '?mi';
const verified = (typeof p.verificationStatus === 'string' && p.verificationStatus.toUpperCase() === 'VERIFIED') ? 'Verified' : '';
const liked = p.interactionStatus?.theirs === 'LIKED' ? 'they liked you' : '';
const tags = [verified, liked, dist].filter(Boolean).join(', ');
return `${i + 1}. ${name}, ${age} (Score: ${p._score}) — ${tags}`;
});
const summary = topN.length > 0
? `Top ${topN.length} Matches:\n${lines.join('\n')}`
: 'No matches found with current filters.';
res.json({
summary,
matches: topN.map(p => ({
id: p.id,
imaginaryName: typeof p.imaginaryName === 'string' ? p.imaginaryName : '',
age: p.age,
score: p._score,
distance: p.distance,
verified: (typeof p.verificationStatus === 'string' && p.verificationStatus.toUpperCase() === 'VERIFIED'),
theyLikedYou: p.interactionStatus?.theirs === 'LIKED',
})),
total,
});
} catch (e) {
console.error('Failed to compute matches summary:', e);
res.status(500).json({ error: e.message });
}
});
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
@@ -522,6 +822,357 @@ app.post('/api/auth/logout', (req, res) => {
res.json({ success: true });
});
// GET /api/auth/token — Single source of truth: backend manages the refresh token,
// frontend just gets a ready-to-use access token. No more race conditions.
app.get('/api/auth/token', async (req, res) => {
try {
// Ensure feeldAPI has credentials loaded
if (!feeldAPI.refreshToken) {
feeldAPI.loadCredentials();
}
if (!feeldAPI.refreshToken) {
return res.status(503).json({ error: 'No refresh token available. Seed from mobile app first.' });
}
const accessToken = await feeldAPI.getToken();
res.json({
accessToken,
profileId: feeldAPI.profileId,
analyticsId: feeldAPI.analyticsId,
expiresAt: feeldAPI.expiresAt,
});
} catch (e) {
console.error('[Auth] Token request failed:', e.message);
res.status(500).json({ error: e.message });
}
});
// POST /api/auth/seed — Seed a refresh token (from mobile app Proxyman capture, etc.)
app.post('/api/auth/seed', (req, res) => {
const { refreshToken, profileId, analyticsId } = req.body;
if (!refreshToken) return res.status(400).json({ error: 'refreshToken required' });
feeldAPI.saveCredentials(
profileId || feeldAPI.profileId,
refreshToken,
analyticsId || feeldAPI.analyticsId
);
// Clear cached access token so next request gets a fresh one
feeldAPI.accessToken = null;
feeldAPI.expiresAt = 0;
res.json({ success: true });
});
// ============================================================
// Emulate App Open — replicate the mobile app's launch sequence
// ============================================================
app.post('/api/emulate-open', async (req, res) => {
const { latitude, longitude, locationName } = req.body;
if (latitude == null || longitude == null) {
return res.status(400).json({ error: 'latitude and longitude required' });
}
if (!feeldAPI.profileId) {
feeldAPI.loadCredentials();
if (!feeldAPI.profileId) {
return res.status(503).json({ error: 'No Feeld credentials. Seed from browser first.' });
}
}
const steps = [];
try {
// Step 1: Set device location (mimics GPS update on app open)
await feeldAPI.updateLocation(latitude, longitude);
steps.push({ step: 'DeviceLocationUpdate', status: 'ok', location: locationName || `${latitude},${longitude}` });
// Step 2: Fetch search settings
let filters = {
ageRange: [22, 59],
maxDistance: 100,
lookingFor: ['WOMAN', 'MAN_WOMAN_COUPLE', 'WOMAN_WOMAN_COUPLE'],
recentlyOnline: false,
desiringFor: [],
};
try {
const settings = await feeldAPI.getSearchSettings();
const profile = settings?.profile;
if (profile) {
filters = {
ageRange: profile.ageRange || filters.ageRange,
maxDistance: profile.distanceMax || filters.maxDistance,
lookingFor: profile.lookingFor || filters.lookingFor,
recentlyOnline: profile.recentlyOnline || false,
desiringFor: profile.desiringFor || [],
};
}
steps.push({ step: 'SearchSettings', status: 'ok', filters });
} catch (e) {
steps.push({ step: 'SearchSettings', status: 'fallback', error: e.message });
}
// Step 3: Discover profiles at this location
const discovery = await feeldAPI.discoverProfiles(filters);
const profiles = discovery?.discovery?.nodes || [];
steps.push({ step: 'DiscoverProfiles', status: 'ok', count: profiles.length, hasNextBatch: discovery?.discovery?.hasNextBatch });
// Step 4: Cache discovered profiles and detect who liked us
let newProfiles = 0;
let likedMeFound = 0;
if (profiles.length > 0) {
const discoveredFile = path.join(DATA_DIR, 'discoveredProfiles.json');
let existing = { profiles: [], updatedAt: null };
try { existing = JSON.parse(fs.readFileSync(discoveredFile, 'utf8')); } catch {}
const existingMap = new Map(existing.profiles.map(p => [p.id, p]));
for (const p of profiles) {
const safeStr = v => (typeof v === 'string' ? v : '');
const cached = {
id: p.id,
imaginaryName: safeStr(p.imaginaryName),
age: p.age,
gender: safeStr(p.gender),
sexuality: safeStr(p.sexuality),
bio: safeStr(p.bio),
desires: Array.isArray(p.desires) ? p.desires.filter(d => typeof d === 'string') : [],
connectionGoals: Array.isArray(p.connectionGoals) ? p.connectionGoals.filter(g => typeof g === 'string') : [],
verificationStatus: safeStr(p.verificationStatus),
interactionStatus: p.interactionStatus,
discoveredLocation: locationName || `${latitude},${longitude}`,
discoveredAt: existingMap.has(p.id) ? existingMap.get(p.id).discoveredAt : new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
if (!existingMap.has(p.id)) newProfiles++;
existingMap.set(p.id, cached);
// Detect who liked us
if (p.interactionStatus?.theirs === 'LIKED') {
likedMeFound++;
// Save to whoLikedYou cache
const whoFile = path.join(DATA_DIR, 'whoLikedYou.json');
let whoData = { profiles: [], updatedAt: null };
try { whoData = JSON.parse(fs.readFileSync(whoFile, 'utf8')); } catch {}
if (!whoData.profiles.some(w => w.id === p.id)) {
whoData.profiles.push({
id: p.id,
imaginaryName: safeStr(p.imaginaryName),
age: p.age,
gender: safeStr(p.gender),
sexuality: safeStr(p.sexuality),
photos: p.photos,
discoveredAt: new Date().toISOString(),
});
whoData.updatedAt = new Date().toISOString();
fs.writeFileSync(whoFile, JSON.stringify(whoData, null, 2));
}
}
}
existing.profiles = Array.from(existingMap.values());
existing.updatedAt = new Date().toISOString();
fs.writeFileSync(discoveredFile, JSON.stringify(existing, null, 2));
console.log(`Discovered profiles batch: +${newProfiles} new, ${profiles.length} updated, ${existing.profiles.length} total`);
}
res.json({
success: true,
location: locationName || `${latitude},${longitude}`,
steps,
summary: {
profilesFound: profiles.length,
newProfiles,
likedMeFound,
},
});
} catch (e) {
console.error('[EmulateOpen] Error:', e.message);
res.status(500).json({ error: e.message, steps });
}
});
// ============================================================
// OKCupid API Proxy — server-side fetch to bypass Cloudflare
// ============================================================
const OKC_TOKEN_FILE = path.join(DATA_DIR, 'okc-token.json');
const OKC_CREDS_FILE = path.join(DATA_DIR, 'okc-credentials.json');
const OKC_HEADERS = {
'Content-Type': 'application/json',
'User-Agent': 'OkCupid/111.1.0 iOS/26.2.1',
'x-okcupid-locale': 'en',
'x-okcupid-platform': 'ios',
'x-okcupid-auth-v': '1',
'x-okcupid-version': '111.1.0',
'x-okcupid-device-id': '40022B89-7089-4969-85CC-94843116EEE9',
'apollographql-client-name': 'com.okcupid.app-apollo-ios',
'apollographql-client-version': '111.1.0-1625',
'Accept': 'application/json',
};
// OKC token is valid for 45 min. We login ONCE and refresh proactively before expiry.
// NEVER login reactively in response to a failed request.
let _okcLoginInProgress = null;
async function okcLogin() {
// Deduplicate: if a login is already in progress, wait for it
if (_okcLoginInProgress) return _okcLoginInProgress;
_okcLoginInProgress = _doOkcLogin();
const result = await _okcLoginInProgress;
_okcLoginInProgress = null;
return result;
}
async function _doOkcLogin() {
if (!fs.existsSync(OKC_CREDS_FILE)) return false;
try {
const creds = JSON.parse(fs.readFileSync(OKC_CREDS_FILE, 'utf8'));
if (!creds.email || !creds.password) return false;
// Step 1: Anonymous token
const anonResp = await fetch('https://e2p-okapi.api.okcupid.com/graphql/AnonAuthToken', {
method: 'POST',
headers: OKC_HEADERS,
body: JSON.stringify({
operationName: 'AnonAuthToken',
query: 'mutation AnonAuthToken($input: AuthAnonymousInput!) { authAnonymous(input: $input) { token } }',
variables: { input: { deviceId: '40022B89-7089-4969-85CC-94843116EEE9', siteCode: 36 } },
extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' } },
}),
});
const anonData = await anonResp.json();
let anonToken = anonData?.data?.authAnonymous?.token;
if (!anonToken) {
console.error('[OKC] Anon auth failed:', JSON.stringify(anonData).substring(0, 200));
return false;
}
anonToken = anonToken.replace(/^Bearer\s+/i, '');
// Step 2: Login with anon token
const loginResp = await fetch('https://e2p-okapi.api.okcupid.com/graphql/AuthLogin', {
method: 'POST',
headers: { ...OKC_HEADERS, 'Authorization': 'Bearer ' + anonToken },
body: JSON.stringify({
operationName: 'AuthLogin',
query: 'mutation AuthLogin($input: AuthEmailLoginInput!) { authEmailLogin(input: $input) { token encryptedUserId status } }',
variables: { input: { email: creds.email, password: creds.password } },
extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' } },
}),
});
const loginData = await loginResp.json();
const newToken = loginData?.data?.authEmailLogin?.token;
if (newToken) {
const clean = newToken.replace(/^Bearer\s+/i, '');
fs.writeFileSync(OKC_TOKEN_FILE, JSON.stringify({ token: clean, updatedAt: new Date().toISOString() }, null, 2));
console.log('[OKC] Token refreshed, valid for 45 min');
return true;
}
console.error('[OKC] Login failed (status ' + loginData?.data?.authEmailLogin?.status + ')');
return false;
} catch (e) {
console.error('[OKC] Login error:', e.message);
return false;
}
}
function getOkcToken() {
if (fs.existsSync(OKC_TOKEN_FILE)) {
try {
const token = JSON.parse(fs.readFileSync(OKC_TOKEN_FILE, 'utf8')).token;
return token ? token.replace(/^Bearer\s+/i, '') : null;
} catch (e) {}
}
return null;
}
function getOkcTokenExpiry() {
const token = getOkcToken();
if (!token) return 0;
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
return payload.exp * 1000;
} catch (e) { return 0; }
}
// Proactive refresh: check every 5 min, login if < 10 min remaining
setInterval(async () => {
const expiry = getOkcTokenExpiry();
if (!expiry) return;
const remaining = expiry - Date.now();
if (remaining < 10 * 60 * 1000 && remaining > -5 * 60 * 1000) {
// Between 10 min before expiry and 5 min after — refresh
console.log('[OKC] Proactive refresh: token expires in', Math.round(remaining / 60000), 'min');
await okcLogin();
}
}, 5 * 60 * 1000);
// Login on startup if token is expired or missing
(async () => {
const expiry = getOkcTokenExpiry();
if (!expiry || Date.now() > expiry) {
console.log('[OKC] Token expired or missing on startup, logging in...');
await okcLogin();
} else {
console.log('[OKC] Token valid, expires in', Math.round((expiry - Date.now()) / 60000), 'min');
}
})();
// POST /api/okcupid/graphql/:operation — Proxy GraphQL requests to OKCupid
// NO reactive login — just forward the request with current token
app.post('/api/okcupid/graphql/:operation', async (req, res) => {
const token = getOkcToken();
if (!token) {
return res.status(401).json({ error: 'No OKCupid token. Login not configured.' });
}
const operation = req.params.operation;
// Log vote operations for debugging
if (operation === 'UserVote') {
console.log('[OKC] UserVote request:', JSON.stringify(req.body?.variables || {}).substring(0, 500));
}
try {
const response = await fetch(`https://e2p-okapi.api.okcupid.com/graphql/${operation}`, {
method: 'POST',
headers: {
...OKC_HEADERS,
'Authorization': `Bearer ${token}`,
'x-match-useragent': 'OkCupid/111.1.0 iOS/26.2.1',
'X-APOLLO-OPERATION-TYPE': req.body?.query?.trim().startsWith('mutation') ? 'mutation' : 'query',
'X-APOLLO-OPERATION-NAME': operation,
'Accept-Language': 'en-US,en;q=0.9',
'Connection': 'keep-alive',
},
body: JSON.stringify({
...req.body,
extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' }, ...req.body.extensions },
}),
});
const data = await response.text();
if (operation === 'UserVote') {
console.log('[OKC] UserVote response:', data.substring(0, 300));
}
res.status(response.status).type('application/json').send(data);
} catch (e) {
console.error('[OKC Proxy] Error:', e.message);
res.status(500).json({ error: e.message });
}
});
// GET /api/okcupid/token — Get stored OKC token
app.get('/api/okcupid/token', (req, res) => {
const token = getOkcToken();
res.json({ token: token ? token.substring(0, 20) + '...' : null, hasToken: !!token });
});
// PUT /api/okcupid/token — Save OKC token
app.put('/api/okcupid/token', (req, res) => {
const { token } = req.body;
if (!token) return res.status(400).json({ error: 'token required' });
fs.writeFileSync(OKC_TOKEN_FILE, JSON.stringify({ token, updatedAt: new Date().toISOString() }, null, 2));
res.json({ success: true });
});
// ============================================================
// FeeldAPI Client — makes direct GraphQL calls to Feeld backend
// ============================================================
@@ -529,8 +1180,8 @@ app.post('/api/auth/logout', (req, res) => {
const FIREBASE_API_KEY = 'AIzaSyD9o9mzulN50-hqOwF6ww9pxUNUxwVOCXA';
const FIREBASE_REFRESH_URL = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`;
const GRAPHQL_ENDPOINT = 'https://core.api.fldcore.com/graphql';
const APP_VERSION = '8.8.3';
const OS_VERSION = '18.6.2';
const APP_VERSION = '8.11.0';
const OS_VERSION = '26.2.1';
const AUTH_TOKENS_FILE = path.join(DATA_DIR, 'auth-tokens.json');
const ROTATION_STATE_FILE = path.join(DATA_DIR, 'locationRotation.json');
const SAVED_LOCATIONS_FILE = path.join(DATA_DIR, 'savedLocations.json');
@@ -613,7 +1264,7 @@ class FeeldAPIClient {
return this.accessToken;
}
async graphql(operationName, query, variables = {}) {
async graphql(operationName, query, variables = {}, _retried = false) {
const token = await this.getToken();
const transactionId = crypto.randomUUID();
@@ -640,6 +1291,21 @@ class FeeldAPIClient {
}
const result = await response.json();
// Auto-retry on UNAUTHENTICATED: force-refresh token and replay
if (result.errors && !_retried) {
const isAuthError = result.errors.some(e => e.extensions?.code === 'UNAUTHENTICATED');
if (isAuthError) {
console.log(`[FeeldAPI] Auth error on ${operationName}, force-refreshing token...`);
this.accessToken = null;
this.expiresAt = 0;
// Re-read credentials from disk in case another process updated them
this.loadCredentials();
await this.refreshAccessToken();
return this.graphql(operationName, query, variables, true);
}
}
if (result.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
}
@@ -876,6 +1542,7 @@ async function performRotation() {
const existingMap = new Map(discoveredData.profiles.map(p => [p.id, p]));
for (const p of profiles) {
const sanitized = sanitizeProfile(p);
sanitized.discoveredLocation = loc.name;
const existing = existingMap.get(p.id);
if (existing) {
existingMap.set(p.id, { ...sanitized, discoveredAt: existing.discoveredAt, updatedAt: new Date().toISOString() });

142
web/server/matchScoring.js Normal file
View File

@@ -0,0 +1,142 @@
// Match scoring engine for discovered profiles
const DEFAULT_WEIGHTS = {
verification: 15,
photoBase: 2, // per photo (max 6)
photoVerified: 3, // per verified photo
bioLong: 15, // >200 chars
bioMedium: 10, // >100 chars
bioShort: 5, // >30 chars
desiresMany: 8, // >=5 desires
desiresSome: 5, // >=3 desires
connectionGoals: 5, // has any
distanceClose: 15, // <=15mi
distanceMedium: 10, // <=30mi
distanceFar: 5, // <=50mi
ageSweetSpot: 15, // 24-40 (preferred)
ageOk: 5, // 21-45
ageOutOfRange: -10, // outside 21-45 penalty
theyLikedYou: 30, // interactionStatus.theirs === 'LIKED'
connectionDesires: 5, // >=2 of CONNECTION/COMMUNICATION/FWB/INTIMACY/RELATIONSHIP
};
const CONNECTION_DESIRE_KEYWORDS = [
'CONNECTION', 'COMMUNICATION', 'FWB', 'INTIMACY', 'RELATIONSHIP',
'connection', 'communication', 'fwb', 'intimacy', 'relationship',
'Friends with benefits', 'Long-term relationship', 'Short-term relationship',
];
function safeStr(v) {
return typeof v === 'string' ? v : '';
}
function scoreProfile(profile, weights = DEFAULT_WEIGHTS) {
const breakdown = {};
let total = 0;
// Verification
const verStatus = safeStr(profile.verificationStatus);
if (verStatus === 'VERIFIED' || verStatus === 'verified') {
breakdown.verification = weights.verification;
total += weights.verification;
}
// Photos
const photos = Array.isArray(profile.photos) ? profile.photos : [];
const photoCount = Math.min(photos.length, 6);
if (photoCount > 0) {
const photoScore = photoCount * weights.photoBase;
breakdown.photos = photoScore;
total += photoScore;
}
// Verified photos (pictureType === 'VERIFIED' or similar)
const verifiedPhotos = photos.filter(p =>
p.pictureType === 'VERIFIED' || p.pictureType === 'verified'
).length;
if (verifiedPhotos > 0) {
const vpScore = verifiedPhotos * weights.photoVerified;
breakdown.verifiedPhotos = vpScore;
total += vpScore;
}
// Bio quality
const bio = safeStr(profile.bio);
if (bio.length > 200) {
breakdown.bio = weights.bioLong;
total += weights.bioLong;
} else if (bio.length > 100) {
breakdown.bio = weights.bioMedium;
total += weights.bioMedium;
} else if (bio.length > 30) {
breakdown.bio = weights.bioShort;
total += weights.bioShort;
}
// Desires
const desires = Array.isArray(profile.desires) ? profile.desires : [];
if (desires.length >= 5) {
breakdown.desires = weights.desiresMany;
total += weights.desiresMany;
} else if (desires.length >= 3) {
breakdown.desires = weights.desiresSome;
total += weights.desiresSome;
}
// Connection goals
const goals = Array.isArray(profile.connectionGoals) ? profile.connectionGoals : [];
if (goals.length > 0) {
breakdown.connectionGoals = weights.connectionGoals;
total += weights.connectionGoals;
}
// Distance
const distMi = profile.distance?.mi;
if (typeof distMi === 'number') {
if (distMi <= 15) {
breakdown.distance = weights.distanceClose;
total += weights.distanceClose;
} else if (distMi <= 30) {
breakdown.distance = weights.distanceMedium;
total += weights.distanceMedium;
} else if (distMi <= 50) {
breakdown.distance = weights.distanceFar;
total += weights.distanceFar;
}
}
// Age preference: 24-40 sweet spot, 21-45 ok, outside penalized
const age = profile.age;
if (typeof age === 'number') {
if (age >= 24 && age <= 40) {
breakdown.age = weights.ageSweetSpot;
total += weights.ageSweetSpot;
} else if (age >= 21 && age <= 45) {
breakdown.age = weights.ageOk;
total += weights.ageOk;
} else {
breakdown.age = weights.ageOutOfRange;
total += weights.ageOutOfRange;
}
}
// They liked you
if (profile.interactionStatus?.theirs === 'LIKED') {
breakdown.theyLikedYou = weights.theyLikedYou;
total += weights.theyLikedYou;
}
// Connection desires (check desires array for relationship-oriented ones)
const desireStrings = desires.map(d => typeof d === 'string' ? d : '');
const matchingDesires = desireStrings.filter(d =>
CONNECTION_DESIRE_KEYWORDS.some(kw => d.toUpperCase().includes(kw.toUpperCase()))
);
if (matchingDesires.length >= 2) {
breakdown.connectionDesires = weights.connectionDesires;
total += weights.connectionDesires;
}
return { total, breakdown };
}
export { DEFAULT_WEIGHTS, scoreProfile, safeStr };

View File

@@ -13,11 +13,20 @@ import { ChatPage } from './pages/Chat';
import { ProfilePage } from './pages/Profile';
import { SettingsPage } from './pages/Settings';
import { SentPingsPage } from './pages/SentPings';
import { MatchesPage } from './pages/Matches';
import { ApiExplorerPage } from './pages/ApiExplorer';
import { OkCupidPage } from './pages/OkCupid';
import { useEffect, useState, useRef } from 'react';
import { initialSync } from './api/dataSync';
import { authManager } from './api/auth';
// One-time credential reset (v2) — remove this block after it runs once
if (!localStorage.getItem('_cred_reset_v2')) {
localStorage.removeItem('feeld_refresh_token');
localStorage.removeItem('feeld_auth_token');
localStorage.setItem('_cred_reset_v2', '1');
}
// Prevent browser tab discarding by keeping minimal activity
function usePreventTabDiscard() {
const intervalRef = useRef<number | null>(null);
@@ -148,6 +157,8 @@ function AuthenticatedApp() {
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="sent-pings" element={<SentPingsPage />} />
<Route path="matches" element={<MatchesPage />} />
<Route path="okcupid" element={<OkCupidPage />} />
<Route path="api-explorer" element={<ApiExplorerPage />} />
</Route>
</Routes>

View File

@@ -1,20 +1,10 @@
import { API_CONFIG, getCredentials } from '../config/constants';
interface TokenResponse {
access_token: string;
expires_in: string;
token_type: string;
refresh_token: string;
id_token: string;
user_id: string;
project_id: string;
}
import { getCredentials } from '../config/constants';
export interface AuthStatus {
isAuthenticated: boolean;
expiresAt: number | null;
expiresIn: number | null; // seconds until expiry
accessToken: string | null; // full token
expiresIn: number | null;
accessToken: string | null;
lastError: string | null;
}
@@ -25,18 +15,15 @@ class AuthManager {
private listeners: Set<() => void> = new Set();
private initPromise: Promise<void> | null = null;
private isReady: boolean = false;
private profileId: string | null = null;
private analyticsId: string | null = null;
// Ensure token is ready before any queries
async ensureReady(): Promise<boolean> {
if (this.isReady && this.accessToken) {
return true;
}
if (this.isReady && this.accessToken) return true;
if (!this.initPromise) {
this.initPromise = this.refresh()
.then(() => {
this.isReady = true;
})
.then(() => { this.isReady = true; })
.catch((err) => {
console.error('Initial token fetch failed:', err);
this.isReady = false;
@@ -56,51 +43,22 @@ class AuthManager {
}
private async refresh(): Promise<void> {
const creds = getCredentials();
const url = `${API_CONFIG.FIREBASE_TOKEN_URL}?key=${API_CONFIG.FIREBASE_API_KEY}`;
console.log('Refreshing token at:', url);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
grantType: 'refresh_token',
refreshToken: creds.REFRESH_TOKEN,
}),
});
// Ask the backend for a fresh access token — it manages the refresh token
const resp = await fetch('/api/auth/token');
if (!response.ok) {
const error = await response.text();
console.error('Token refresh failed:', response.status, error);
this.lastError = `HTTP ${response.status}: ${error}`;
this.notifyListeners();
throw new Error(`Failed to refresh token: ${error}`);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
const data: TokenResponse = await response.json();
this.accessToken = data.access_token;
this.expiresAt = Date.now() + parseInt(data.expires_in) * 1000;
const data = await resp.json();
this.accessToken = data.accessToken;
this.expiresAt = data.expiresAt || (Date.now() + 3500000); // ~58 min fallback
this.profileId = data.profileId || null;
this.analyticsId = data.analyticsId || null;
this.lastError = null;
console.log('Token refreshed, expires in:', data.expires_in, 'seconds');
// Seed the backend with updated refresh token for rotation cron
try {
const creds = getCredentials();
fetch('/api/location-rotation/seed-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refreshToken: creds.REFRESH_TOKEN,
profileId: creds.PROFILE_ID,
analyticsId: creds.EVENT_ANALYTICS_ID,
}),
}).catch(() => {}); // Best-effort, don't block auth flow
} catch (e) {}
this.notifyListeners();
} catch (err) {
this.lastError = err instanceof Error ? err.message : 'Unknown error';
@@ -109,15 +67,19 @@ class AuthManager {
}
}
// Force a token refresh (useful after updating credentials)
async forceRefresh(): Promise<void> {
this.accessToken = null;
this.expiresAt = 0;
this.initPromise = null;
await this.refresh();
}
getProfileId(): string {
return getCredentials().PROFILE_ID;
return this.profileId || getCredentials().PROFILE_ID;
}
getAnalyticsId(): string {
return this.analyticsId || getCredentials().EVENT_ANALYTICS_ID;
}
isAuthenticated(): boolean {
@@ -127,7 +89,6 @@ class AuthManager {
getStatus(): AuthStatus {
const now = Date.now();
const expiresIn = this.expiresAt > 0 ? Math.floor((this.expiresAt - now) / 1000) : null;
return {
isAuthenticated: this.isAuthenticated(),
expiresAt: this.expiresAt > 0 ? this.expiresAt : null,
@@ -137,7 +98,6 @@ class AuthManager {
};
}
// Subscribe to auth status changes
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);

View File

@@ -1,14 +1,13 @@
import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink } from '@apollo/client/core';
import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink, Observable } from '@apollo/client/core';
import { onError } from '@apollo/client/link/error';
import { setContext } from '@apollo/client/link/context';
import { API_CONFIG, REQUEST_HEADERS, TEST_CREDENTIALS } from '../config/constants';
import { API_CONFIG, REQUEST_HEADERS } from '../config/constants';
import { authManager } from './auth';
// UUID generator that works in non-secure contexts (HTTP)
function generateUUID(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for non-secure contexts
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
@@ -20,47 +19,93 @@ const httpLink = createHttpLink({
uri: API_CONFIG.GRAPHQL_ENDPOINT,
});
// Auth link that adds exact headers from Proxyman capture
const authLink = setContext(async (operation, { headers }) => {
console.log('GraphQL operation:', operation.operationName);
let token: string;
try {
token = await authManager.getToken();
console.log('Got auth token, length:', token?.length);
} catch (err) {
console.error('Failed to get auth token:', err);
throw err;
}
// Exact headers from Proxyman capture
const newHeaders = {
...headers,
// From Proxyman: Host header (handled by browser)
'Accept': REQUEST_HEADERS['Accept'],
'Accept-Language': REQUEST_HEADERS['Accept-Language'],
'Accept-Encoding': REQUEST_HEADERS['Accept-Encoding'],
'Content-Type': REQUEST_HEADERS['Content-Type'],
'Connection': REQUEST_HEADERS['Connection'],
'User-Agent': REQUEST_HEADERS['User-Agent'],
// Auth headers
'Authorization': `Bearer ${token}`,
'x-profile-id': TEST_CREDENTIALS.PROFILE_ID,
// App identification headers - exact from Proxyman
'x-device-os': REQUEST_HEADERS['x-device-os'],
'x-app-version': REQUEST_HEADERS['x-app-version'],
'x-os-version': REQUEST_HEADERS['x-os-version'],
// Transaction headers
'x-transaction-id': generateUUID(),
'x-event-analytics-id': TEST_CREDENTIALS.EVENT_ANALYTICS_ID,
return {
headers: {
...headers,
'Accept': REQUEST_HEADERS['Accept'],
'Accept-Language': REQUEST_HEADERS['Accept-Language'],
'Accept-Encoding': REQUEST_HEADERS['Accept-Encoding'],
'Content-Type': REQUEST_HEADERS['Content-Type'],
'Connection': REQUEST_HEADERS['Connection'],
'User-Agent': REQUEST_HEADERS['User-Agent'],
'Authorization': `Bearer ${token}`,
'x-profile-id': authManager.getProfileId(),
'x-device-os': REQUEST_HEADERS['x-device-os'],
'x-app-version': REQUEST_HEADERS['x-app-version'],
'x-os-version': REQUEST_HEADERS['x-os-version'],
'x-transaction-id': generateUUID(),
'x-event-analytics-id': authManager.getAnalyticsId(),
},
};
});
console.log('Request headers:', Object.keys(newHeaders));
return { headers: newHeaders };
// Auto-retry on UNAUTHENTICATED errors: force-refresh token and replay the request
let isRefreshing = false;
let pendingRetries: Array<() => void> = [];
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
const isAuthError = graphQLErrors?.some(
(e) => e.extensions?.code === 'UNAUTHENTICATED'
);
if (!isAuthError) return;
if (isRefreshing) {
// Another refresh is in progress — queue this request to retry after
return new Observable((observer) => {
pendingRetries.push(() => {
const subscriber = forward(operation).subscribe(observer);
return () => subscriber.unsubscribe();
});
});
}
isRefreshing = true;
return new Observable((observer) => {
authManager
.forceRefresh()
.then(async () => {
// Update this operation's headers with the new token
const token = await authManager.getToken();
const oldHeaders = operation.getContext().headers;
operation.setContext({
headers: {
...oldHeaders,
'Authorization': `Bearer ${token}`,
'x-transaction-id': generateUUID(),
},
});
isRefreshing = false;
// Retry all queued requests
pendingRetries.forEach((cb) => cb());
pendingRetries = [];
// Retry this request
const subscriber = forward(operation).subscribe(observer);
return () => subscriber.unsubscribe();
})
.catch((err) => {
isRefreshing = false;
pendingRetries = [];
observer.error(err);
});
});
});
export const apolloClient = new ApolloClient({
link: ApolloLink.from([authLink, httpLink]),
link: ApolloLink.from([errorLink, authLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
Profile: {

251
web/src/api/okcupid.ts Normal file
View File

@@ -0,0 +1,251 @@
// OKCupid API integration
// Uses JWT bearer token auth, proxied through Vite to bypass Cloudflare
const DEFAULT_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJjb3JlYXBpIiwiYXVkIjoiY29yZWFwaSIsInBsYXRmb3JtSWQiOjExMSwic2Vzc2lvbklkIjoiZjYwYjY3NTAtZTRkMC00ZmViLTg2MzEtNDk3OTVkMWViN2Y5Iiwic2l0ZUNvZGUiOjM2LCJTZXJ2ZXJJZCI6NzksInZlciI6MTIsImlzc1NyYyI6MTAsImVudiI6MSwic2NvcGUiOlsyXSwiYXV0aF90aW1lIjoxNzU3MzM0NTk4LCJpYXQiOjE3NzQ3NTc3NzgsImV4cCI6MTc3NDc2MDQ3OCwic3ViIjoidXZNbGtiYXF6N0Q3VFNqSG91YlE1ZzIiLCJ1cmxDb2RlIjoiMTg2IiwicmVnVXJsQ29kZSI6IjE4NiJ9.X1RwfV7A8aDq6gVXr9IVLIFTQwtlyiQRogWKJwB1Wqc';
export function getOkcToken(): string {
return localStorage.getItem('okc_token') || DEFAULT_TOKEN;
}
// Try to load token from backend on first use
let _tokenLoaded = false;
async function ensureToken(): Promise<string> {
const local = localStorage.getItem('okc_token');
if (local) return local;
if (!_tokenLoaded) {
_tokenLoaded = true;
try {
const resp = await fetch('/api/okcupid/token');
const data = await resp.json();
if (data.token) {
// Backend only returns a preview — we need the full token saved there
// Actually check if backend has full token by trying a query
}
} catch (e) {}
}
return DEFAULT_TOKEN;
}
export function setOkcToken(token: string): void {
localStorage.setItem('okc_token', token);
// Also save to backend so it persists across sessions
fetch('/api/okcupid/token', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
}).catch(() => {});
}
// ─── Token refresh ──────────────────────────────────────────────────────────
async function refreshOkcToken(): Promise<string | null> {
const currentToken = getOkcToken();
try {
const res = await fetch('/api/okcupid/graphql/authTokenRefresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-okc-token': currentToken },
body: JSON.stringify({
operationName: 'authTokenRefresh',
query: 'mutation authTokenRefresh($token: String!) { authTokenRefresh(token: $token) { token loginDisabled } }',
variables: { token: currentToken },
}),
});
if (!res.ok) return null;
const json = await res.json();
const newToken = json?.data?.authTokenRefresh?.token;
if (newToken) {
setOkcToken(newToken);
return newToken;
}
} catch (e) {}
return null;
}
// ─── Core query helper ───────────────────────────────────────────────────────
export async function okcQuery(
operationName: string,
query: string,
variables: Record<string, unknown> = {},
): Promise<any> {
const token = getOkcToken();
const res = await fetch(`/api/okcupid/graphql/${operationName}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-okc-token': token,
},
body: JSON.stringify({ operationName, query, variables }),
});
if (res.status === 401) {
throw new Error('OKCupid token expired. Paste a new JWT in the Profile tab.');
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`OKC API ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
if (json.errors?.length) {
const errCode = json.errors[0]?.extensions?.code;
// Auto-refresh on expired token
if (errCode === 'TOKEN_EXPIRED' || errCode === 'UNAUTHENTICATED') {
const newToken = await refreshOkcToken();
if (newToken) {
// Retry with new token
const retry = await fetch(`/api/okcupid/graphql/${operationName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-okc-token': newToken },
body: JSON.stringify({ operationName, query, variables }),
});
if (retry.ok) {
const retryJson = await retry.json();
if (!retryJson.errors?.length) return retryJson.data;
}
}
throw new Error('OKCupid token expired. Paste a new JWT in the Profile tab.');
}
throw new Error(`OKC GraphQL: ${json.errors[0].message}`);
}
return json.data;
}
// ─── Query strings (full, from captured operations) ──────────────────────────
const QUERIES = {
MainSessionQuery: `query MainSessionQuery($experimentNames: [String]!) { session { __typename ...SessionDataFragment } me { __typename ...SessionUserFragment ...ApolloNotificationCounts ...CrossSellDataFragmentV2 } }\nfragment ApolloNotificationCounts on User { __typename notificationCounts { __typename messages likesIncoming likesMutual likesAndViews intros } }\nfragment CrossSellDataFragmentV2 on User { __typename id photos { __typename id original caption width height } xMatchFields { __typename astrologicalSign bio birthday bodyType drinking education firstName genderPresentation genderReductive hasKids heightInCm highestEducation isPayer jobCompany jobTitle languagesSpoken lookingFor marijuana pets preferences { __typename age { __typename max min } bodyType distanceInKilometers drinking ethnicity hasKids heightInCm { __typename max min } highestEducation marijuana pets politics relationshipStatus religion sexualRole smoking wantsKids } registrationDate relationshipIntent relationshipStatus relationshipType sensitiveFields { __typename ethnicity location { __typename latitude longitude } politics preferences { __typename genderSeeking } religion sexualRole } smoking wantsKids work } }\nfragment GateKeeperChecksFragment on Session { __typename gatekeeperChecks { __typename APP_FORCE_UPDATE ONBOARDING_MANDATORY_REDIRECT TERMS_MANDATORY_REDIRECT SMS_MANDATORY_REDIRECT BLOCK_PERSONALIZED_MARKETING HAS_PHONE SMS_KILL_SWITCH NEEDS_DETAILS_REBOARDING IDENTITY_TAGS_QUALIFIES JEWISH_RELIGIOUS_IDENTITY_ATTRIBUTES_QUALIFIES INCOGNITO_TERMED_MANDATORY_REDIRECT } }\nfragment SessionDataFragment on Session { __typename guestId ipCountry isInEU isAppsConsentKillswitchEnabled additionalPolicies traceSampleRate ...GateKeeperChecksFragment experiments(names: $experimentNames) { __typename group } }\nfragment SessionUserFragment on User { __typename id displayname age emailAddress binaryGenderLetter orientations relationshipType unitPreference userLocation { __typename publicName } primaryImage { __typename square800 } boostTokenCount superlikeTokenCount rewindTokenCount learnMyTypeLikeCount isIncognito hasMetPhotoRequirements hasSeenSwipingTutorial: hasSeenUserGuide(feature: USER_SWIPING_TUTORIAL) joinDate ...SubscriptionInfo globalPreferences { __typename gender { __typename values } } selfieVerifiedStatus(shouldReturnStatus: true) isPurchaseBanned }\nfragment SubscriptionFeature on FeatureSubscription { __typename timeOfActualLoss wasEverActive isActive }\nfragment SubscriptionInfo on User { __typename unlimitedLikesSubscriptionFeature: featureSubscription(feature: UNLIMITED_LIKES) { __typename ...SubscriptionFeature } seeWhoLikesYouSubscriptionFeature: featureSubscription( feature: SEE_WHO_LIKES_YOU ) { __typename ...SubscriptionFeature } ALIST_BASIC: hasPremium(name: ALIST_BASIC) ALIST_PREMIUM: hasPremium(name: ALIST_PREMIUM) ALIST_PREMIUM_PLUS: hasPremium(name: ALIST_PREMIUM_PLUS) INCOGNITO: hasPremium(name: INCOGNITO_BUNDLE) }`,
PublicProfile: `query PublicProfile($userId: ID!, $userIdString: String!, $conversationThreadLimit: Int) { me { __typename id ...SelfProfile match(id: $userIdString) { __typename ...ApolloBaseUser user { __typename id ...ProfileDetailsAndPreferences ...ProfileEssays ...PhotosFragment } } } }\nfragment ApolloBaseUser on Match { __typename user { __typename id displayname age userLocation { __typename publicName } primaryImage { __typename square800 } isOnline } matchPercent senderVote senderMessageTime senderBlocked targetVote targetLikes targetViewedMe likeTime targetLikeViaSpotlight targetLikeViaSuperBoost targetMessageTime firstMessage { __typename id text time threadId attachments { __typename ...AttachmentFragment } } }\nfragment ApolloEssay on Essay { __typename id title groupTitle groupId isActive isPassion processedContent rawContent placeholder picture { __typename id square800 } }\nfragment AttachmentFragment on Attachment { __typename ... on ProfileCommentPhoto { __typename photo { __typename id original } } ... on ProfileCommentEssay { __typename essayTitle essayText } }\nfragment PhotosFragment on User { __typename photos { __typename id original square225 square800 caption width height crop { __typename upperLeftX upperLeftY lowerRightX lowerRightY } } }\nfragment ProfileDetailsAndPreferences on User { __typename badges { __typename name } bodyType children relationshipStatus relationshipType drinking pets weed ethnicity smoking politics height astrologicalSign diet knownLanguages genders orientations pronounCategory customPronouns identityTags occupation { __typename title employer status } education { __typename level school { __typename name } } religion { __typename value modifier } shabbatRoutine kosherHabits religiousBackground globalPreferences { __typename relationshipType { __typename values } connectionType { __typename values } gender { __typename values } } selfieVerifiedStatus(shouldReturnStatus: true) }\nfragment ProfileEssays on User { __typename essaysWithUniqueIds { __typename ...ApolloEssay } }\nfragment SelfProfile on User { __typename isIncognito unitPreference conversationThread(targetId: $userId, limit: $conversationThreadLimit) { __typename canMessage messages { __typename senderId text attachments { __typename ...AttachmentFragment } } } match(id: $userIdString) { __typename senderIsVisibleThroughIncognito } }`,
NotificationBatch: `query NotificationBatch { me { __typename notificationCounts { __typename likesMutual messages } matches: conversationsAndMatches(filter: MATCHES, limit: 1) { __typename data { __typename ... on MutualMatch { __typename match { __typename user { __typename ...BasicUserInfo } } } } } messages: conversationsAndMatches(filter: REPLIES, limit: 1) { __typename data { __typename ... on Conversation { __typename correspondent { __typename user { __typename ...BasicUserInfo } } } } } } }\nfragment BasicUserInfo on User { __typename id displayname primaryImage { __typename square225 } }`,
LikesIncomingPage: `query LikesIncomingPage($nextPageKey: String, $limit: Int, $sort: LikesListSort, $includeViews: Boolean! = false) { me { __typename likesIncomingWithPreviews( after: $nextPageKey limit: $limit sort: $sort includeViews: $includeViews ) { __typename data { __typename ...ApolloBaseUser ...ApolloPreviewUser ...UserMatchHighlightsFragment ...PreviewMatchHighlightsFragment } pageInfo { __typename ...ApolloPaging } } promosForPage(page: LIKES_INCOMING) { __typename ...ApolloPromo } } }\nfragment ApolloBaseUser on Match { __typename user { __typename id displayname age userLocation { __typename publicName } primaryImage { __typename square800 } isOnline } matchPercent senderVote senderMessageTime senderBlocked targetVote targetLikes targetViewedMe likeTime targetLikeViaSpotlight targetLikeViaSuperBoost targetMessageTime firstMessage { __typename id text time threadId attachments { __typename ...AttachmentFragment } } }\nfragment ApolloPaging on PageInfo { __typename before after hasMore total }\nfragment ApolloPreviewUser on MatchPreview { __typename primaryImage { __typename square800 } primaryImageBlurred { __typename square800 } hasFirstMessage targetSuperlikes targetViewedMe matchHighlights { __typename ...MatchHighlightsFragment } }\nfragment ApolloPromo on Promo { __typename id name type upsellType featureType }\nfragment AttachmentFragment on Attachment { __typename ... on ProfileCommentPhoto { __typename photo { __typename id original } } ... on ProfileCommentEssay { __typename essayTitle essayText } }\nfragment MatchHighlightsFragment on MatchHighlights { __typename age matchScore isOnline isVerified hasIntroMessage dynamicHighlight { __typename ... on RelationshipIntentHighlight { __typename sharedIntents } ... on LocationHighlight { __typename summary } } }\nfragment PreviewMatchHighlightsFragment on MatchPreview { __typename matchHighlights { __typename ...MatchHighlightsFragment } }\nfragment UserMatchHighlightsFragment on Match { __typename matchHighlights { __typename ...MatchHighlightsFragment } }`,
MessagesAndMatches: `query MessagesAndMatches($nextPageKey: String, $filter: ConversationsAndMatchesFilter!) { me { __typename conversationsAndMatches(filter: $filter, after: $nextPageKey) { __typename data { __typename ... on Conversation { __typename ...ApolloConversationRow } ... on MutualMatch { __typename status isUnread match { __typename ...ConversationCorrespondent } } } pageInfo { __typename ...ApolloPaging } } } }\nfragment ApolloConversationRow on Conversation { __typename correspondent { __typename ...ConversationCorrespondent } attachmentPreviews { __typename ... on GifAttachmentPreview { __typename id } ... on ReactionUpdate { __typename reaction originalMessage updateType } } snippet { __typename text sender { __typename id } } time isUnread threadid status }\nfragment ApolloPaging on PageInfo { __typename before after hasMore total }\nfragment ConversationCorrespondent on Match { __typename likeTime senderVote targetVote targetLikeViaSpotlight targetLikeViaSuperBoost user { __typename id displayname primaryImage { __typename square225 } isOnline } }`,
conversationThread: `query conversationThread($targetId: ID!, $limit: Int, $before: String) { me { __typename conversationThread(targetId: $targetId, limit: $limit, before: $before) { __typename id status canMessage pageInfo { __typename ...ApolloPaging } correspondent { __typename senderVote targetVote targetLikeViaSpotlight targetLikeViaSuperBoost matchPercent user { __typename id displayname isOnline primaryImage { __typename square225 } } } messages { __typename id senderId threadId text time attachments { __typename ...AttachmentFragment } readTime } isReadReceiptActivated } } }\nfragment ApolloPaging on PageInfo { __typename before after hasMore total }\nfragment AttachmentFragment on Attachment { __typename ... on ProfileCommentPhoto { __typename photo { __typename id original } } ... on ProfileCommentEssay { __typename essayTitle essayText } }`,
ConversationSend: `mutation ConversationSend($input: ConversationMessageSendInput!) { conversationMessageSend(input: $input) { __typename success nway messageId threadId adTrigger } }`,
UserVote: `mutation UserVote($input: UserVoteInput!) { userVote(input: $input) { __typename likesRemaining success } }`,
StacksMenu: `query StacksMenu { me { __typename id stacks { __typename ...StackFragment } hasSeenSwipingTutorial: hasSeenUserGuide(feature: USER_SWIPING_TUTORIAL) } }\nfragment Highlight on ProfileHighlight { __typename ... on PhotoHighlight { __typename id url caption } }\nfragment StackFragment on Stack { __typename id status emptyStateStatus badge data { __typename ... on StackMatch { __typename stream targetLikesSender hasSuperlikeRecommendation profileHighlights { __typename ...Highlight } match { __typename user { __typename id } } } ... on FirstPartyAd { __typename id } ... on ThirdPartyAd { __typename ad } ... on PromotedQuestionPrompt { __typename promotedQuestionId } } }`,
Stack: `query Stack($stackId: StackTypes!, $excludedUserIds: [String]!, $usersRemaining: Int!) { me { __typename stack( id: $stackId excludedUserIds: $excludedUserIds usersRemaining: $usersRemaining shouldReturnStatusForSelfieVerification: true ) { __typename ...StackWithStackMatchesFragment } } }\nfragment ApolloBaseUser on Match { __typename user { __typename id displayname age userLocation { __typename publicName } primaryImage { __typename square800 } isOnline } matchPercent senderVote senderMessageTime senderBlocked targetVote targetLikes targetViewedMe likeTime targetLikeViaSpotlight targetLikeViaSuperBoost targetMessageTime firstMessage { __typename id text time threadId attachments { __typename ...AttachmentFragment } } }\nfragment ApolloEssay on Essay { __typename id title groupTitle groupId isActive isPassion processedContent rawContent placeholder picture { __typename id square800 } }\nfragment AttachmentFragment on Attachment { __typename ... on ProfileCommentPhoto { __typename photo { __typename id original } } ... on ProfileCommentEssay { __typename essayTitle essayText } }\nfragment Highlight on ProfileHighlight { __typename ... on PhotoHighlight { __typename id url caption } }\nfragment PhotosFragment on User { __typename photos { __typename id original square225 square800 caption width height crop { __typename upperLeftX upperLeftY lowerRightX lowerRightY } } }\nfragment StackMatchFragment on Match { __typename ...ApolloBaseUser user { __typename selfieVerifiedStatus(shouldReturnStatus: true) essaysWithUniqueIds { __typename ...ApolloEssay } ...PhotosFragment badges { __typename name } } }\nfragment StackWithStackMatchesFragment on Stack { __typename id status emptyStateStatus badge data { __typename ... on StackMatch { __typename stream targetLikesSender hasSuperlikeRecommendation profileHighlights { __typename ...Highlight } match { __typename user { __typename id } ...StackMatchFragment } } ... on FirstPartyAd { __typename id } ... on ThirdPartyAd { __typename ad } ... on PromotedQuestionPrompt { __typename promotedQuestionId } } }`,
StackMatches: `query StackMatches($userIds: [String!]!) { me { __typename matches(ids: $userIds) { __typename ...StackMatchFragment } } }\nfragment ApolloBaseUser on Match { __typename user { __typename id displayname age userLocation { __typename publicName } primaryImage { __typename square800 } isOnline } matchPercent senderVote senderMessageTime senderBlocked targetVote targetLikes targetViewedMe likeTime targetLikeViaSpotlight targetLikeViaSuperBoost targetMessageTime firstMessage { __typename id text time threadId attachments { __typename ...AttachmentFragment } } }\nfragment ApolloEssay on Essay { __typename id title groupTitle groupId isActive isPassion processedContent rawContent placeholder picture { __typename id square800 } }\nfragment AttachmentFragment on Attachment { __typename ... on ProfileCommentPhoto { __typename photo { __typename id original } } ... on ProfileCommentEssay { __typename essayTitle essayText } }\nfragment PhotosFragment on User { __typename photos { __typename id original square225 square800 caption width height crop { __typename upperLeftX upperLeftY lowerRightX lowerRightY } } }\nfragment StackMatchFragment on Match { __typename ...ApolloBaseUser user { __typename selfieVerifiedStatus(shouldReturnStatus: true) essaysWithUniqueIds { __typename ...ApolloEssay } ...PhotosFragment badges { __typename name } } }`,
LikesCapInfo: `query LikesCapInfo { me { __typename likesCap { __typename ...LikesCapFragment } } }\nfragment LikesCapFragment on LikesCap { __typename likesCapTotal likesRemaining viewCount resetTime }`,
UserViewedUsers: `mutation UserViewedUsers($input: UserViewedUsersInput!) { userViewedUsers(input: $input) { __typename success } }`,
};
// ─── Exported API functions ──────────────────────────────────────────────────
export async function getMainSession() {
return okcQuery('MainSessionQuery', QUERIES.MainSessionQuery, {
experimentNames: [
'cbmPivotPhase2',
'iOS.cbmpivot.2485',
'iOS.LearnMyType.2497',
'okQuizzyAd',
'iOS.IAPV3',
'testExperiment',
],
});
}
export async function getProfile(userId: string) {
return okcQuery('PublicProfile', QUERIES.PublicProfile, {
userId,
userIdString: userId,
conversationThreadLimit: 2,
});
}
export async function getNotifications() {
return okcQuery('NotificationBatch', QUERIES.NotificationBatch);
}
export async function getLikesIncoming(limit = 10, nextPageKey: string | null = null) {
return okcQuery('LikesIncomingPage', QUERIES.LikesIncomingPage, {
includeViews: true,
limit,
nextPageKey,
sort: 'LIKES_VIEWS_GLOBAL',
});
}
export async function getRealLikesCount() {
const data = await okcQuery('LikesIncomingPage', QUERIES.LikesIncomingPage, {
includeViews: false,
limit: 1,
nextPageKey: null,
sort: 'LIKES_VIEWS_GLOBAL',
});
return data?.me?.likesIncomingWithPreviews?.pageInfo?.total || 0;
}
export async function getMessages(filter: string = 'ALL', nextPageKey: string | null = null) {
return okcQuery('MessagesAndMatches', QUERIES.MessagesAndMatches, {
filter,
nextPageKey,
});
}
export async function getConversation(targetId: string, limit = 30) {
return okcQuery('conversationThread', QUERIES.conversationThread, {
targetId,
limit,
before: null,
});
}
export async function sendMessage(targetId: string, text: string) {
return okcQuery('ConversationSend', QUERIES.ConversationSend, {
input: { targetId, text },
});
}
export async function vote(targetId: string, voteType: 'LIKE' | 'PASS', userMetadata?: string) {
return okcQuery('UserVote', QUERIES.UserVote, {
input: {
votes: [
{
targetId,
vote: voteType,
voteSource: 'DOUBLETAKE',
...(userMetadata ? { userMetadata } : {}),
},
],
},
});
}
export async function getStacks() {
return okcQuery('StacksMenu', QUERIES.StacksMenu);
}
export async function getStack(stackId: string, excludedUserIds: string[] = []) {
return okcQuery('Stack', QUERIES.Stack, {
stackId,
excludedUserIds,
usersRemaining: 0,
});
}
export async function getStackMatches(userIds: string[]) {
return okcQuery('StackMatches', QUERIES.StackMatches, { userIds });
}
export async function getLikesCapInfo() {
return okcQuery('LikesCapInfo', QUERIES.LikesCapInfo);
}
export async function markUsersViewed(targetIds: string[]) {
return okcQuery('UserViewedUsers', QUERIES.UserViewedUsers, {
input: { targetIds },
});
}

View File

@@ -1,7 +1,6 @@
import { gql } from '@apollo/client/core';
// Experimental queries to discover hidden API endpoints
// Based on patterns: whoLikesMe, whoPingsMe -> try whoILiked, whoIPinged, myLikes, etc.
// Real endpoints discovered in v8.11.0 - replaces old wrong guesses
export const LIKES_PROFILE_FRAGMENT = gql`
fragment LikesProfileFragment on Profile {
@@ -55,198 +54,25 @@ export const LIKES_PROFILE_FRAGMENT = gql`
}
`;
// Attempt 1: whoILiked (mirror of whoLikesMe)
export const WHO_I_LIKED_QUERY = gql`
// pastLikes - profiles you've liked (new in v8.11.0)
export const PAST_LIKES_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query WhoILiked($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: whoILiked(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
query pastLikes($cursor: String, $input: PastLikesQueryInput!, $limit: Int) {
pastLikes(cursor: $cursor, input: $input, limit: $limit) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 2: myLikes
export const MY_LIKES_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query MyLikes($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: myLikes(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 3: sentLikes
export const SENT_LIKES_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query SentLikes($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: sentLikes(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 4: likedProfiles
export const LIKED_PROFILES_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query LikedProfiles($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: likedProfiles(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 5: profilesILiked
export const PROFILES_I_LIKED_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query ProfilesILiked($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: profilesILiked(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 6: outgoingLikes (opposite of incoming likes)
export const OUTGOING_LIKES_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query OutgoingLikes($limit: Int, $cursor: String, $sortBy: SortBy!) {
interactions: outgoingLikes(
input: {sortBy: $sortBy}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 7: Try interactions query with direction parameter
export const INTERACTIONS_OUTGOING_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query InteractionsOutgoing($limit: Int, $cursor: String, $sortBy: SortBy!, $direction: String) {
interactions(
input: {sortBy: $sortBy, direction: $direction}
limit: $limit
cursor: $cursor
) {
nodes {
...LikesProfileFragment
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
// Attempt 8: Try whoLikesMe with a filter for mine=LIKED
export const FILTERED_WHO_I_LIKED_MUTATION = gql`
${LIKES_PROFILE_FRAGMENT}
mutation FilteredWhoILiked($input: FilteredInteractionInput!, $cursor: String) {
filteredWhoILiked(input: $input, cursor: $cursor) {
filters {
ageRange
desires
lookingFor
sexualities
__typename
}
profiles {
nodes {
isPing
interactionSentAt
profile {
...LikesProfileFragment
__typename
}
pageInfo {
total
unfilteredTotal
hasNextPage
nextPageCursor
__typename
}
__typename
}
pageInfo {
hasNextPage
nextPageCursor
total
unfilteredTotal
__typename
}
__typename
@@ -254,47 +80,27 @@ export const FILTERED_WHO_I_LIKED_MUTATION = gql`
}
`;
// Direct profile lookup query - to test if profile IDs from WhoLikesMe return real data
export const DIRECT_PROFILE_LOOKUP_QUERY = gql`
query DirectProfileLookup($profileId: String!) {
profile(id: $profileId) {
id
age
gender
sexuality
imaginaryName
bio
desires
connectionGoals
verificationStatus
distance {
km
mi
__typename
}
photos {
id
pictureUrl
pictureUrls {
small
medium
large
// pastDislikes - profiles you've passed (new in v8.11.0)
export const PAST_DISLIKES_QUERY = gql`
${LIKES_PROFILE_FRAGMENT}
query pastDislikes($cursor: String, $input: PastDislikesQueryInput!, $limit: Int) {
pastDislikes(cursor: $cursor, input: $input, limit: $limit) {
nodes {
interactionSentAt
profile {
...LikesProfileFragment
__typename
}
publicId
__typename
}
pageInfo {
hasNextPage
nextPageCursor
total
unfilteredTotal
__typename
}
__typename
}
}
`;
// List of all experimental queries to try
export const EXPERIMENTAL_QUERIES = [
{ name: 'whoILiked', query: WHO_I_LIKED_QUERY },
{ name: 'myLikes', query: MY_LIKES_QUERY },
{ name: 'sentLikes', query: SENT_LIKES_QUERY },
{ name: 'likedProfiles', query: LIKED_PROFILES_QUERY },
{ name: 'profilesILiked', query: PROFILES_I_LIKED_QUERY },
{ name: 'outgoingLikes', query: OUTGOING_LIKES_QUERY },
];

View File

@@ -294,3 +294,417 @@ export const PROFILE_DISLIKE_MUTATION = gql`
profileDislike(input: { targetProfileId: $targetProfileId })
}
`;
// === New mutations discovered via API probing + APK analysis (v8.11.0) ===
// Location mutations
export const PROFILE_LOCATION_UPDATE_MUTATION = gql`
mutation ProfileLocationUpdate($input: ProfileLocationInput!) {
profileLocationUpdate(input: $input) {
location {
... on DeviceLocation {
device {
latitude
longitude
geocode {
city
country
__typename
}
__typename
}
__typename
}
... on TeleportLocation {
teleport {
latitude
longitude
geocode {
city
country
__typename
}
__typename
}
__typename
}
... on VirtualLocation {
core
__typename
}
__typename
}
__typename
}
}
`;
// Interaction mutations
export const PROFILE_BLOCK_MUTATION = gql`
mutation ProfileBlock($input: ProfileBlockInteractionInput!) {
profileBlock(input: $input)
}
`;
export const PROFILE_REPORT_MUTATION = gql`
mutation ProfileReport($input: ProfileReportInteractionInput!) {
profileReport(input: $input)
}
`;
export const PROFILE_ACCEPT_PING_MUTATION = gql`
mutation ProfileAcceptPing($targetProfileId: String!) {
profileAcceptPing(input: { targetProfileId: $targetProfileId }) {
status
chat {
id
name
type
streamChatId
status
members {
id
status
imaginaryName
streamUserId
age
gender
sexuality
photos {
id
pictureUrl
pictureUrls {
small
medium
large
__typename
}
__typename
}
__typename
}
__typename
}
__typename
}
}
`;
export const PROFILE_REJECT_PING_MUTATION = gql`
mutation ProfileRejectPing($targetProfileId: String!) {
profileRejectPing(input: { targetProfileId: $targetProfileId })
}
`;
export const UNDO_PROFILE_DISLIKE_MUTATION = gql`
mutation UndoProfileDislike {
undoProfileDislike
}
`;
// Account mutations
export const SYNC_ACCOUNT_MUTATION = gql`
mutation SyncAccount {
syncAccount {
id
status
isMajestic
isUplift
availablePings
__typename
}
}
`;
export const ACCOUNT_REDEEM_OFFER_MUTATION = gql`
mutation RedeemAccountOffer($input: RedeemAccountOfferInput!) {
accountRedeemOffer(input: $input) {
redeemedOffers {
offerName
redeemedAt
__typename
}
__typename
}
}
`;
export const ACCOUNT_REDEEM_OFFER_WITH_REFLECTION_MUTATION = gql`
mutation AccountRedeemOfferWithReflection($input: RedeemAccountOfferInput!) {
accountRedeemOfferWithReflection(input: $input) {
availablePings
__typename
}
}
`;
// Chat mutations
export const DISCONNECT_FROM_CHAT_MUTATION = gql`
mutation DisconnectFromChat($input: ChatDisconnectInput!) {
disconnectFromChat(input: $input) {
chatId
__typename
}
}
`;
export const CHAT_ACTIVATE_MUTATION = gql`
mutation ChatActivate($input: ChatActivateInput!) {
chatActivate(input: $input) {
streamChatId
__typename
}
}
`;
export const CHAT_CREATE_MUTATION = gql`
mutation ChatCreate($input: ChatCreateInput!) {
chatCreate(input: $input) {
id
name
type
status
streamChannelId
__typename
}
}
`;
export const CHATS_MARK_READ_MUTATION = gql`
mutation ChatsMarkRead($input: ChatsMarkReadInput!) {
chatsMarkRead(input: $input)
}
`;
export const GROUP_CHAT_ADD_MEMBERS_MUTATION = gql`
mutation GroupChatAddMembers($sourceProfileId: String!, $chatId: String!, $targetProfileIds: [String!]!) {
groupChatAddMembers(
input: { chatId: $chatId, targetProfileIds: $targetProfileIds }
) {
id
streamChatId
name
type
status
members {
id
imaginaryName
__typename
}
__typename
}
}
`;
// Profile link mutations
export const PROFILE_LINK_CREATE_MUTATION = gql`
mutation ProfileLinkCreate($input: ProfileLinkCreateInput!) {
profileLinkCreate(input: $input) {
linkId
url
linkType
__typename
}
}
`;
export const PROFILE_LINK_DELETE_MUTATION = gql`
mutation ProfileLinkDelete($linkId: String!) {
profileLinkDelete(linkId: $linkId) {
linkId
__typename
}
}
`;
// Picture mutations
export const PICTURE_CREATE_MUTATION = gql`
mutation PictureCreate($input: PictureCreateInput!) {
pictureCreate(input: $input) {
id
publicId
pictureUrl
pictureUrls {
small
medium
large
__typename
}
pictureOrder
pictureType
__typename
}
}
`;
export const PICTURE_UPDATE_MUTATION = gql`
mutation PictureUpdate($input: PictureUpdateInput!) {
pictureUpdate(input: $input) {
id
publicId
pictureType
__typename
}
}
`;
export const PICTURE_DELETE_MUTATION = gql`
mutation PictureDelete($input: PictureDeleteInput!) {
pictureDelete(input: $input) {
id
__typename
}
}
`;
// Upload mutations
export const CLOUDINARY_GENERATE_UPLOAD_CREDENTIALS_MUTATION = gql`
mutation CloudinaryGenerateUploadCredentials($resourceType: ResourceType) {
cloudinaryGenerateUploadCredentials(resourceType: $resourceType) {
publicId
signature
timestamp
__typename
}
}
`;
export const CREATE_UPLOAD_URL_MUTATION = gql`
mutation CreateUploadUrl($input: CreateUploadUrlInput!) {
createUploadUrl(input: $input) {
mediaUploadUrl
__typename
}
}
`;
// Settings mutation
export const APP_SETTINGS_UPDATE_MUTATION = gql`
mutation AppSettingsUpdate(
$isDistanceInMiles: Boolean
$receiveMarketingNotifications: Boolean
$language: String
$receiveNewsEmailNotifications: Boolean
$receivePromotionsEmailNotifications: Boolean
$receiveNewsPushNotifications: Boolean
$receivePromotionsPushNotifications: Boolean
$receiveNewConnectionPushNotifications: Boolean
$receiveNewPingPushNotifications: Boolean
$receiveNewMessagePushNotifications: Boolean
$receiveNewLikePushNotifications: Boolean
) {
accountUpdate(
input: {
isDistanceInMiles: $isDistanceInMiles
receiveMarketingNotifications: $receiveMarketingNotifications
language: $language
receiveNewsEmailNotifications: $receiveNewsEmailNotifications
receivePromotionsEmailNotifications: $receivePromotionsEmailNotifications
receiveNewsPushNotifications: $receiveNewsPushNotifications
receivePromotionsPushNotifications: $receivePromotionsPushNotifications
receiveNewConnectionPushNotifications: $receiveNewConnectionPushNotifications
receiveNewPingPushNotifications: $receiveNewPingPushNotifications
receiveNewMessagePushNotifications: $receiveNewMessagePushNotifications
receiveNewLikePushNotifications: $receiveNewLikePushNotifications
}
) {
id
appSettings {
receiveMarketingNotifications
receiveNewsEmailNotifications
receivePromotionsEmailNotifications
receiveNewsPushNotifications
receivePromotionsPushNotifications
receiveNewConnectionPushNotifications
receiveNewPingPushNotifications
receiveNewMessagePushNotifications
receiveNewLikePushNotifications
__typename
}
__typename
}
}
`;
// Filtered interactions
export const FILTERED_WHO_PINGS_ME_MUTATION = gql`
mutation FilteredWhoPingsMe($input: FilteredPingInteractionInput!, $cursor: String) {
filteredWhoPingsMe(input: $input, cursor: $cursor) {
profiles {
nodes {
id
age
gender
sexuality
imaginaryName
bio
desires
connectionGoals
interests
verificationStatus
isMajestic
distance {
km
mi
__typename
}
interactionStatus {
message
mine
theirs
__typename
}
photos {
id
pictureUrl
pictureUrls {
small
medium
large
__typename
}
__typename
}
__typename
}
pageInfo {
total
unfilteredTotal
hasNextPage
nextPageCursor
__typename
}
__typename
}
__typename
}
}
`;
// Challenge mutation
export const START_CHALLENGE_MUTATION = gql`
mutation StartChallenge($input: StartChallengeInput!) {
startChallenge(input: $input)
}
`;
// Account management
export const ACCOUNT_DEACTIVATE_MUTATION = gql`
mutation AccountDeactivate {
accountDeactivate {
id
status
__typename
}
}
`;
export const ACCOUNT_TERMINATE_MUTATION = gql`
mutation AccountTerminate {
accountTerminate {
id
status
__typename
}
}
`;

View File

@@ -577,3 +577,197 @@ export const ACCOUNT_STATUS_QUERY = gql`
}
}
`;
// === New queries discovered via API probing + APK analysis (v8.11.0) ===
export const POPULAR_LOCATIONS_QUERY = gql`
query PopularLocationsQuery {
popularLocations {
latitude
longitude
geocode {
city
country
__typename
}
__typename
}
}
`;
export const GET_PROFILE_CONNECTIONS_QUERY = gql`
${PICTURE_FRAGMENT}
query ProfileConnections($limit: Int = 25, $cursor: String) {
connections: getProfileConnections(limit: $limit, cursor: $cursor) {
nodes {
imaginaryName
isIncognito
sexuality
verificationStatus
isMajestic
age
gender
photos {
...GetPictureUrlFragment
__typename
}
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
}
`;
export const REDEEMED_OFFERS_QUERY = gql`
query RedeemedOffers {
account {
redeemedOffers {
offerName
redeemedAt
__typename
}
__typename
}
}
`;
export const APP_SETTINGS_QUERY = gql`
query AppSettings {
account {
id
email
analyticsId
status
createdAt
isFinishedOnboarding
isMajestic
upliftExpirationTimestamp
isUplift
isDistanceInMiles
language
ageVerificationStatus
verificationNumber
challenges
appSettings {
receiveMarketingNotifications
receiveNewsEmailNotifications
receivePromotionsEmailNotifications
receiveNewsPushNotifications
receivePromotionsPushNotifications
receiveNewConnectionPushNotifications
receiveNewPingPushNotifications
receiveNewMessagePushNotifications
receiveNewLikePushNotifications
__typename
}
location {
device {
country
__typename
}
__typename
}
profiles {
id
status
imaginaryName
__typename
}
__typename
}
}
`;
export const PROFILE_BY_STREAM_USER_ID_QUERY = gql`
${PICTURE_FRAGMENT}
query ProfileByStreamUserId($streamUserId: String!) {
profile: profileByStreamUserId(streamUserId: $streamUserId) {
id
age
imaginaryName
gender
sexuality
bio
desires
connectionGoals
interests
verificationStatus
isMajestic
photos {
...GetPictureUrlFragment
__typename
}
__typename
}
}
`;
export const PROFILE_MATCHES_QUERY = gql`
${PICTURE_FRAGMENT}
query ProfileMatches($profileId: String!, $limit: Int, $cursor: String) {
profile(id: $profileId) {
matches(limit: $limit, cursor: $cursor) {
nodes {
profile {
id
imaginaryName
age
gender
sexuality
bio
desires
connectionGoals
interests
verificationStatus
isMajestic
distance {
km
mi
__typename
}
photos {
...GetPictureUrlFragment
__typename
}
interactionStatus {
mine
theirs
message
__typename
}
__typename
}
chat {
id
streamChatId
name
type
status
__typename
}
__typename
}
pageInfo {
total
hasNextPage
nextPageCursor
__typename
}
__typename
}
__typename
}
}
`;
export const HAS_LINKED_REFLECTION_QUERY = gql`
query HasLinkedReflection {
hasLinkedReflection
}
`;

View File

@@ -14,6 +14,7 @@ interface ChatListItemProps {
age?: number;
isMajestic?: boolean;
} | null;
discoveredLocation?: string | null;
};
onClick?: () => void;
}
@@ -21,6 +22,7 @@ interface ChatListItemProps {
export function ChatListItem({ chat, onClick }: ChatListItemProps) {
const avatar = chat.avatarSet?.[0];
const message = chat.latestMessage;
const locationText = typeof chat.discoveredLocation === 'string' ? chat.discoveredLocation : '';
// Parse the message if it's a string (from API)
const messageData = typeof message === 'string' ? JSON.parse(message) : message;
@@ -114,6 +116,16 @@ export function ChatListItem({ chat, onClick }: ChatListItemProps) {
`}>
{messageText}
</p>
{locationText && (
<div className="flex items-center gap-1 mt-1 text-xs text-[var(--color-text-muted)]">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-3 h-3 flex-shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
<span className="truncate">{locationText}</span>
</div>
)}
</div>
{/* Chevron indicator */}

View File

@@ -3,27 +3,29 @@ import { Navigation } from './Navigation';
export function Layout() {
return (
<div className="min-h-screen bg-[var(--color-void)]">
<div style={{
minHeight: '100dvh',
background: 'var(--color-void)',
display: 'flex',
flexDirection: 'column',
}}>
<Navigation />
{/* Main content area - centered with nav offset */}
<main
style={{
minHeight: '100vh',
paddingTop: '24px',
paddingBottom: '96px',
paddingLeft: '16px',
paddingRight: '16px',
marginLeft: 'var(--nav-width, 0)',
}}
>
<div
style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
}}
>
<main style={{
flex: 1,
minHeight: '100dvh',
paddingTop: 'max(var(--safe-top, 0px), 16px)',
paddingBottom: 'calc(var(--nav-height) + var(--safe-bottom, 0px) + 16px)',
paddingLeft: '16px',
paddingRight: '16px',
marginLeft: 'var(--nav-width, 0)',
WebkitOverflowScrolling: 'touch',
}}>
<div style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
}}>
<Outlet />
</div>
</main>

View File

@@ -6,7 +6,7 @@ const navItems = [
to: '/discover',
label: 'Discover',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 24, height: 24 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
),
@@ -15,26 +15,42 @@ const navItems = [
to: '/likes',
label: 'Likes',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 24, height: 24 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
),
},
{
to: '/messages',
label: 'Chat',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 24, height: 24 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
),
},
{
to: '/sent-pings',
label: 'Pings',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 24, height: 24 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
),
},
{
to: '/messages',
label: 'Messages',
to: '/okcupid',
label: 'OKC',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
<span style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 800, fontSize: '11px', color: 'currentColor', fontFamily: 'var(--font-display, system-ui)' }}>OK</span>
),
},
{
to: '/matches',
label: 'Matches',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 24, height: 24 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
</svg>
),
},
@@ -42,7 +58,7 @@ const navItems = [
to: '/profile',
label: 'Profile',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 24, height: 24 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
),
@@ -51,7 +67,7 @@ const navItems = [
to: '/settings',
label: 'Settings',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-6 h-6">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 24, height: 24 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
@@ -59,126 +75,206 @@ const navItems = [
},
];
const s = {
// Desktop side rail
desktop: {
position: 'fixed' as const,
left: 0,
top: 0,
bottom: 0,
width: '80px',
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
paddingTop: '24px',
paddingBottom: '24px',
zIndex: 50,
background: 'var(--color-void)',
borderRight: '1px solid rgba(255,255,255,0.04)',
},
desktopLogo: {
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'var(--gradient-desire)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '32px',
boxShadow: '0 4px 16px rgba(124,58,237,0.3)',
},
desktopLogoText: {
color: '#fff',
fontFamily: "var(--font-display)",
fontWeight: 700,
fontSize: '18px',
},
desktopNavList: {
display: 'flex',
flexDirection: 'column' as const,
gap: '4px',
flex: 1,
},
desktopLink: (active: boolean) => ({
position: 'relative' as const,
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '14px',
color: active ? '#fff' : 'rgba(255,255,255,0.38)',
background: active ? 'rgba(124,58,237,0.12)' : 'transparent',
border: active ? '1px solid rgba(124,58,237,0.20)' : '1px solid transparent',
transition: 'all 250ms cubic-bezier(0.22,1,0.36,1)',
cursor: 'pointer',
textDecoration: 'none',
}),
desktopTooltip: {
position: 'absolute' as const,
left: '100%',
marginLeft: '12px',
padding: '6px 12px',
borderRadius: '8px',
background: 'var(--color-surface-elevated)',
border: '1px solid rgba(255,255,255,0.06)',
fontSize: '13px',
fontWeight: 500,
color: 'rgba(255,255,255,0.87)',
whiteSpace: 'nowrap' as const,
pointerEvents: 'none' as const,
opacity: 0,
transform: 'translateX(4px)',
transition: 'all 200ms cubic-bezier(0.22,1,0.36,1)',
},
desktopActiveDot: {
position: 'absolute' as const,
right: '-4px',
width: '6px',
height: '6px',
borderRadius: '50%',
background: 'var(--color-desire)',
boxShadow: '0 0 8px rgba(124,58,237,0.5)',
},
// Mobile bottom bar
mobile: {
position: 'fixed' as const,
bottom: 0,
left: 0,
right: 0,
zIndex: 50,
background: 'rgba(18,18,18,0.82)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
borderTop: '1px solid rgba(255,255,255,0.06)',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
},
mobileInner: {
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
height: '56px',
padding: '0 4px',
},
mobileLink: (active: boolean) => ({
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
gap: '2px',
width: '64px',
height: '48px',
borderRadius: '12px',
color: active ? 'var(--color-desire)' : 'rgba(255,255,255,0.38)',
background: active ? 'rgba(124,58,237,0.10)' : 'transparent',
transition: 'all 200ms cubic-bezier(0.22,1,0.36,1)',
textDecoration: 'none',
WebkitTapHighlightColor: 'transparent',
userSelect: 'none' as const,
WebkitUserSelect: 'none' as const,
}),
mobileLabel: (active: boolean) => ({
fontSize: '10px',
fontWeight: active ? 600 : 500,
fontFamily: 'var(--font-body)',
lineHeight: 1,
letterSpacing: '0.02em',
}),
mobileIconWrap: (active: boolean) => ({
transition: 'transform 200ms cubic-bezier(0.22,1,0.36,1)',
transform: active ? 'scale(1.1)' : 'scale(1)',
display: 'flex',
}),
};
export function Navigation() {
const location = useLocation();
const [mounted, setMounted] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [hovered, setHovered] = useState<string | null>(null);
useEffect(() => {
setMounted(true);
const onResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const isActive = (to: string) =>
location.pathname === to || (to === '/discover' && location.pathname === '/');
if (isMobile) {
return (
<nav style={s.mobile}>
<div style={s.mobileInner}>
{navItems.map((item) => {
const active = isActive(item.to);
return (
<NavLink
key={item.to}
to={item.to}
style={s.mobileLink(active)}
>
<span style={s.mobileIconWrap(active)}>{item.icon}</span>
<span style={s.mobileLabel(active)}>{item.label}</span>
</NavLink>
);
})}
</div>
</nav>
);
}
return (
<>
{/* Desktop Navigation - Side rail */}
<nav className="hidden md:flex fixed left-0 top-0 bottom-0 w-20 flex-col items-center py-8 z-50 bg-[var(--color-void)] border-r border-white/5">
{/* Logo */}
<div className="mb-12">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-desire)] to-[var(--color-desire-glow)] flex items-center justify-center shadow-lg">
<span className="text-white font-display font-bold text-lg">F</span>
</div>
</div>
<nav style={s.desktop}>
<div style={s.desktopLogo}>
<span style={s.desktopLogoText}>F</span>
</div>
{/* Nav Items */}
<div className="flex flex-col gap-2 flex-1">
{navItems.map((item, index) => {
const isActive = location.pathname === item.to ||
(item.to === '/discover' && location.pathname === '/');
<div style={s.desktopNavList}>
{navItems.map((item) => {
const active = isActive(item.to);
return (
<NavLink
key={item.to}
to={item.to}
style={s.desktopLink(active)}
onMouseEnter={() => setHovered(item.to)}
onMouseLeave={() => setHovered(null)}
>
{item.icon}
return (
<NavLink
key={item.to}
to={item.to}
className={`
relative w-12 h-12 flex items-center justify-center rounded-xl
transition-all duration-300 ease-out group
${isActive
? 'text-white'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}
${mounted ? 'animate-fade-up' : 'opacity-0'}
`}
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Active indicator */}
{isActive && (
<div className="absolute inset-0 rounded-xl bg-[var(--color-desire)]/10 border border-[var(--color-desire)]/30" />
)}
{active && <div style={s.desktopActiveDot} />}
{/* Glow effect on active */}
{isActive && (
<div className="absolute inset-0 rounded-xl bg-[var(--color-desire)]/5 blur-xl" />
)}
{/* Icon */}
<span className={`relative z-10 transition-transform duration-300 ${isActive ? 'scale-110' : 'group-hover:scale-110'}`}>
{item.icon}
</span>
{/* Tooltip */}
<div className="
absolute left-full ml-3 px-3 py-1.5 rounded-lg
bg-[var(--color-surface-elevated)] border border-[var(--glass-border)]
text-sm font-medium text-white
opacity-0 pointer-events-none translate-x-1
group-hover:opacity-100 group-hover:translate-x-0
transition-all duration-200
whitespace-nowrap
">
{item.label}
</div>
{/* Active dot */}
{isActive && (
<div className="absolute -right-1 w-1.5 h-1.5 rounded-full bg-[var(--color-desire)]" />
)}
</NavLink>
);
})}
</div>
</nav>
{/* Mobile Navigation - Bottom bar */}
<nav className="
md:hidden fixed bottom-0 left-0 right-0 z-50
glass rounded-t-2xl
safe-area-inset-bottom
">
<div className="flex justify-around py-2 px-2">
{navItems.map((item, index) => {
const isActive = location.pathname === item.to ||
(item.to === '/discover' && location.pathname === '/');
return (
<NavLink
key={item.to}
to={item.to}
className={`
relative flex flex-col items-center gap-1 px-4 py-2 rounded-xl
transition-all duration-300
${isActive
? 'text-[var(--color-desire)]'
: 'text-[var(--color-text-muted)]'
}
${mounted ? 'animate-fade-up' : 'opacity-0'}
`}
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Active background */}
{isActive && (
<div className="absolute inset-0 rounded-xl bg-[var(--color-desire)]/10" />
)}
<span className={`relative z-10 transition-transform duration-200 ${isActive ? 'scale-110' : ''}`}>
{item.icon}
</span>
<span className={`relative z-10 text-[10px] font-medium ${isActive ? 'text-[var(--color-desire)]' : ''}`}>
{item.label}
</span>
</NavLink>
);
})}
</div>
</nav>
</>
<div style={{
...s.desktopTooltip,
...(hovered === item.to ? { opacity: 1, transform: 'translateX(0)' } : {}),
}}>
{item.label}
</div>
</NavLink>
);
})}
</div>
</nav>
);
}

View File

@@ -15,7 +15,7 @@ interface ProfileCardProps {
connectionGoals?: string[];
interests?: string[];
distance?: { km: number; mi: number } | null;
location?: string | null;
discoveredLocation?: string | null;
interactionStatus?: {
mine?: string | null;
theirs?: string | null;
@@ -38,439 +38,314 @@ interface ProfileCardProps {
showDislike?: boolean;
}
const styles = {
card: {
position: 'relative' as const,
borderRadius: '16px',
overflow: 'hidden',
cursor: 'pointer',
background: 'rgba(255,255,255,0.03)',
},
photoContainer: {
aspectRatio: '3/4',
position: 'relative' as const,
overflow: 'hidden',
},
photo: {
width: '100%',
height: '100%',
objectFit: 'cover' as const,
transition: 'transform 0.5s ease',
},
photoFallback: {
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, #1a1a1f 0%, #0a0a0c 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
gradient: {
position: 'absolute' as const,
inset: 0,
background: 'linear-gradient(180deg, transparent 0%, transparent 40%, rgba(0,0,0,0.8) 70%, rgba(0,0,0,0.95) 100%)',
},
// Status badges
statusBadge: (variant: 'liked' | 'passed') => ({
position: 'absolute' as const,
top: '12px',
left: '12px',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
borderRadius: '20px',
fontSize: '12px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
color: '#ffffff',
background: variant === 'liked' ? 'rgba(34,197,94,0.9)' : 'rgba(100,116,139,0.9)',
backdropFilter: 'blur(8px)',
boxShadow: variant === 'liked' ? '0 4px 12px rgba(34,197,94,0.3)' : 'none',
}),
// Majestic/Verified badges
badgeContainer: {
position: 'absolute' as const,
top: '12px',
right: '12px',
zIndex: 10,
display: 'flex',
gap: '6px',
},
majesticBadge: {
width: '28px',
height: '28px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 12px rgba(245, 158, 11, 0.4)',
},
verifiedBadge: {
width: '28px',
height: '28px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.4)',
},
// Info overlay
infoOverlay: {
position: 'absolute' as const,
bottom: 0,
left: 0,
right: 0,
padding: '20px',
},
nameRow: {
display: 'flex',
alignItems: 'baseline',
gap: '8px',
marginBottom: '4px',
},
name: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '20px',
fontWeight: 600,
color: '#ffffff',
margin: 0,
letterSpacing: '-0.01em',
},
age: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '16px',
fontWeight: 500,
color: 'rgba(255,255,255,0.8)',
},
detailsRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '13px',
fontFamily: "'Satoshi', sans-serif",
color: 'rgba(255,255,255,0.7)',
marginBottom: '12px',
},
detailDot: {
width: '3px',
height: '3px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.4)',
},
tagsContainer: {
display: 'flex',
flexWrap: 'wrap' as const,
gap: '6px',
},
tag: {
padding: '5px 10px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
background: 'rgba(190,49,68,0.3)',
color: '#f4a5b0',
textTransform: 'uppercase' as const,
letterSpacing: '0.02em',
},
tagMore: {
padding: '5px 10px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
background: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.6)',
},
// Refresh button overlay
refreshOverlay: {
position: 'absolute' as const,
inset: 0,
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
background: 'linear-gradient(135deg, rgba(26,26,31,0.95) 0%, rgba(10,10,12,0.95) 100%)',
zIndex: 15,
},
refreshButton: (isRefreshing: boolean) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '12px 20px',
borderRadius: '12px',
border: 'none',
background: isRefreshing
? 'rgba(255,255,255,0.1)'
: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
color: '#ffffff',
fontSize: '13px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: isRefreshing ? 'not-allowed' : 'pointer',
opacity: isRefreshing ? 0.7 : 1,
transition: 'all 0.2s ease',
boxShadow: isRefreshing ? 'none' : '0 4px 12px rgba(59, 130, 246, 0.3)',
}),
refreshText: {
fontSize: '12px',
color: 'rgba(255,255,255,0.5)',
fontFamily: "'Satoshi', sans-serif",
textAlign: 'center' as const,
maxWidth: '160px',
},
// Quick action buttons
quickActionsContainer: {
position: 'absolute' as const,
bottom: '16px',
right: '16px',
zIndex: 20,
display: 'flex',
gap: '8px',
},
dislikeButton: (isDisliking: boolean) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
borderRadius: '50%',
border: 'none',
background: isDisliking
? 'rgba(100,116,139,0.6)'
: 'rgba(100,116,139,0.8)',
color: '#ffffff',
cursor: isDisliking ? 'not-allowed' : 'pointer',
opacity: isDisliking ? 0.7 : 1,
transition: 'all 0.2s ease',
backdropFilter: 'blur(8px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
}),
};
// Safely convert any value to a renderable string (handles GraphQL objects with __typename)
const safeText = (v: any): string => {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
// Object - likely a GraphQL type with __typename
return '';
};
export function ProfileCard({ profile, onClick, onRefresh, isRefreshing, onDislike, isDisliking, showDislike }: ProfileCardProps) {
export function ProfileCard({ profile, onClick, index = 0, onRefresh, isRefreshing, onDislike, isDisliking, showDislike }: ProfileCardProps) {
const [imageError, setImageError] = useState(false);
const [copied, setCopied] = useState(false);
const primaryPhotoUrl = profile.photos?.[0]?.pictureUrls?.medium || profile.photos?.[0]?.pictureUrls?.large;
const theyDislikedMe = profile.interactionStatus?.theirs === 'DISLIKED';
const theyLikedMe = profile.interactionStatus?.theirs === 'LIKED';
const theyDislikedMe = profile.interactionStatus?.theirs === 'DISLIKED';
const locationText = typeof profile.discoveredLocation === 'string' ? profile.discoveredLocation : '';
const rawGoals = profile.connectionGoals || profile.desires || [];
// Ensure goals are strings (GraphQL may return objects)
const goals = Array.isArray(rawGoals) ? rawGoals.filter((g): g is string => typeof g === 'string') : [];
// Reset error state when photo URL changes (new photos loaded after refresh)
useEffect(() => {
setImageError(false);
}, [primaryPhotoUrl]);
useEffect(() => { setImageError(false); }, [primaryPhotoUrl]);
const handleCopyId = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(profile.id.replace(/^profile#/, '')).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
const handleRefresh = (e: React.MouseEvent) => {
e.stopPropagation(); // Don't trigger card onClick
if (onRefresh && !isRefreshing) {
onRefresh(profile.id);
}
e.stopPropagation();
if (onRefresh && !isRefreshing) onRefresh(profile.id);
};
const handleDislike = async (e: React.MouseEvent) => {
e.stopPropagation(); // Don't trigger card onClick
if (onDislike && !isDisliking) {
await onDislike(profile);
}
e.stopPropagation();
if (onDislike && !isDisliking) await onDislike(profile);
};
// Key for ProxiedImage - changes when URL changes
const photoKey = primaryPhotoUrl || 'no-photo';
return (
<div style={styles.card} onClick={onClick}>
<div style={styles.photoContainer}>
<div
onClick={onClick}
className="btn-press"
style={{
position: 'relative',
borderRadius: '16px',
overflow: 'hidden',
cursor: 'pointer',
background: 'var(--color-surface)',
border: '1px solid rgba(255,255,255,0.04)',
animation: `cardAppear 350ms cubic-bezier(0.22,1,0.36,1) ${index * 50}ms both`,
}}
>
{/* Photo */}
<div style={{ aspectRatio: '3/4', position: 'relative', overflow: 'hidden' }}>
<ProxiedImage
key={photoKey}
key={primaryPhotoUrl || 'no-photo'}
src={primaryPhotoUrl}
alt={profile.imaginaryName}
style={styles.photo}
style={{
width: '100%',
height: '100%',
objectFit: 'cover' as const,
transition: 'transform 0.5s cubic-bezier(0.22,1,0.36,1)',
}}
onError={() => setImageError(true)}
onLoad={() => setImageError(false)}
fallback={
<div style={styles.photoFallback}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="1"
style={{ width: '48px', height: '48px' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
<div style={{
width: '100%', height: '100%',
background: 'linear-gradient(135deg, var(--color-surface) 0%, var(--color-void) 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.15)" strokeWidth="1" style={{ width: 48, height: 48 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
}
/>
{/* Refresh overlay when image fails to load (only if there was a URL to try) */}
{/* Refresh overlay */}
{imageError && onRefresh && primaryPhotoUrl && (
<div style={styles.refreshOverlay}>
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '12px',
background: 'rgba(18,18,18,0.92)', zIndex: 15,
}}>
<button
style={styles.refreshButton(isRefreshing || false)}
onClick={handleRefresh}
disabled={isRefreshing}
className="btn-press"
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '10px 18px', borderRadius: '10px', border: 'none',
background: isRefreshing ? 'rgba(255,255,255,0.08)' : 'var(--gradient-desire)',
color: '#fff', fontSize: '13px', fontWeight: 600, fontFamily: 'var(--font-body)',
cursor: isRefreshing ? 'not-allowed' : 'pointer',
opacity: isRefreshing ? 0.6 : 1,
}}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{
width: '16px',
height: '16px',
animation: isRefreshing ? 'spin 1s linear infinite' : 'none',
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
style={{ width: 14, height: 14, animation: isRefreshing ? 'spin 1s linear infinite' : 'none' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</button>
<span style={styles.refreshText}>
Image expired. Tap to fetch fresh data.
<span style={{ fontSize: '11px', color: 'rgba(255,255,255,0.4)', fontFamily: 'var(--font-body)', textAlign: 'center' }}>
Image expired
</span>
</div>
)}
{/* Gradient overlay */}
<div style={styles.gradient} />
<div style={{
position: 'absolute', inset: 0,
background: 'var(--gradient-photo-overlay)',
pointerEvents: 'none',
}} />
{/* Likes you badge */}
{/* Status badges — top left */}
{theyLikedMe && (
<div style={styles.statusBadge('liked')}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '14px', height: '14px' }}>
<div style={{
position: 'absolute', top: '10px', left: '10px', zIndex: 10,
display: 'flex', alignItems: 'center', gap: '5px',
padding: '5px 10px', borderRadius: '20px',
fontSize: '11px', fontWeight: 600, fontFamily: 'var(--font-body)',
color: '#fff',
background: 'rgba(34,197,94,0.85)',
backdropFilter: 'blur(8px)',
boxShadow: '0 2px 8px rgba(34,197,94,0.3)',
}}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: 12, height: 12 }}>
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
Likes you
</div>
)}
{/* Not interested badge */}
{theyDislikedMe && (
<div style={styles.statusBadge('passed')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '14px', height: '14px' }}>
<div style={{
position: 'absolute', top: '10px', left: '10px', zIndex: 10,
display: 'flex', alignItems: 'center', gap: '5px',
padding: '5px 10px', borderRadius: '20px',
fontSize: '11px', fontWeight: 600, fontFamily: 'var(--font-body)',
color: '#fff', background: 'rgba(100,116,139,0.85)', backdropFilter: 'blur(8px)',
}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ width: 11, height: 11 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
Not interested
Passed
</div>
)}
{/* Majestic/Verified badges */}
{(profile.isMajestic || profile.verificationStatus) && (
<div style={styles.badgeContainer}>
{profile.isMajestic && (
<div style={styles.majesticBadge} title="Majestic Member">
<svg viewBox="0 0 24 24" fill="#ffffff" style={{ width: '16px', height: '16px' }}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
{/* Top right badges */}
<div style={{ position: 'absolute', top: '10px', right: '10px', zIndex: 10, display: 'flex', gap: '6px' }}>
{profile.isMajestic && (
<div style={{
width: '26px', height: '26px', borderRadius: '50%',
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 2px 8px rgba(245,158,11,0.4)',
}} title="Majestic">
<svg viewBox="0 0 24 24" fill="#fff" style={{ width: 14, height: 14 }}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
)}
{profile.verificationStatus && (
<div style={{
width: '26px', height: '26px', borderRadius: '50%',
background: 'linear-gradient(135deg, #3b82f6, #2563eb)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 2px 8px rgba(59,130,246,0.4)',
}} title="Verified">
<svg viewBox="0 0 24 24" fill="#fff" style={{ width: 14, height: 14 }}>
<path fillRule="evenodd" d="M8.603 3.799A4.49 4.49 0 0112 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 013.498 1.307 4.491 4.491 0 011.307 3.497A4.49 4.49 0 0121.75 12a4.49 4.49 0 01-1.549 3.397 4.491 4.491 0 01-1.307 3.497 4.491 4.491 0 01-3.497 1.307A4.49 4.49 0 0112 21.75a4.49 4.49 0 01-3.397-1.549 4.49 4.49 0 01-3.498-1.306 4.491 4.491 0 01-1.307-3.498A4.49 4.49 0 012.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 011.307-3.497 4.49 4.49 0 013.497-1.307zm7.007 6.387a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
</svg>
</div>
)}
<button
onClick={handleCopyId}
title={copied ? 'Copied!' : 'Copy ID'}
className="btn-press"
style={{
width: '26px', height: '26px', borderRadius: '50%',
background: copied ? 'rgba(34,197,94,0.9)' : 'rgba(0,0,0,0.5)',
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', backdropFilter: 'blur(8px)',
transition: 'all 200ms',
}}
>
{copied ? (
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" style={{ width: 12, height: 12 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.5" style={{ width: 12, height: 12 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
)}
{profile.verificationStatus && (
<div style={styles.verifiedBadge} title="Verified Profile">
<svg viewBox="0 0 24 24" fill="#ffffff" style={{ width: '16px', height: '16px' }}>
<path fillRule="evenodd" d="M8.603 3.799A4.49 4.49 0 0112 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 013.498 1.307 4.491 4.491 0 011.307 3.497A4.49 4.49 0 0121.75 12a4.49 4.49 0 01-1.549 3.397 4.491 4.491 0 01-1.307 3.497 4.491 4.491 0 01-3.497 1.307A4.49 4.49 0 0112 21.75a4.49 4.49 0 01-3.397-1.549 4.49 4.49 0 01-3.498-1.306 4.491 4.491 0 01-1.307-3.498A4.49 4.49 0 012.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 011.307-3.497 4.49 4.49 0 013.497-1.307zm7.007 6.387a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
)}
</button>
</div>
{/* Info overlay */}
<div style={styles.infoOverlay}>
{/* Name and age */}
<div style={styles.nameRow}>
<h3 style={styles.name}>{profile.imaginaryName}</h3>
<span style={styles.age}>{profile.age}</span>
{/* Info overlay — glassmorphism */}
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0,
padding: '16px',
}}>
{/* Name + Age */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: '6px', marginBottom: '3px' }}>
<h3 style={{
fontFamily: 'var(--font-display)', fontSize: '18px', fontWeight: 600,
color: '#fff', margin: 0, letterSpacing: '-0.01em',
}}>
{profile.imaginaryName}
</h3>
<span style={{
fontFamily: 'var(--font-body)', fontSize: '15px', fontWeight: 500,
color: 'rgba(255,255,255,0.75)',
}}>
{profile.age}
</span>
</div>
{/* Details row - gender/sexuality */}
<div style={styles.detailsRow}>
{/* Gender / Sexuality */}
<div style={{
display: 'flex', alignItems: 'center', gap: '6px',
fontSize: '12px', fontFamily: 'var(--font-body)',
color: 'rgba(255,255,255,0.6)', marginBottom: '8px',
}}>
<span>{safeText(profile.gender)}</span>
<div style={styles.detailDot} />
<span style={{ width: 3, height: 3, borderRadius: '50%', background: 'rgba(255,255,255,0.3)' }} />
<span>{safeText(profile.sexuality)}</span>
{profile.distance && typeof profile.distance === 'object' && 'mi' in profile.distance && (
<>
<span style={{ width: 3, height: 3, borderRadius: '50%', background: 'rgba(255,255,255,0.3)' }} />
<span>{Math.round(profile.distance.mi)} mi</span>
</>
)}
</div>
{/* Location row - distance and location */}
{(profile.distance && typeof profile.distance === 'object' && 'mi' in profile.distance || typeof profile.location === 'string') && (
<div style={{ ...styles.detailsRow, marginBottom: '12px' }}>
{profile.distance && typeof profile.distance === 'object' && 'mi' in profile.distance && (
<span>{Math.round(profile.distance.mi)} mi</span>
)}
{profile.distance && typeof profile.distance === 'object' && 'mi' in profile.distance && typeof profile.location === 'string' && (
<div style={styles.detailDot} />
)}
{typeof profile.location === 'string' && (
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '12px', height: '12px', opacity: 0.7 }}>
<path fillRule="evenodd" d="M11.54 22.351l.07.04.028.016a.76.76 0 00.723 0l.028-.015.071-.041a16.975 16.975 0 001.144-.742 19.58 19.58 0 002.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 00-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 002.682 2.282 16.975 16.975 0 001.145.742zM12 13.5a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
{profile.location}
</span>
)}
{/* Location */}
{locationText && (
<div style={{
display: 'flex', alignItems: 'center', gap: '4px',
fontSize: '11px', fontFamily: 'var(--font-body)',
color: 'rgba(255,255,255,0.7)', marginBottom: '8px',
}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
style={{ width: 11, height: 11, flexShrink: 0 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{locationText}
</span>
</div>
)}
{/* Connection goals / Desires */}
{/* Tags */}
{goals.length > 0 && (
<div style={styles.tagsContainer}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{goals.slice(0, 2).map((goal) => (
<span key={goal} style={styles.tag}>
<span key={goal} style={{
padding: '3px 8px', borderRadius: '8px',
fontSize: '10px', fontWeight: 600, fontFamily: 'var(--font-body)',
background: 'rgba(124,58,237,0.25)', color: 'rgba(200,170,255,0.9)',
textTransform: 'uppercase', letterSpacing: '0.03em',
}}>
{goal.replace(/_/g, ' ')}
</span>
))}
{goals.length > 2 && (
<span style={styles.tagMore}>+{goals.length - 2}</span>
<span style={{
padding: '3px 8px', borderRadius: '8px',
fontSize: '10px', fontWeight: 600, fontFamily: 'var(--font-body)',
background: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.5)',
}}>
+{goals.length - 2}
</span>
)}
</div>
)}
</div>
{/* Quick action buttons */}
{/* Dislike button */}
{showDislike && onDislike && (
<div style={styles.quickActionsContainer}>
<div style={{ position: 'absolute', bottom: '12px', right: '12px', zIndex: 20 }}>
<button
style={styles.dislikeButton(isDisliking || false)}
onClick={handleDislike}
disabled={isDisliking}
title="Pass"
className="btn-press"
style={{
width: '40px', height: '40px', borderRadius: '50%', border: 'none',
background: 'rgba(100,116,139,0.7)', color: '#fff',
cursor: isDisliking ? 'not-allowed' : 'pointer',
opacity: isDisliking ? 0.5 : 1, display: 'flex',
alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(8px)', boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
transition: 'all 200ms',
}}
>
{isDisliking ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ width: '18px', height: '18px', animation: 'spin 1s linear infinite' }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
style={{ width: 16, height: 16, animation: 'spin 1s linear infinite' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ width: '18px', height: '18px' }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ width: 16, height: 16 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}

View File

@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation } from '@apollo/client/react';
import { PROFILE_QUERY, ACCOUNT_STATUS_QUERY } from '../../api/operations/queries';
import { PROFILE_LIKE_MUTATION, PROFILE_PING_MUTATION, PROFILE_DISLIKE_MUTATION } from '../../api/operations/mutations';
import { PROFILE_LIKE_MUTATION, PROFILE_PING_MUTATION, PROFILE_DISLIKE_MUTATION, PROFILE_BLOCK_MUTATION, PROFILE_REPORT_MUTATION } from '../../api/operations/mutations';
import { addLikedProfileToStorage } from '../../hooks/useLikedProfiles';
import { addSentPing, addDislikedProfile } from '../../api/dataSync';
import { authManager } from '../../api/auth';
import { ProxiedImage } from '../ui/ProxiedImage';
import { PingModal } from './PingModal';
@@ -390,6 +391,106 @@ const styles = {
boxShadow: variant === 'primary' ? '0 8px 24px rgba(190,49,68,0.4)' : 'none',
}),
// More options button
moreButton: {
position: 'absolute' as const,
top: '16px',
right: '64px',
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
cursor: 'pointer',
transition: 'all 200ms ease',
zIndex: 10,
fontSize: '20px',
fontWeight: 700,
fontFamily: "'Satoshi', sans-serif",
letterSpacing: '1px',
},
// Block/Report dropdown
blockReportOverlay: {
position: 'absolute' as const,
inset: 0,
zIndex: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)',
},
blockReportMenu: {
width: '280px',
background: 'rgba(15,15,20,0.95)',
borderRadius: '20px',
border: '1px solid rgba(255,255,255,0.1)',
padding: '8px',
boxShadow: '0 20px 40px rgba(0,0,0,0.5)',
},
blockReportTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '16px',
fontWeight: 600,
color: '#fff',
textAlign: 'center' as const,
padding: '12px 16px 8px',
margin: 0,
},
blockReportItem: (variant: 'danger' | 'warning' | 'default') => ({
display: 'flex',
alignItems: 'center',
gap: '12px',
width: '100%',
padding: '14px 16px',
borderRadius: '12px',
border: 'none',
background: 'transparent',
color: variant === 'danger' ? '#ef4444' : variant === 'warning' ? '#f59e0b' : 'rgba(255,255,255,0.8)',
fontSize: '14px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'background 150ms ease',
textAlign: 'left' as const,
}),
blockReportDivider: {
height: '1px',
background: 'rgba(255,255,255,0.06)',
margin: '4px 8px',
},
blockReportCancel: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
padding: '14px 16px',
borderRadius: '12px',
border: 'none',
background: 'rgba(255,255,255,0.05)',
color: 'rgba(255,255,255,0.6)',
fontSize: '14px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'background 150ms ease',
marginTop: '4px',
},
blockReportError: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '13px',
color: '#ef4444',
textAlign: 'center' as const,
padding: '8px 16px',
margin: 0,
},
// Error state
errorContainer: {
padding: '60px 40px',
@@ -426,6 +527,10 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai
const [showPingModal, setShowPingModal] = useState(false);
const [viewingProfileId, setViewingProfileId] = useState(profileId);
const [profileHistory, setProfileHistory] = useState<string[]>([]);
const [showBlockReport, setShowBlockReport] = useState(false);
const [blockReportMode, setBlockReportMode] = useState<'block' | 'report' | null>(null);
const [blockReportLoading, setBlockReportLoading] = useState(false);
const [blockReportError, setBlockReportError] = useState<string | null>(null);
const handleViewPartner = (partnerId: string) => {
setProfileHistory(prev => [...prev, viewingProfileId]);
@@ -445,7 +550,11 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (showPingModal) {
if (showBlockReport) {
setShowBlockReport(false);
setBlockReportMode(null);
setBlockReportError(null);
} else if (showPingModal) {
setShowPingModal(false);
} else {
handleClose();
@@ -455,7 +564,7 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [showPingModal]);
}, [showPingModal, showBlockReport]);
const handleClose = () => {
setIsClosing(true);
@@ -466,6 +575,21 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai
variables: { profileId: viewingProfileId },
});
// Fetch our cached discoveredLocation (the saved-location label that was
// active when we last saw this profile).
const [discoveredLocation, setDiscoveredLocation] = useState<string | null>(null);
useEffect(() => {
if (!viewingProfileId) return;
setDiscoveredLocation(null);
fetch(`/api/discovered-profiles/lookup/${encodeURIComponent(viewingProfileId)}`)
.then(r => r.ok ? r.json() : null)
.then(d => {
const loc = d?.profile?.discoveredLocation;
if (typeof loc === 'string' && loc) setDiscoveredLocation(loc);
})
.catch(() => {});
}, [viewingProfileId]);
// Query for available pings
const { data: accountData, refetch: refetchAccount } = useQuery(ACCOUNT_STATUS_QUERY, {
fetchPolicy: 'cache-and-network',
@@ -517,6 +641,62 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai
},
});
const [blockProfile] = useMutation(PROFILE_BLOCK_MUTATION);
const [reportProfile] = useMutation(PROFILE_REPORT_MUTATION);
const handleBlock = async (blockCategory: string) => {
setBlockReportLoading(true);
setBlockReportError(null);
try {
await blockProfile({
variables: {
input: {
targetProfileId: viewingProfileId,
blockCategory,
},
},
});
setShowBlockReport(false);
setBlockReportMode(null);
handleClose();
} catch (err: any) {
setBlockReportError(err.message || 'Failed to block profile');
} finally {
setBlockReportLoading(false);
}
};
const handleReport = async (reportCategory: string) => {
setBlockReportLoading(true);
setBlockReportError(null);
try {
await reportProfile({
variables: {
input: {
targetProfileId: viewingProfileId,
sourceProfileId: authManager.getProfileId(),
reportCategory,
},
},
});
setBlockReportMode(null);
setBlockReportError(null);
// Show brief confirmation then close
setBlockReportLoading(false);
setTimeout(() => {
setShowBlockReport(false);
handleClose();
}, 1200);
// Temporarily reuse error field for success message
setBlockReportError('Report submitted successfully.');
return;
} catch (err: any) {
setBlockReportError(err.message || 'Failed to report profile');
} finally {
setBlockReportLoading(false);
}
};
const handleSendPing = (message: string) => {
sendPingMutation({
variables: {
@@ -594,6 +774,21 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai
<div style={styles.photoGradient} />
{/* More options button */}
<button
style={styles.moreButton}
onClick={() => { setShowBlockReport(true); setBlockReportMode(null); setBlockReportError(null); }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.7)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.5)'}
title="More options"
>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '20px', height: '20px' }}>
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
</button>
{/* Close button */}
<button
style={styles.closeButton}
@@ -677,6 +872,12 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai
<span>{Math.round(profile.distance.mi)} mi away</span>
</>
)}
{discoveredLocation && (
<>
<div style={styles.detailDot} />
<span>{discoveredLocation}</span>
</>
)}
</div>
{/* Likes you badge */}
{theyLikedMe && (
@@ -885,6 +1086,115 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai
</button>
</div>
{/* Block/Report Overlay */}
{showBlockReport && (
<div style={styles.blockReportOverlay} onClick={() => { if (!blockReportLoading) { setShowBlockReport(false); setBlockReportMode(null); setBlockReportError(null); } }}>
<div style={styles.blockReportMenu} onClick={(e) => e.stopPropagation()}>
{!blockReportMode ? (
<>
<p style={styles.blockReportTitle}>Options</p>
<button
style={styles.blockReportItem('danger')}
onClick={() => setBlockReportMode('block')}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(239,68,68,0.1)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '18px', height: '18px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
Block
</button>
<div style={styles.blockReportDivider} />
<button
style={styles.blockReportItem('warning')}
onClick={() => setBlockReportMode('report')}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(245,158,11,0.1)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '18px', height: '18px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v1.5M3 21v-6m0 0l2.77-.693a9 9 0 016.208.682l.108.054a9 9 0 006.086.71l3.114-.732a48.524 48.524 0 01-.005-10.499l-3.11.732a9 9 0 01-6.085-.711l-.108-.054a9 9 0 00-6.208-.682L3 4.5M3 15V4.5" />
</svg>
Report
</button>
<button
style={styles.blockReportCancel}
onClick={() => { setShowBlockReport(false); setBlockReportError(null); }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.08)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
>
Cancel
</button>
</>
) : blockReportMode === 'block' ? (
<>
<p style={styles.blockReportTitle}>Block Reason</p>
{['NOT_INTERESTED', 'SOMETHING_ELSE'].map((category) => (
<button
key={category}
style={{
...styles.blockReportItem('default'),
opacity: blockReportLoading ? 0.5 : 1,
cursor: blockReportLoading ? 'not-allowed' : 'pointer',
}}
onClick={() => !blockReportLoading && handleBlock(category)}
disabled={blockReportLoading}
onMouseEnter={(e) => { if (!blockReportLoading) e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{blockReportLoading ? 'Blocking...' : category.replace(/_/g, ' ')}
</button>
))}
{blockReportError && <p style={styles.blockReportError}>{blockReportError}</p>}
<button
style={{ ...styles.blockReportCancel, opacity: blockReportLoading ? 0.5 : 1 }}
onClick={() => { if (!blockReportLoading) { setBlockReportMode(null); setBlockReportError(null); } }}
disabled={blockReportLoading}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.08)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
>
Back
</button>
</>
) : (
<>
<p style={styles.blockReportTitle}>Report Reason</p>
{['INAPPROPRIATE', 'UNDERAGE', 'OFFENSIVE', 'OTHER'].map((category) => (
<button
key={category}
style={{
...styles.blockReportItem('default'),
opacity: blockReportLoading ? 0.5 : 1,
cursor: blockReportLoading ? 'not-allowed' : 'pointer',
}}
onClick={() => !blockReportLoading && handleReport(category)}
disabled={blockReportLoading}
onMouseEnter={(e) => { if (!blockReportLoading) e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{blockReportLoading ? 'Reporting...' : category.replace(/_/g, ' ')}
</button>
))}
{blockReportError && (
<p style={{
...styles.blockReportError,
color: blockReportError.includes('successfully') ? '#22c55e' : '#ef4444',
}}>{blockReportError}</p>
)}
<button
style={{ ...styles.blockReportCancel, opacity: blockReportLoading ? 0.5 : 1 }}
onClick={() => { if (!blockReportLoading) { setBlockReportMode(null); setBlockReportError(null); } }}
disabled={blockReportLoading}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.08)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
>
Back
</button>
</>
)}
</div>
</div>
)}
{/* Ping Modal */}
{showPingModal && (
<PingModal

View File

@@ -1,47 +1,27 @@
interface LoadingProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function Loading({ size = 'md', className = '' }: LoadingProps) {
const sizes = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
const spinnerSizes = { sm: 16, md: 28, lg: 40 };
export function Loading({ size = 'md' }: LoadingProps) {
const px = spinnerSizes[size];
return (
<div className={`flex items-center justify-center ${className}`}>
<div className={`relative ${sizes[size]}`}>
{/* Outer ring */}
<div
className={`
absolute inset-0
border-2 border-[var(--color-surface-elevated)]
rounded-full
`}
/>
{/* Spinning gradient arc */}
<div
className={`
absolute inset-0
border-2 border-transparent
border-t-[var(--color-desire)]
border-r-[var(--color-desire-glow)]
rounded-full
animate-spin
`}
style={{ animationDuration: '0.8s' }}
/>
{/* Inner glow */}
<div
className={`
absolute inset-1
bg-[var(--color-desire)]/5
rounded-full
blur-sm
`}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ position: 'relative', width: px, height: px }}>
<div style={{
position: 'absolute', inset: 0,
border: '2px solid var(--color-surface-elevated)',
borderRadius: '50%',
}} />
<div style={{
position: 'absolute', inset: 0,
border: '2px solid transparent',
borderTopColor: 'var(--color-desire)',
borderRightColor: 'var(--color-desire-glow)',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}} />
</div>
</div>
);
@@ -49,9 +29,17 @@ export function Loading({ size = 'md', className = '' }: LoadingProps) {
export function LoadingPage() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
minHeight: '60dvh', gap: '16px',
}}>
<Loading size="lg" />
<p className="text-[var(--color-text-muted)] text-sm animate-pulse">
<p style={{
color: 'var(--color-text-muted)',
fontSize: '14px',
fontFamily: 'var(--font-body)',
animation: 'fadeIn 1s ease-in-out infinite alternate',
}}>
Loading...
</p>
</div>
@@ -60,25 +48,36 @@ export function LoadingPage() {
export function LoadingCard() {
return (
<div className="rounded-2xl overflow-hidden bg-[var(--color-surface)] border border-[var(--glass-border)]">
<div className="aspect-[3/4] shimmer" />
<div className="p-4 space-y-3">
<div className="h-6 w-3/4 rounded-lg shimmer" />
<div className="h-4 w-1/2 rounded-lg shimmer" />
<div className="flex gap-2">
<div className="h-6 w-16 rounded-full shimmer" />
<div className="h-6 w-20 rounded-full shimmer" />
<div style={{
borderRadius: '16px',
overflow: 'hidden',
background: 'var(--color-surface)',
border: '1px solid var(--glass-border)',
}}>
<div className="shimmer" style={{ aspectRatio: '3/4' }} />
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div className="shimmer" style={{ height: '20px', width: '70%', borderRadius: '8px' }} />
<div className="shimmer" style={{ height: '14px', width: '45%', borderRadius: '8px' }} />
<div style={{ display: 'flex', gap: '8px' }}>
<div className="shimmer" style={{ height: '24px', width: '56px', borderRadius: '12px' }} />
<div className="shimmer" style={{ height: '24px', width: '72px', borderRadius: '12px' }} />
</div>
</div>
</div>
);
}
export function LoadingCards({ count = 8 }: { count?: number }) {
export function LoadingCards({ count = 6 }: { count?: number }) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
gap: '12px',
}}>
{Array.from({ length: count }).map((_, i) => (
<LoadingCard key={i} />
<div key={i} style={{ animation: `cardAppear 350ms cubic-bezier(0.22,1,0.36,1) ${i * 60}ms both` }}>
<LoadingCard />
</div>
))}
</div>
);

View File

@@ -1,6 +1,6 @@
// ⚠️ When updating, also update vite.config.ts proxy headers (search for APP_VERSION)
export const APP_VERSION = '8.8.3';
export const OS_VERSION = '18.6.2';
export const APP_VERSION = '8.11.0';
export const OS_VERSION = '26.2.1';
export const API_CONFIG = {
// Use Vite proxy to bypass CORS
@@ -27,8 +27,8 @@ export const REQUEST_HEADERS = {
// Default credentials - can be overridden via localStorage
const DEFAULT_CREDENTIALS = {
PROFILE_ID: 'profile#c5f40eca-b972-41b8-ba74-24db33a8f244',
REFRESH_TOKEN: 'AMf-vBzSZvWXvunGC0c3Sn5Sf4vxwSdb2z4VwT6rLC7LhGo_I4Wd759NCtKBBmIM994RPlEd3eHFT7v0CwkTFpu7uTT80uFa7EDR5DgYK-DiCm4S8nvcuoPMgPK1pa2xRYafqXnUmMEaZNIpWozvQrqmGZHa62pFySbX6EXxXd-S9vewsbWbSXUkZtZUMtOx3GR5nP1jP-z_bTGFktrJUNjV0tfeGa6C3Jc5MOg4dvjPGJ6YAMKGGMkKoDauNK1lHFKSvtnfMRFC6KrJYar1bsCljre-9aaYwq2gJ9YSZ53T6JIkV2_74c0',
EVENT_ANALYTICS_ID: '53923e7d-39bb-42ac-99c6-99cdf6008d11',
REFRESH_TOKEN: 'AMf-vBwqZWAS46Y76TtMlbyE7eSaPA952sVuMyxRwIUKbuqwEmHxn6Mk4Zy0vbOVKPjAckKjT42LkBIzFEUV9PdlhDpQehTmZ45VbqYLOPj7Vh4cQCYDsZP47APdLThHYoWGT4verYMEz7I7o0_6TC_V0B9kgvL6xdtcumeTY_UAG-5LZxUNYQ1EdAXwv4tCT7CpGIKsdoMpyyNwWNBKRV1LIP7c2H2OfZVgGF107XM3mfzc97kKOqT7JSZPg5tOuuFXFykTiiS_JjFMgvWzs2tk-V394sa1Jz3DrgWkXYhphUsa7S-ls9A',
EVENT_ANALYTICS_ID: 'e207fccf-b258-428f-bb6a-02e14616915c',
};
// Auth credentials manager with localStorage persistence

View File

@@ -2,7 +2,7 @@ import { createContext, useContext, useState, useEffect, ReactNode, useCallback
import { StreamChat } from 'stream-chat';
import { useQuery } from '@apollo/client/react';
import { STREAM_CREDENTIALS_QUERY } from '../api/operations/queries';
import { TEST_CREDENTIALS } from '../config/constants';
import { authManager } from '../api/auth';
// Stream Chat API Key for Feeld - this is a public key
// Found from app traffic analysis (chat.stream-io-api.com requests)
@@ -59,7 +59,7 @@ export function StreamChatProvider({ children }: { children: ReactNode }) {
const [userName, setUserName] = useState<string | null>(null);
const { data, loading, error: queryError } = useQuery(STREAM_CREDENTIALS_QUERY, {
variables: { profileId: TEST_CREDENTIALS.PROFILE_ID },
variables: { profileId: authManager.getProfileId() },
});
// Extract stable values to avoid reconnecting on every Apollo cache update

178
web/src/hooks/useMatches.ts Normal file
View File

@@ -0,0 +1,178 @@
import { useState, useEffect, useCallback, useRef } from 'react';
export type MatchProfile = {
id: string;
imaginaryName?: string;
age?: number;
gender?: string;
sexuality?: string;
bio?: string;
desires?: string[];
connectionGoals?: string[];
interests?: string[];
isMajestic?: boolean;
verificationStatus?: string;
distance?: { km: number; mi: number };
photos?: Array<{
id: string;
pictureUrls?: { small?: string; medium?: string; large?: string };
pictureType?: string;
}>;
interactionStatus?: {
mine?: string;
theirs?: string;
message?: string;
};
discoveredAt?: string;
_score: number;
_scoreBreakdown: Record<string, number>;
};
export type MatchFilters = {
search?: string;
minAge?: number;
maxAge?: number;
maxDistance?: number;
gender?: string;
verifiedOnly?: boolean;
theyLikedOnly?: boolean;
sort?: 'score' | 'distance' | 'recent';
};
export type MatchWeights = {
[key: string]: number;
};
const FILTERS_KEY = 'feeld_match_filters';
function loadSavedFilters(): MatchFilters {
try {
const saved = localStorage.getItem(FILTERS_KEY);
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
}
export function useMatches() {
const [matches, setMatches] = useState<MatchProfile[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filters, setFiltersState] = useState<MatchFilters>(loadSavedFilters);
const [weights, setWeights] = useState<MatchWeights | null>(null);
const [offset, setOffset] = useState(0);
const searchTimerRef = useRef<number | null>(null);
const setFilters = useCallback((newFilters: MatchFilters | ((prev: MatchFilters) => MatchFilters)) => {
setFiltersState(prev => {
const updated = typeof newFilters === 'function' ? newFilters(prev) : newFilters;
localStorage.setItem(FILTERS_KEY, JSON.stringify(updated));
return updated;
});
setOffset(0);
setMatches([]);
}, []);
const fetchMatches = useCallback(async (currentOffset = 0, append = false) => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filters.search) params.set('search', filters.search);
if (filters.minAge) params.set('minAge', String(filters.minAge));
if (filters.maxAge) params.set('maxAge', String(filters.maxAge));
if (filters.maxDistance) params.set('maxDistance', String(filters.maxDistance));
if (filters.gender) params.set('gender', filters.gender);
if (filters.verifiedOnly) params.set('verifiedOnly', 'true');
if (filters.theyLikedOnly) params.set('theyLikedOnly', 'true');
if (filters.sort && filters.sort !== 'score') params.set('sort', filters.sort);
params.set('limit', '50');
params.set('offset', String(currentOffset));
const res = await fetch(`/api/matches?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (append) {
setMatches(prev => [...prev, ...data.matches]);
} else {
setMatches(data.matches);
}
setTotal(data.total);
} catch (e) {
console.error('Failed to fetch matches:', e);
} finally {
setLoading(false);
}
}, [filters]);
const loadMore = useCallback(() => {
const newOffset = offset + 50;
setOffset(newOffset);
fetchMatches(newOffset, true);
}, [offset, fetchMatches]);
const removeMatch = useCallback((profileId: string) => {
setMatches(prev => prev.filter(m => m.id !== profileId));
setTotal(prev => Math.max(0, prev - 1));
}, []);
// Fetch weights
const fetchWeights = useCallback(async () => {
try {
const res = await fetch('/api/matches/weights');
if (!res.ok) return;
const data = await res.json();
setWeights(data.weights);
} catch (e) {
console.error('Failed to fetch weights:', e);
}
}, []);
const updateWeights = useCallback(async (newWeights: MatchWeights) => {
try {
const res = await fetch('/api/matches/weights', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ weights: newWeights }),
});
if (!res.ok) return;
const data = await res.json();
setWeights(data.weights);
// Re-fetch matches with new weights
setOffset(0);
setMatches([]);
} catch (e) {
console.error('Failed to update weights:', e);
}
}, []);
// Debounced fetch on filter change
useEffect(() => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = window.setTimeout(() => {
fetchMatches(0, false);
}, 300);
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
};
}, [fetchMatches]);
// Fetch weights on mount
useEffect(() => {
fetchWeights();
}, [fetchWeights]);
return {
matches,
total,
loading,
filters,
setFilters,
fetchMatches: () => { setOffset(0); setMatches([]); fetchMatches(0, false); },
loadMore,
removeMatch,
weights,
updateWeights,
hasMore: matches.length < total,
};
}

View File

@@ -68,7 +68,7 @@ export function useSentPings() {
const result = await apolloClient.query({
query: PROFILE_QUERY,
variables: { profileId: ping.targetProfileId },
fetchPolicy: 'cache-first',
fetchPolicy: 'network-first',
});
const profile = result.data?.profile;

View File

@@ -1,91 +1,102 @@
/* Import distinctive fonts - must be before tailwindcss */
/* Feeld — Mobile-First Design System 2026 */
/* Fonts: Clash Display (display) + Satoshi (body) */
@import url('https://api.fontshare.com/v2/css?f[]=clash-display@700,600,500&f[]=satoshi@400,500,700&display=swap');
@import "tailwindcss";
/* ─── Design Tokens ─── */
:root {
/* Navigation width for layout */
/* Navigation */
--nav-width: 0px;
--nav-height: 56px;
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-top: env(safe-area-inset-top, 0px);
/* Core palette - Midnight Velvet */
--color-void: #000000;
--color-midnight: #0a0a0f;
--color-deep: #12121a;
--color-surface: #1a1a24;
--color-surface-elevated: #24242f;
--color-surface-hover: #2e2e3a;
/* Surfaces — warm charcoal, not pure black */
--color-void: #121212;
--color-midnight: #161618;
--color-deep: #1a1a1e;
--color-surface: #1E1E22;
--color-surface-elevated: #262628;
--color-surface-hover: #2e2e32;
/* Accent colors */
--color-desire: #c41e3a;
--color-desire-glow: #e91e63;
--color-desire-soft: rgba(196, 30, 58, 0.15);
/* Accent — saturated purple + rose for dating context */
--color-desire: #7c3aed;
--color-desire-glow: #a855f7;
--color-desire-soft: rgba(124, 58, 237, 0.15);
--color-rose: #be3144;
--color-rose-glow: #e91e63;
--color-rose-gold: #b76e79;
--color-champagne: #f7e7ce;
/* Status colors */
/* Status */
--color-liked: #22c55e;
--color-liked-soft: rgba(34, 197, 94, 0.15);
--color-liked-soft: rgba(34, 197, 94, 0.12);
--color-passed: #ef4444;
--color-passed-soft: rgba(239, 68, 68, 0.15);
--color-passed-soft: rgba(239, 68, 68, 0.12);
--color-warning: #f59e0b;
/* Text hierarchy */
--color-text: #ffffff;
--color-text-primary: #ffffff;
--color-text-secondary: #9ca3af;
--color-text-muted: #6b7280;
/* Text — WCAG-compliant opacity levels */
--color-text: rgba(255, 255, 255, 0.87);
--color-text-primary: rgba(255, 255, 255, 0.87);
--color-text-secondary: rgba(255, 255, 255, 0.60);
--color-text-muted: rgba(255, 255, 255, 0.38);
/* Gradients */
--gradient-desire: linear-gradient(135deg, #c41e3a 0%, #e91e63 100%);
--gradient-surface: linear-gradient(180deg, rgba(26, 26, 36, 0.8) 0%, rgba(10, 10, 15, 0.95) 100%);
--gradient-glass: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
--gradient-photo-overlay: linear-gradient(180deg, transparent 0%, transparent 40%, rgba(0, 0, 0, 0.7) 70%, rgba(0, 0, 0, 0.95) 100%);
--gradient-desire: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
--gradient-rose: linear-gradient(135deg, #be3144 0%, #e91e63 100%);
--gradient-surface: linear-gradient(180deg, rgba(30, 30, 34, 0.8) 0%, rgba(18, 18, 18, 0.95) 100%);
--gradient-glass: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%);
--gradient-photo-overlay: linear-gradient(180deg, transparent 0%, transparent 50%, rgba(0, 0, 0, 0.6) 75%, rgba(0, 0, 0, 0.92) 100%);
/* Effects */
--glow-desire: 0 0 30px rgba(196, 30, 58, 0.3);
--glow-subtle: 0 0 60px rgba(196, 30, 58, 0.1);
--shadow-elevated: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.4);
--glow-desire: 0 0 24px rgba(124, 58, 237, 0.25);
--glow-subtle: 0 0 48px rgba(124, 58, 237, 0.08);
--shadow-elevated: 0 16px 48px -8px rgba(0, 0, 0, 0.5);
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.3);
/* Glass morphism */
--glass-bg: rgba(26, 26, 36, 0.7);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-bg: rgba(30, 30, 34, 0.72);
--glass-border: rgba(255, 255, 255, 0.06);
--glass-blur: blur(20px);
/* Typography */
--font-display: 'Clash Display', sans-serif;
--font-body: 'Satoshi', sans-serif;
/* Spacing rhythm */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
/* 8px spacing grid */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* Border radius */
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.5rem;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-sensual: 600ms cubic-bezier(0.22, 1, 0.36, 1);
--transition-spring: 500ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Desktop nav spacing */
/* Desktop nav offset */
@media (min-width: 768px) {
:root {
--nav-width: 104px;
--nav-width: 80px;
}
}
/* Base reset and defaults */
/* ─── Base Reset ─── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
@@ -101,14 +112,18 @@ html {
body {
font-family: var(--font-body);
font-weight: 400;
font-size: 16px;
background-color: var(--color-void);
color: var(--color-text);
min-height: 100vh;
line-height: 1.6;
min-height: 100dvh;
line-height: 1.5;
overflow-x: hidden;
overscroll-behavior: none;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
/* Ambient background effect */
/* Ambient glow — subtle purple in top-left */
body::before {
content: '';
position: fixed;
@@ -117,13 +132,13 @@ body::before {
right: 0;
bottom: 0;
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(196, 30, 58, 0.08) 0%, transparent 50%),
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(183, 110, 121, 0.05) 0%, transparent 50%);
radial-gradient(ellipse 70% 40% at 20% -10%, rgba(124, 58, 237, 0.06) 0%, transparent 50%),
radial-gradient(ellipse 50% 30% at 90% 100%, rgba(190, 49, 68, 0.04) 0%, transparent 50%);
pointer-events: none;
z-index: -1;
}
/* Subtle noise texture overlay */
/* Subtle film grain texture */
body::after {
content: '';
position: fixed;
@@ -131,20 +146,20 @@ body::after {
left: 0;
right: 0;
bottom: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
opacity: 0.015;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.012;
pointer-events: none;
z-index: 1000;
}
#root {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
position: relative;
}
/* Typography utilities */
/* ─── Typography ─── */
.font-display {
font-family: var(--font-display);
}
@@ -156,9 +171,9 @@ body::after {
background-clip: text;
}
/* Custom scrollbar */
/* ─── Scrollbar ─── */
::-webkit-scrollbar {
width: 6px;
width: 4px;
}
::-webkit-scrollbar-track {
@@ -174,35 +189,34 @@ body::after {
background: var(--color-surface-hover);
}
/* Selection styling */
/* Hide scrollbar on mobile */
@media (max-width: 767px) {
::-webkit-scrollbar {
display: none;
}
body {
scrollbar-width: none;
-ms-overflow-style: none;
}
}
/* ─── Selection ─── */
::selection {
background: var(--color-desire);
color: white;
}
/* Focus styles */
/* ─── Focus ─── */
:focus-visible {
outline: 2px solid var(--color-desire);
outline-offset: 2px;
}
/* Smooth page transitions */
.page-enter {
opacity: 0;
transform: translateY(10px);
}
.page-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity var(--transition-slow), transform var(--transition-slow);
}
/* Stagger animation utility */
/* ─── Animations ─── */
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(20px);
transform: translateY(12px);
}
to {
opacity: 1;
@@ -218,7 +232,7 @@ body::after {
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
transform: scale(0.96);
}
to {
opacity: 1;
@@ -226,10 +240,21 @@ body::after {
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
transform: translateX(16px);
}
to {
opacity: 1;
@@ -242,7 +267,7 @@ body::after {
box-shadow: var(--glow-desire);
}
50% {
box-shadow: 0 0 40px rgba(196, 30, 58, 0.4);
box-shadow: 0 0 32px rgba(124, 58, 237, 0.35);
}
}
@@ -256,16 +281,24 @@ body::after {
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes cardAppear {
from {
transform: rotate(0deg);
opacity: 0;
transform: translateY(8px);
}
to {
transform: rotate(360deg);
opacity: 1;
transform: translateY(0);
}
}
/* Utility classes */
.animate-fade-up {
animation: fadeSlideUp var(--transition-sensual) both;
animation: fadeSlideUp var(--transition-spring) both;
}
.animate-fade-in {
@@ -284,17 +317,21 @@ body::after {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Stagger delays */
.stagger-1 { animation-delay: 50ms; }
.stagger-2 { animation-delay: 100ms; }
.stagger-3 { animation-delay: 150ms; }
.stagger-4 { animation-delay: 200ms; }
.stagger-5 { animation-delay: 250ms; }
.stagger-6 { animation-delay: 300ms; }
.stagger-7 { animation-delay: 350ms; }
.stagger-8 { animation-delay: 400ms; }
.animate-card-appear {
animation: cardAppear 350ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Glass morphism utility */
/* Stagger delays */
.stagger-1 { animation-delay: 40ms; }
.stagger-2 { animation-delay: 80ms; }
.stagger-3 { animation-delay: 120ms; }
.stagger-4 { animation-delay: 160ms; }
.stagger-5 { animation-delay: 200ms; }
.stagger-6 { animation-delay: 240ms; }
.stagger-7 { animation-delay: 280ms; }
.stagger-8 { animation-delay: 320ms; }
/* ─── Glass morphism ─── */
.glass {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
@@ -302,7 +339,14 @@ body::after {
border: 1px solid var(--glass-border);
}
/* Shimmer loading effect */
.glass-subtle {
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
}
/* ─── Shimmer loading ─── */
.shimmer {
background: linear-gradient(
90deg,
@@ -311,24 +355,31 @@ body::after {
var(--color-surface) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
animation: shimmer 1.5s ease-in-out infinite;
}
/* Image hover zoom effect */
.img-zoom {
transition: transform var(--transition-sensual);
/* ─── Image zoom on hover (desktop only) ─── */
@media (hover: hover) {
.img-zoom {
transition: transform var(--transition-spring);
}
.img-zoom:hover {
transform: scale(1.03);
}
}
.img-zoom:hover {
transform: scale(1.05);
}
/* Magnetic button effect placeholder */
.magnetic {
/* ─── Button press effect ─── */
.btn-press {
transition: transform var(--transition-fast);
user-select: none;
-webkit-user-select: none;
}
/* Hide scrollbar utility */
.btn-press:active {
transform: scale(0.97);
}
/* ─── No scrollbar utility ─── */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
@@ -337,3 +388,42 @@ body::after {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* ─── Scroll snap for carousels ─── */
.snap-x {
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.snap-center {
scroll-snap-align: center;
}
.snap-start {
scroll-snap-align: start;
}
/* ─── Reduced motion ─── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ─── Safe area utilities ─── */
.safe-top {
padding-top: env(safe-area-inset-top, 0px);
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.safe-x {
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { apolloClient } from '../api/client';
import { EXPERIMENTAL_QUERIES, FILTERED_WHO_I_LIKED_MUTATION, INTERACTIONS_OUTGOING_QUERY, DIRECT_PROFILE_LOOKUP_QUERY } from '../api/operations/experimental';
import { WHO_LIKES_ME_QUERY, DISCOVER_PROFILES_QUERY } from '../api/operations/queries';
import { PAST_LIKES_QUERY, PAST_DISLIKES_QUERY } from '../api/operations/experimental';
import { WHO_LIKES_ME_QUERY, DISCOVER_PROFILES_QUERY, PROFILE_QUERY } from '../api/operations/queries';
const styles = {
container: {
@@ -188,21 +188,17 @@ export function ApiExplorerPage() {
setRunningAll(true);
setResults([]);
for (const { name, query } of EXPERIMENTAL_QUERIES) {
await runQuery(name, query);
// Small delay between requests
await new Promise(resolve => setTimeout(resolve, 500));
}
// Also try the mutation
await runMutation('filteredWhoILiked', FILTERED_WHO_I_LIKED_MUTATION, {
input: { filters: {}, sortBy: 'LAST_INTERACTION' },
// pastLikes - profiles you've liked (v8.11.0)
await runQuery('pastLikes', PAST_LIKES_QUERY, {
input: { sortDirection: 'NEWEST_FIRST' },
limit: 20,
});
await new Promise(resolve => setTimeout(resolve, 500));
// Try interactions with direction
await runQuery('interactions(direction: outgoing)', INTERACTIONS_OUTGOING_QUERY, {
sortBy: 'LAST_INTERACTION',
direction: 'OUTGOING',
// pastDislikes - profiles you've passed (v8.11.0)
await runQuery('pastDislikes', PAST_DISLIKES_QUERY, {
input: { sortDirection: 'NEWEST_FIRST' },
limit: 20,
});
setRunningAll(false);
@@ -246,7 +242,7 @@ export function ApiExplorerPage() {
anonymizedProfiles.slice(0, 3).map(async (profile: any) => {
try {
const result = await apolloClient.query({
query: DIRECT_PROFILE_LOOKUP_QUERY,
query: PROFILE_QUERY,
variables: { profileId: profile.id },
fetchPolicy: 'no-cache',
});
@@ -554,7 +550,7 @@ export function ApiExplorerPage() {
<div style={styles.header}>
<h1 style={styles.title}>API Explorer</h1>
<p style={styles.subtitle}>
Testing experimental queries to discover hidden "Who I Liked" endpoint
API Explorer pastLikes, pastDislikes, and discovery tools
</p>
</div>
@@ -607,34 +603,25 @@ export function ApiExplorerPage() {
<div style={styles.buttonGrid}>
{EXPERIMENTAL_QUERIES.map(({ name, query }) => (
<button
key={name}
onClick={() => runQuery(name, query)}
style={styles.button(loading === name)}
disabled={loading !== null}
>
{loading === name ? 'Testing...' : `Test: ${name}`}
</button>
))}
<button
onClick={() => runMutation('filteredWhoILiked', FILTERED_WHO_I_LIKED_MUTATION, {
input: { filters: {}, sortBy: 'LAST_INTERACTION' },
onClick={() => runQuery('pastLikes', PAST_LIKES_QUERY, {
input: { sortDirection: 'NEWEST_FIRST' },
limit: 20,
})}
style={styles.button(loading === 'filteredWhoILiked')}
style={styles.button(loading === 'pastLikes')}
disabled={loading !== null}
>
Test: filteredWhoILiked (mutation)
{loading === 'pastLikes' ? 'Loading...' : 'Past Likes (profiles you liked)'}
</button>
<button
onClick={() => runQuery('interactions(direction)', INTERACTIONS_OUTGOING_QUERY, {
sortBy: 'LAST_INTERACTION',
direction: 'OUTGOING',
onClick={() => runQuery('pastDislikes', PAST_DISLIKES_QUERY, {
input: { sortDirection: 'NEWEST_FIRST' },
limit: 20,
})}
style={styles.button(loading === 'interactions(direction)')}
style={styles.button(loading === 'pastDislikes')}
disabled={loading !== null}
>
Test: interactions(direction: OUTGOING)
{loading === 'pastDislikes' ? 'Loading...' : 'Past Dislikes (profiles you passed)'}
</button>
</div>
</div>

View File

@@ -1,7 +1,8 @@
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery } from '@apollo/client/react';
import { useQuery, useMutation } from '@apollo/client/react';
import { useState, useRef, useEffect } from 'react';
import { GET_CHAT_SUMMARY_QUERY } from '../api/operations/queries';
import { DISCONNECT_FROM_CHAT_MUTATION, CHAT_ACTIVATE_MUTATION } from '../api/operations/mutations';
import { useChannel, useStreamChat } from '../context/StreamChatContext';
import type { StreamMessage } from '../context/StreamChatContext';
import { LoadingPage } from '../components/ui/Loading';
@@ -180,8 +181,11 @@ export function ChatPage() {
const [inputText, setInputText] = useState('');
const [isSending, setIsSending] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Stream Chat connection
const { isConnected, isConnecting: streamConnecting, error: streamError, userId } = useStreamChat();
@@ -203,6 +207,47 @@ export function ChatPage() {
const summary = summaryData?.summary;
const displayName = summary?.name || chatName;
const displayAvatar = proxyImageUrl(summary?.avatarSet?.[0] || chatAvatar);
const chatStatus = summary?.status;
// Chat mutations
const [disconnectFromChat, { loading: disconnecting }] = useMutation(DISCONNECT_FROM_CHAT_MUTATION);
const [chatActivate, { loading: activating }] = useMutation(CHAT_ACTIVATE_MUTATION);
const handleDisconnect = async () => {
try {
await disconnectFromChat({
variables: { input: { chatId: summary?.id || channelId } },
});
navigate('/messages');
} catch (err) {
console.error('Failed to disconnect:', err);
}
};
const handleReactivate = async () => {
try {
await chatActivate({
variables: { input: { chatId: summary?.id || channelId } },
});
setShowMenu(false);
} catch (err) {
console.error('Failed to reactivate chat:', err);
}
};
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowMenu(false);
setShowDisconnectConfirm(false);
}
};
if (showMenu) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showMenu]);
// Scroll to bottom on new messages
useEffect(() => {
@@ -363,16 +408,100 @@ export function ChatPage() {
</div>
{/* More options */}
<button className="
w-10 h-10 rounded-full
hover:bg-white/[0.05]
flex items-center justify-center
transition-colors
">
<svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5 text-white/50">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
</svg>
</button>
<div className="relative" ref={menuRef}>
<button
onClick={() => { setShowMenu(!showMenu); setShowDisconnectConfirm(false); }}
className="
w-10 h-10 rounded-full
hover:bg-white/[0.05]
flex items-center justify-center
transition-colors
"
>
<svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5 text-white/50">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
</svg>
</button>
{showMenu && (
<div className="
absolute right-0 top-12 z-50
w-56 py-1.5
bg-[var(--color-surface-elevated)] backdrop-blur-xl
border border-white/[0.08]
rounded-xl shadow-xl shadow-black/40
animate-fade-up
">
{showDisconnectConfirm ? (
<div className="px-4 py-3">
<p className="text-sm text-[var(--color-text-primary)] mb-3 font-body">
Disconnect from {displayName}?
</p>
<div className="flex gap-2">
<button
onClick={() => setShowDisconnectConfirm(false)}
className="
flex-1 px-3 py-2 rounded-lg
bg-white/[0.06] hover:bg-white/[0.1]
text-sm text-[var(--color-text-muted)]
transition-colors font-body
"
>
Cancel
</button>
<button
onClick={handleDisconnect}
disabled={disconnecting}
className="
flex-1 px-3 py-2 rounded-lg
bg-red-500/20 hover:bg-red-500/30
text-sm text-red-400 font-medium
transition-colors font-body
disabled:opacity-50
"
>
{disconnecting ? 'Disconnecting...' : 'Disconnect'}
</button>
</div>
</div>
) : (
<>
{chatStatus === 'INACTIVE' && (
<button
onClick={handleReactivate}
disabled={activating}
className="
w-full flex items-center gap-3 px-4 py-2.5
hover:bg-white/[0.05] transition-colors
text-left
"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-5 h-5 text-emerald-400 flex-shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M2.985 19.644l3.182-3.182" />
</svg>
<span className="text-sm text-[var(--color-text-primary)] font-body">
{activating ? 'Reactivating...' : 'Reactivate Chat'}
</span>
</button>
)}
<button
onClick={() => setShowDisconnectConfirm(true)}
className="
w-full flex items-center gap-3 px-4 py-2.5
hover:bg-white/[0.05] transition-colors
text-left
"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-5 h-5 text-red-400 flex-shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.181 8.68l-4.503 4.504M8.678 8.68l4.503 4.504M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm text-red-400 font-body">Disconnect</span>
</button>
</>
)}
</div>
)}
</div>
</div>
{/* Messages area */}

View File

@@ -1,12 +1,14 @@
import { useQuery } from '@apollo/client/react';
import { useMutation, useQuery } from '@apollo/client/react';
import { gql } from '@apollo/client';
import { DISCOVER_PROFILES_QUERY } from '../api/operations/queries';
import { DEVICE_LOCATION_UPDATE_MUTATION } from '../api/operations/mutations';
import { ProfileCard } from '../components/profile/ProfileCard';
import { ProfileDetailModal } from '../components/profile/ProfileDetailModal';
import { LoadingPage, LoadingCards } from '../components/ui/Loading';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLocation } from '../hooks/useLocation';
import { apolloClient } from '../api/client';
import { authManager } from '../api/auth';
// Separate query for load more to avoid cache conflicts
const LOAD_MORE_PROFILES_QUERY = gql`
@@ -290,6 +292,22 @@ export function DiscoverPage() {
const savedLikedMeProfiles = useRef<Set<string>>(new Set());
const { location } = useLocation();
// Set device location on Feeld when Discover page loads (emulates app open GPS)
const [updateDeviceLocation] = useMutation(DEVICE_LOCATION_UPDATE_MUTATION);
const locationSetRef = useRef(false);
useEffect(() => {
if (location && !locationSetRef.current) {
locationSetRef.current = true;
updateDeviceLocation({
variables: { input: { latitude: location.latitude, longitude: location.longitude } },
}).then(() => {
console.log(`[Discover] Location set to ${location.name || `${location.latitude},${location.longitude}`}`);
}).catch((err) => {
console.error('[Discover] Failed to set location:', err);
});
}
}, [location, updateDeviceLocation]);
// Fetch rotation location (what the cron actually set on Feeld)
const [rotationLocation, setRotationLocation] = useState<string | null>(null);
useEffect(() => {
@@ -304,7 +322,7 @@ export function DiscoverPage() {
}, []);
// Save all discovered profiles to backend cache for Likes page enrichment
const saveDiscoveredProfiles = useCallback(async (profiles: any[]) => {
const saveDiscoveredProfiles = useCallback(async (profiles: any[], discoveredLocation?: string) => {
if (!profiles.length) return;
const safeStr = (v: any) => (typeof v === 'string' ? v : '');
@@ -321,6 +339,7 @@ export function DiscoverPage() {
connectionGoals: p.connectionGoals,
verificationStatus: p.verificationStatus,
interactionStatus: p.interactionStatus,
discoveredLocation: discoveredLocation ?? null,
discoveredAt: new Date().toISOString(),
}));
@@ -414,9 +433,9 @@ export function DiscoverPage() {
setHasMore(data.discovery.hasNextBatch ?? false);
initialLoadDone.current = true;
// Cache all discovered profiles for Likes page enrichment
saveDiscoveredProfiles(data.discovery.nodes);
saveDiscoveredProfiles(data.discovery.nodes, location?.name || rotationLocation || undefined);
}
}, [data]);
}, [data, location?.name, rotationLocation]);
// Save profiles that liked me to the backend for matching on Likes page
useEffect(() => {
@@ -458,7 +477,7 @@ export function DiscoverPage() {
setAllProfiles(prev => [...prev, ...newProfiles]);
setHasMore(result.data?.discovery?.hasNextBatch ?? false);
// Cache all discovered profiles for Likes page enrichment
saveDiscoveredProfiles(newProfiles);
saveDiscoveredProfiles(newProfiles, location?.name || rotationLocation || undefined);
} catch (err) {
console.error('Failed to load more profiles:', err);
} finally {
@@ -469,6 +488,8 @@ export function DiscoverPage() {
if (loading) return <LoadingPage message="Finding connections..." />;
if (error) {
// Auto-retry on auth errors: force refresh token and refetch
const isAuthError = error.message?.includes('Unauthorized') || error.message?.includes('UNAUTHENTICATED');
return (
<div style={styles.emptyState}>
<div style={styles.emptyIcon('error')}>
@@ -478,6 +499,45 @@ export function DiscoverPage() {
</div>
<h2 style={styles.emptyTitle}>Connection Error</h2>
<p style={styles.emptyText}>{error.message}</p>
<button
onClick={async () => {
try { await authManager.forceRefresh(); } catch {}
window.location.reload();
}}
style={{
marginTop: '16px',
padding: '14px 32px',
background: 'var(--gradient-desire, linear-gradient(135deg, #7c3aed, #a855f7))',
border: 'none',
borderRadius: '12px',
color: '#fff',
fontSize: '15px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
}}
>
{isAuthError ? 'Reconnect' : 'Retry'}
</button>
{isAuthError && (
<button
onClick={async () => { try { await authManager.forceRefresh(); } catch {} window.location.reload(); }}
style={{
marginTop: '8px',
padding: '10px 24px',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: '12px',
color: 'rgba(255,255,255,0.5)',
fontSize: '13px',
fontWeight: 500,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
}}
>
Reset Credentials
</button>
)}
</div>
);
}

View File

@@ -1,12 +1,12 @@
import { useMutation } from '@apollo/client/react';
import { gql } from '@apollo/client';
import { WHO_LIKES_ME_QUERY, WHO_PINGS_ME_QUERY, PROFILE_QUERY } from '../api/operations/queries';
import { DEVICE_LOCATION_UPDATE_MUTATION } from '../api/operations/mutations';
import { DEVICE_LOCATION_UPDATE_MUTATION, UNDO_PROFILE_DISLIKE_MUTATION, PROFILE_ACCEPT_PING_MUTATION, PROFILE_REJECT_PING_MUTATION } from '../api/operations/mutations';
import { PAST_LIKES_QUERY, PAST_DISLIKES_QUERY } from '../api/operations/experimental';
import { ProfileCard } from '../components/profile/ProfileCard';
import { ProfileDetailModal } from '../components/profile/ProfileDetailModal';
import { LoadingPage, LoadingCards } from '../components/ui/Loading';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useLikedProfiles } from '../hooks/useLikedProfiles';
import { useDislikedProfiles } from '../hooks/useDislikedProfiles';
import { apolloClient } from '../api/client';
import { useLocation } from '../hooks/useLocation';
@@ -339,8 +339,6 @@ interface WhoLikedYouProfile {
export function LikesPage() {
const [activeTab, setActiveTab] = useState<Tab>('likes');
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [youLikedProfiles, setYouLikedProfiles] = useState<any[]>([]);
const [youLikedLoading, setYouLikedLoading] = useState(false);
// Scanner state
const [selectedLocationId, setSelectedLocationId] = useState<string>('');
@@ -367,12 +365,33 @@ export function LikesPage() {
const { savedLocations, setLocation } = useLocation();
const [updateLocation] = useMutation(DEVICE_LOCATION_UPDATE_MUTATION);
const { likedProfiles, getLikedProfileIds, removeLikedProfile } = useLikedProfiles();
const { dislikedProfiles, dislikeProfile, isDisliked, removeDislikedProfile } = useDislikedProfiles();
const { dislikeProfile, isDisliked } = useDislikedProfiles();
// Track which profiles are currently being disliked
const [dislikingProfiles, setDislikingProfiles] = useState<Set<string>>(new Set());
// Past Likes (You Liked tab - from API)
const [pastLikes, setPastLikes] = useState<any[]>([]);
const [pastLikesLoading, setPastLikesLoading] = useState(false);
const [pastLikesTotal, setPastLikesTotal] = useState(0);
const [pastLikesCursor, setPastLikesCursor] = useState<string | null>(null);
const [pastLikesHasMore, setPastLikesHasMore] = useState(false);
// Past Dislikes (Passed tab - from API)
const [pastDislikes, setPastDislikes] = useState<any[]>([]);
const [pastDislikesLoading, setPastDislikesLoading] = useState(false);
const [pastDislikesTotal, setPastDislikesTotal] = useState(0);
const [pastDislikesCursor, setPastDislikesCursor] = useState<string | null>(null);
const [pastDislikesHasMore, setPastDislikesHasMore] = useState(false);
// Undo dislike state
const [undoLoading, setUndoLoading] = useState(false);
const [undoError, setUndoError] = useState<string | null>(null);
// Ping accept/reject state
const [pingActionLoading, setPingActionLoading] = useState<Set<string>>(new Set());
const [pingMatched, setPingMatched] = useState<Set<string>>(new Set());
// Discovered profiles cache (all profiles seen in Discover, without photos)
const [discoveredProfiles, setDiscoveredProfiles] = useState<any[]>([]);
@@ -482,7 +501,7 @@ export function LikesPage() {
}, [fetchAllLikes, fetchAllPings]);
// Helper: batch-save discovered profiles to cache (strips photos, fire-and-forget)
const saveDiscoveredProfiles = useCallback(async (profiles: any[]) => {
const saveDiscoveredProfiles = useCallback(async (profiles: any[], discoveredLocation?: string) => {
if (!profiles.length) return;
const safeStr = (v: any) => (typeof v === 'string' ? v : '');
const stripped = profiles.map(p => ({
@@ -496,6 +515,7 @@ export function LikesPage() {
connectionGoals: p.connectionGoals,
verificationStatus: p.verificationStatus,
interactionStatus: p.interactionStatus,
discoveredLocation: discoveredLocation ?? null,
discoveredAt: new Date().toISOString(),
}));
try {
@@ -577,7 +597,7 @@ export function LikesPage() {
});
// Save ALL scanned profiles to discovered cache for future Likes enrichment
saveDiscoveredProfiles(profiles);
saveDiscoveredProfiles(profiles, location.name);
setScanStatus(scanAbortRef.current.signal.aborted
? `Cancelled. Scanned ${profiles.length} profiles, found ${matches} who liked you.`
@@ -644,7 +664,7 @@ export function LikesPage() {
});
// Save ALL scanned profiles to discovered cache
saveDiscoveredProfiles(profiles);
saveDiscoveredProfiles(profiles, location.name);
grandTotalProfiles += profiles.length;
grandTotalMatches += matches;
@@ -1056,57 +1076,184 @@ export function LikesPage() {
}
}, [dislikeProfile, fetchAllLikes]);
// Fetch profiles for "You Liked" tab
useEffect(() => {
const fetchYouLikedProfiles = async () => {
const ids = getLikedProfileIds();
if (ids.length === 0) {
setYouLikedProfiles([]);
return;
}
// Fetch past likes from API (You Liked tab)
const fetchPastLikes = useCallback(async (cursor?: string | null) => {
setPastLikesLoading(true);
try {
const result = await apolloClient.query({
query: PAST_LIKES_QUERY,
variables: {
input: { sortDirection: 'NEWEST_FIRST' },
limit: 25,
...(cursor ? { cursor } : {}),
},
fetchPolicy: 'network-only',
});
setYouLikedLoading(true);
try {
const profiles = await Promise.all(
ids.slice(0, 50).map(async (id) => { // Limit to 50
try {
const result = await apolloClient.query({
query: PROFILE_QUERY,
variables: { profileId: id },
fetchPolicy: 'cache-first',
});
return result.data?.profile;
} catch (e) {
console.error(`Failed to fetch profile ${id}:`, e);
return null;
}
})
);
setYouLikedProfiles(profiles.filter(Boolean));
} catch (e) {
console.error('Failed to fetch liked profiles:', e);
} finally {
setYouLikedLoading(false);
}
};
const nodes = result.data?.pastLikes?.nodes || [];
const pageInfo = result.data?.pastLikes?.pageInfo;
if (activeTab === 'youLiked') {
fetchYouLikedProfiles();
if (cursor) {
setPastLikes(prev => [...prev, ...nodes]);
} else {
setPastLikes(nodes);
}
setPastLikesTotal(pageInfo?.total || 0);
setPastLikesCursor(pageInfo?.nextPageCursor || null);
setPastLikesHasMore(pageInfo?.hasNextPage || false);
console.log(`Fetched ${nodes.length} past likes, total: ${pageInfo?.total}, hasMore: ${pageInfo?.hasNextPage}`);
} catch (e) {
console.error('Failed to fetch past likes:', e);
} finally {
setPastLikesLoading(false);
}
}, [activeTab, likedProfiles.length]);
}, []);
// Fetch past dislikes from API (Passed tab)
const fetchPastDislikes = useCallback(async (cursor?: string | null) => {
setPastDislikesLoading(true);
try {
const result = await apolloClient.query({
query: PAST_DISLIKES_QUERY,
variables: {
input: { sortDirection: 'NEWEST_FIRST' },
limit: 25,
...(cursor ? { cursor } : {}),
},
fetchPolicy: 'network-only',
});
const nodes = result.data?.pastDislikes?.nodes || [];
const pageInfo = result.data?.pastDislikes?.pageInfo;
if (cursor) {
setPastDislikes(prev => [...prev, ...nodes]);
} else {
setPastDislikes(nodes);
}
setPastDislikesTotal(pageInfo?.total || 0);
setPastDislikesCursor(pageInfo?.nextPageCursor || null);
setPastDislikesHasMore(pageInfo?.hasNextPage || false);
console.log(`Fetched ${nodes.length} past dislikes, total: ${pageInfo?.total}, hasMore: ${pageInfo?.hasNextPage}`);
} catch (e) {
console.error('Failed to fetch past dislikes:', e);
} finally {
setPastDislikesLoading(false);
}
}, []);
// Undo last dislike
const handleUndoDislike = useCallback(async () => {
setUndoLoading(true);
setUndoError(null);
try {
await apolloClient.mutate({
mutation: UNDO_PROFILE_DISLIKE_MUTATION,
});
console.log('Undo dislike successful');
// Refetch to show updated list
fetchPastDislikes();
} catch (e: any) {
const msg = e?.graphQLErrors?.[0]?.message || e?.message || 'Unknown error';
if (msg.toLowerCase().includes('majestic') || msg.toLowerCase().includes('not majestic')) {
setUndoError('Account is not majestic');
} else {
setUndoError(msg);
}
console.error('Undo dislike failed:', e);
} finally {
setUndoLoading(false);
}
}, [fetchPastDislikes]);
// Handle accept ping
const handleAcceptPing = useCallback(async (profileId: string) => {
setPingActionLoading(prev => new Set(prev).add(profileId));
try {
const result = await apolloClient.mutate({
mutation: PROFILE_ACCEPT_PING_MUTATION,
variables: { targetProfileId: profileId },
});
console.log('Accept ping result:', result.data);
// Show matched indicator briefly
setPingMatched(prev => new Set(prev).add(profileId));
setTimeout(() => {
setPingMatched(prev => {
const next = new Set(prev);
next.delete(profileId);
return next;
});
// Remove from pings list
setAllPings(prev => prev.filter(p => p.id !== profileId));
setPingsTotal(prev => Math.max(0, prev - 1));
}, 1500);
} catch (e) {
console.error('Failed to accept ping:', e);
} finally {
setPingActionLoading(prev => {
const next = new Set(prev);
next.delete(profileId);
return next;
});
}
}, []);
// Handle reject ping
const handleRejectPing = useCallback(async (profileId: string) => {
setPingActionLoading(prev => new Set(prev).add(profileId));
try {
await apolloClient.mutate({
mutation: PROFILE_REJECT_PING_MUTATION,
variables: { targetProfileId: profileId },
});
console.log('Rejected ping from:', profileId);
// Remove from pings list
setAllPings(prev => prev.filter(p => p.id !== profileId));
setPingsTotal(prev => Math.max(0, prev - 1));
} catch (e) {
console.error('Failed to reject ping:', e);
} finally {
setPingActionLoading(prev => {
const next = new Set(prev);
next.delete(profileId);
return next;
});
}
}, []);
// Fetch past likes/dislikes when tab is selected
useEffect(() => {
if (activeTab === 'youLiked') {
fetchPastLikes();
} else if (activeTab === 'disliked') {
fetchPastDislikes();
}
}, [activeTab, fetchPastLikes, fetchPastDislikes]);
const loading = likesLoading || pingsLoading;
if (loading && activeTab !== 'youLiked') return <LoadingPage message="Loading interactions..." />;
if (loading && activeTab !== 'youLiked' && activeTab !== 'disliked') return <LoadingPage message="Loading interactions..." />;
const likes = enrichedLikes;
const pings = allPings;
const youLikedTotal = likedProfiles.length;
// Count how many likes have been matched with real data
const matchedCount = enrichedLikes.filter((p: any) => p._matched).length;
const currentProfiles = activeTab === 'likes' ? likes : activeTab === 'pings' ? pings : activeTab === 'disliked' ? dislikedProfiles : youLikedProfiles;
// Map pastLikes nodes to profiles for the grid
const pastLikesProfiles = pastLikes.map((node: any) => ({
...node.profile,
_isPing: node.isPing,
_interactionSentAt: node.interactionSentAt,
}));
// Map pastDislikes nodes to profiles for the grid
const pastDislikesProfiles = pastDislikes.map((node: any) => ({
...node.profile,
_interactionSentAt: node.interactionSentAt,
}));
const currentProfiles = activeTab === 'likes' ? likes : activeTab === 'pings' ? pings : activeTab === 'disliked' ? pastDislikesProfiles : pastLikesProfiles;
return (
<div style={styles.container}>
@@ -1191,7 +1338,7 @@ export function LikesPage() {
</svg>
You Liked
<span style={styles.tabBadge(activeTab === 'youLiked', 'youLiked')}>
{youLikedTotal}
{pastLikesTotal || pastLikesProfiles.length}
</span>
</button>
@@ -1204,7 +1351,7 @@ export function LikesPage() {
</svg>
Passed
<span style={styles.tabBadge(activeTab === 'disliked', 'disliked')}>
{dislikedProfiles.length}
{pastDislikesTotal || pastDislikesProfiles.length}
</span>
</button>
</div>
@@ -1316,14 +1463,65 @@ export function LikesPage() {
)}
{/* Loading state for You Liked */}
{activeTab === 'youLiked' && youLikedLoading && (
{activeTab === 'youLiked' && pastLikesLoading && pastLikes.length === 0 && (
<div style={styles.profileGrid}>
<LoadingCards count={6} />
</div>
)}
{/* Loading state for Passed */}
{activeTab === 'disliked' && pastDislikesLoading && pastDislikes.length === 0 && (
<div style={styles.profileGrid}>
<LoadingCards count={6} />
</div>
)}
{/* Undo Last Pass button for Passed tab */}
{activeTab === 'disliked' && pastDislikesProfiles.length > 0 && (
<div style={{ marginBottom: '16px' }}>
<button
onClick={handleUndoDislike}
disabled={undoLoading}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 18px',
borderRadius: '10px',
border: '1px solid rgba(100,116,139,0.3)',
background: undoLoading ? 'rgba(100,116,139,0.1)' : 'rgba(100,116,139,0.15)',
color: undoLoading ? 'rgba(255,255,255,0.4)' : 'rgba(255,255,255,0.8)',
fontSize: '13px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: undoLoading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '16px', height: '16px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" />
</svg>
{undoLoading ? 'Undoing...' : 'Undo Last Pass'}
</button>
{undoError && (
<div style={{
marginTop: '8px',
padding: '8px 14px',
borderRadius: '8px',
background: 'rgba(245,158,11,0.15)',
border: '1px solid rgba(245,158,11,0.3)',
color: '#fcd34d',
fontSize: '13px',
fontFamily: "'Satoshi', sans-serif",
}}>
{undoError}
</div>
)}
</div>
)}
{/* Empty State */}
{currentProfiles.length === 0 && !(activeTab === 'youLiked' && youLikedLoading) ? (
{currentProfiles.length === 0 && !(activeTab === 'youLiked' && pastLikesLoading && pastLikes.length === 0) && !(activeTab === 'disliked' && pastDislikesLoading && pastDislikes.length === 0) ? (
<div style={styles.emptyState}>
<div style={styles.emptyIcon(activeTab)}>
{activeTab === 'likes' ? (
@@ -1353,34 +1551,222 @@ export function LikesPage() {
: activeTab === 'pings'
? 'Pings from interested members will show up here'
: activeTab === 'disliked'
? "Profiles you've passed on will be saved here"
: "Profiles you've liked will be saved here"
? "Profiles you've passed on will appear here"
: "Profiles you've liked will appear here"
}
</p>
</div>
) : !(activeTab === 'youLiked' && youLikedLoading) && (
/* Profiles Grid */
<div style={styles.profileGrid}>
{currentProfiles.map((profile: any, index: number) => {
// Only show dislike for profiles that have been matched with real scanner data
// The _matched flag is set by enrichLikesWithRealData when we have real profile info
const canDislike = activeTab === 'likes' && profile._matched && !isDisliked(profile.id);
) : !(activeTab === 'youLiked' && pastLikesLoading && pastLikes.length === 0) && !(activeTab === 'disliked' && pastDislikesLoading && pastDislikes.length === 0) && (
<>
{/* Profiles Grid */}
<div style={styles.profileGrid}>
{currentProfiles.map((profile: any, index: number) => {
// Only show dislike for profiles that have been matched with real scanner data
const canDislike = activeTab === 'likes' && profile._matched && !isDisliked(profile.id);
return (
<ProfileCard
key={profile.id}
profile={profile}
onClick={() => setSelectedProfileId(profile.id)}
index={index}
onRefresh={(id) => refreshProfile(id, profile.imaginaryName)}
isRefreshing={refreshingProfiles.has(profile.id)}
onDislike={canDislike ? handleDislikeFromLikes : undefined}
isDisliking={dislikingProfiles.has(profile.id)}
showDislike={canDislike}
/>
);
})}
</div>
return (
<div key={profile.id || index} style={{ position: 'relative' }}>
{/* Ping badge for You Liked tab */}
{activeTab === 'youLiked' && profile._isPing && (
<div style={{
position: 'absolute',
top: '8px',
left: '8px',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '3px 8px',
borderRadius: '6px',
background: 'rgba(245,158,11,0.85)',
color: '#fff',
fontSize: '11px',
fontWeight: 700,
fontFamily: "'Satoshi', sans-serif",
backdropFilter: 'blur(4px)',
}}>
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '10px', height: '10px' }}>
<path d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
Ping
</div>
)}
{/* Timestamp badge for You Liked and Passed tabs */}
{(activeTab === 'youLiked' || activeTab === 'disliked') && profile._interactionSentAt && (
<div style={{
position: 'absolute',
top: '8px',
right: '8px',
zIndex: 10,
padding: '3px 8px',
borderRadius: '6px',
background: 'rgba(0,0,0,0.65)',
color: 'rgba(255,255,255,0.8)',
fontSize: '10px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
backdropFilter: 'blur(4px)',
}}>
{new Date(profile._interactionSentAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</div>
)}
{/* Matched indicator for accepted pings */}
{activeTab === 'pings' && pingMatched.has(profile.id) && (
<div style={{
position: 'absolute',
inset: 0,
zIndex: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(34,197,94,0.85)',
borderRadius: '12px',
backdropFilter: 'blur(4px)',
}}>
<span style={{
color: '#fff',
fontSize: '24px',
fontWeight: 700,
fontFamily: "'Clash Display', sans-serif",
}}>Matched!</span>
</div>
)}
<ProfileCard
profile={profile}
onClick={() => setSelectedProfileId(profile.id)}
index={index}
onRefresh={activeTab === 'likes' ? (id) => refreshProfile(id, profile.imaginaryName) : undefined}
isRefreshing={refreshingProfiles.has(profile.id)}
onDislike={canDislike ? handleDislikeFromLikes : undefined}
isDisliking={dislikingProfiles.has(profile.id)}
showDislike={canDislike}
/>
{/* Accept/Reject buttons for Pings tab */}
{activeTab === 'pings' && !pingMatched.has(profile.id) && (
<div style={{
display: 'flex',
gap: '8px',
marginTop: '8px',
}}>
<button
onClick={(e) => { e.stopPropagation(); handleRejectPing(profile.id); }}
disabled={pingActionLoading.has(profile.id)}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: '10px',
borderRadius: '10px',
border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.15)',
color: '#fca5a5',
fontSize: '13px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: pingActionLoading.has(profile.id) ? 'not-allowed' : 'pointer',
opacity: pingActionLoading.has(profile.id) ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ width: '16px', height: '16px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
{pingActionLoading.has(profile.id) ? '...' : 'Reject'}
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAcceptPing(profile.id); }}
disabled={pingActionLoading.has(profile.id)}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: '10px',
borderRadius: '10px',
border: 'none',
background: pingActionLoading.has(profile.id) ? 'rgba(34,197,94,0.2)' : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
color: '#fff',
fontSize: '13px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: pingActionLoading.has(profile.id) ? 'not-allowed' : 'pointer',
opacity: pingActionLoading.has(profile.id) ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" style={{ width: '16px', height: '16px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{pingActionLoading.has(profile.id) ? '...' : 'Accept'}
</button>
</div>
)}
</div>
);
})}
</div>
{/* Load More button for You Liked tab */}
{activeTab === 'youLiked' && pastLikesHasMore && (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '24px' }}>
<button
onClick={() => fetchPastLikes(pastLikesCursor)}
disabled={pastLikesLoading}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 28px',
borderRadius: '10px',
border: '1px solid rgba(34,197,94,0.3)',
background: pastLikesLoading ? 'rgba(34,197,94,0.1)' : 'rgba(34,197,94,0.15)',
color: pastLikesLoading ? 'rgba(255,255,255,0.4)' : '#86efac',
fontSize: '14px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: pastLikesLoading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
}}
>
{pastLikesLoading ? 'Loading...' : `Load More (${pastLikesProfiles.length} of ${pastLikesTotal})`}
</button>
</div>
)}
{/* Load More button for Passed tab */}
{activeTab === 'disliked' && pastDislikesHasMore && (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '24px' }}>
<button
onClick={() => fetchPastDislikes(pastDislikesCursor)}
disabled={pastDislikesLoading}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 28px',
borderRadius: '10px',
border: '1px solid rgba(100,116,139,0.3)',
background: pastDislikesLoading ? 'rgba(100,116,139,0.1)' : 'rgba(100,116,139,0.15)',
color: pastDislikesLoading ? 'rgba(255,255,255,0.4)' : '#94a3b8',
fontSize: '14px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: pastDislikesLoading ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
}}
>
{pastDislikesLoading ? 'Loading...' : `Load More (${pastDislikesProfiles.length} of ${pastDislikesTotal})`}
</button>
</div>
)}
</>
)}
{/* Profile Detail Modal */}

950
web/src/pages/Matches.tsx Normal file
View File

@@ -0,0 +1,950 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@apollo/client/react';
import { ProfileCard } from '../components/profile/ProfileCard';
import { ProfileDetailModal } from '../components/profile/ProfileDetailModal';
import { useMatches } from '../hooks/useMatches';
import type { MatchFilters, MatchWeights } from '../hooks/useMatches';
import { apolloClient } from '../api/client';
import { PROFILE_QUERY, PROFILE_MATCHES_QUERY, GET_PROFILE_CONNECTIONS_QUERY } from '../api/operations/queries';
import { authManager } from '../api/auth';
const safeText = (v: any): string => {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return '';
};
const styles = {
container: {
paddingBottom: '48px',
},
header: {
marginBottom: '24px',
},
title: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '36px',
fontWeight: 700,
color: '#ffffff',
margin: 0,
marginBottom: '8px',
letterSpacing: '-0.02em',
},
subtitle: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
color: 'rgba(255,255,255,0.5)',
margin: 0,
},
headerActions: {
display: 'flex',
gap: '8px',
marginTop: '12px',
},
iconBtn: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
borderRadius: '10px',
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.03)',
color: 'rgba(255,255,255,0.7)',
cursor: 'pointer',
fontSize: '18px',
transition: 'all 0.2s',
},
// Filter bar
filterBar: {
display: 'flex',
flexWrap: 'wrap' as const,
gap: '10px',
marginBottom: '20px',
alignItems: 'center',
},
searchInput: {
flex: '1 1 200px',
minWidth: '200px',
padding: '10px 14px',
borderRadius: '10px',
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.03)',
color: '#ffffff',
fontSize: '14px',
fontFamily: "'Satoshi', sans-serif",
outline: 'none',
},
numberInput: {
width: '70px',
padding: '10px 8px',
borderRadius: '10px',
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.03)',
color: '#ffffff',
fontSize: '14px',
fontFamily: "'Satoshi', sans-serif",
outline: 'none',
textAlign: 'center' as const,
},
select: {
padding: '10px 14px',
borderRadius: '10px',
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.05)',
color: '#ffffff',
fontSize: '14px',
fontFamily: "'Satoshi', sans-serif",
outline: 'none',
cursor: 'pointer',
},
toggleBtn: (active: boolean) => ({
padding: '10px 16px',
borderRadius: '10px',
border: active ? '1px solid rgba(168,85,247,0.4)' : '1px solid rgba(255,255,255,0.1)',
background: active ? 'rgba(168,85,247,0.15)' : 'rgba(255,255,255,0.03)',
color: active ? '#c084fc' : 'rgba(255,255,255,0.6)',
fontSize: '13px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 0.2s',
whiteSpace: 'nowrap' as const,
}),
clearBtn: {
padding: '10px 14px',
borderRadius: '10px',
border: '1px solid rgba(239,68,68,0.2)',
background: 'rgba(239,68,68,0.08)',
color: '#f87171',
fontSize: '13px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
whiteSpace: 'nowrap' as const,
},
filterLabel: {
fontSize: '12px',
color: 'rgba(255,255,255,0.4)',
fontFamily: "'Satoshi', sans-serif",
whiteSpace: 'nowrap' as const,
},
// Sort bar
sortBar: {
display: 'flex',
gap: '8px',
marginBottom: '20px',
alignItems: 'center',
},
sortLabel: {
fontSize: '13px',
color: 'rgba(255,255,255,0.4)',
fontFamily: "'Satoshi', sans-serif",
marginRight: '4px',
},
sortBtn: (active: boolean) => ({
padding: '6px 14px',
borderRadius: '8px',
border: 'none',
background: active ? 'rgba(255,255,255,0.1)' : 'transparent',
color: active ? '#ffffff' : 'rgba(255,255,255,0.5)',
fontSize: '13px',
fontWeight: active ? 600 : 400,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 0.2s',
}),
// Grid
profileGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: '16px',
},
cardWrapper: {
position: 'relative' as const,
},
scoreBadge: {
position: 'absolute' as const,
bottom: '-8px',
left: '50%',
transform: 'translateX(-50%)',
padding: '4px 14px',
borderRadius: '12px',
background: 'linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%)',
color: '#ffffff',
fontSize: '13px',
fontWeight: 700,
fontFamily: "'Satoshi', sans-serif",
boxShadow: '0 2px 8px rgba(124,58,237,0.4)',
zIndex: 10,
cursor: 'default',
whiteSpace: 'nowrap' as const,
},
scoreTooltip: {
position: 'absolute' as const,
bottom: '32px',
left: '50%',
transform: 'translateX(-50%)',
padding: '10px 14px',
borderRadius: '10px',
background: 'rgba(15,15,20,0.95)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#ffffff',
fontSize: '12px',
fontFamily: "'Satoshi', sans-serif",
zIndex: 20,
whiteSpace: 'nowrap' as const,
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
},
tooltipRow: {
display: 'flex',
justifyContent: 'space-between',
gap: '16px',
padding: '2px 0',
},
tooltipLabel: {
color: 'rgba(255,255,255,0.6)',
},
tooltipValue: {
color: '#a78bfa',
fontWeight: 600,
},
// Load more
loadMoreBtn: {
display: 'block',
margin: '32px auto 0',
padding: '14px 32px',
borderRadius: '14px',
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.03)',
color: '#ffffff',
fontSize: '15px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 0.2s',
},
// Empty state
emptyState: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
padding: '80px 20px',
},
emptyIcon: {
width: '80px',
height: '80px',
borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(168,85,247,0.15) 0%, rgba(168,85,247,0.05) 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
fontSize: '32px',
},
emptyTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '22px',
fontWeight: 600,
color: '#ffffff',
marginBottom: '8px',
textAlign: 'center' as const,
},
emptyText: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
color: 'rgba(255,255,255,0.5)',
textAlign: 'center' as const,
maxWidth: '320px',
},
// Settings panel
settingsPanel: {
marginBottom: '24px',
padding: '20px',
borderRadius: '14px',
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.06)',
},
settingsHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
},
settingsTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '18px',
fontWeight: 600,
color: '#ffffff',
},
weightRow: {
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '8px',
},
weightLabel: {
flex: '0 0 160px',
fontSize: '13px',
color: 'rgba(255,255,255,0.6)',
fontFamily: "'Satoshi', sans-serif",
},
weightSlider: {
flex: 1,
accentColor: '#7c3aed',
},
weightValue: {
flex: '0 0 36px',
fontSize: '13px',
color: '#a78bfa',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
textAlign: 'right' as const,
},
// Loading
loadingContainer: {
display: 'flex',
justifyContent: 'center',
padding: '60px 20px',
color: 'rgba(255,255,255,0.5)',
fontFamily: "'Satoshi', sans-serif",
fontSize: '15px',
},
// Gender pills
genderPills: {
display: 'flex',
gap: '6px',
},
// Mode toggle
modeToggleBar: {
display: 'flex',
gap: '4px',
marginBottom: '20px',
padding: '4px',
borderRadius: '12px',
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
width: 'fit-content',
},
modeBtn: (active: boolean) => ({
padding: '8px 20px',
borderRadius: '10px',
border: 'none',
background: active ? 'rgba(124,58,237,0.2)' : 'transparent',
color: active ? '#c084fc' : 'rgba(255,255,255,0.5)',
fontSize: '14px',
fontWeight: active ? 600 : 400,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 0.2s',
}),
// API Matches section
sectionTitle: {
fontFamily: "'Clash Display', sans-serif",
fontSize: '20px',
fontWeight: 600,
color: '#ffffff',
margin: '0 0 16px 0',
},
sectionSubtitle: {
fontFamily: "'Satoshi', sans-serif",
fontSize: '13px',
color: 'rgba(255,255,255,0.4)',
margin: '-12px 0 16px 0',
},
sectionDivider: {
margin: '32px 0',
border: 'none',
borderTop: '1px solid rgba(255,255,255,0.06)',
},
messageBtn: {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
marginTop: '8px',
padding: '6px 14px',
borderRadius: '8px',
border: '1px solid rgba(168,85,247,0.3)',
background: 'rgba(168,85,247,0.1)',
color: '#c084fc',
fontSize: '12px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
cursor: 'pointer',
transition: 'all 0.2s',
},
matchCardWrapper: {
position: 'relative' as const,
},
chatStatusBadge: {
position: 'absolute' as const,
bottom: '-8px',
left: '50%',
transform: 'translateX(-50%)',
padding: '4px 14px',
borderRadius: '12px',
background: 'linear-gradient(135deg, #059669 0%, #047857 100%)',
color: '#ffffff',
fontSize: '12px',
fontWeight: 600,
fontFamily: "'Satoshi', sans-serif",
boxShadow: '0 2px 8px rgba(5,150,105,0.4)',
zIndex: 10,
whiteSpace: 'nowrap' as const,
},
};
const GENDER_OPTIONS = ['WOMAN', 'MAN', 'NON_BINARY'];
const DISTANCE_OPTIONS = [
{ label: '15mi', value: 15 },
{ label: '30mi', value: 30 },
{ label: '50mi', value: 50 },
{ label: '100mi', value: 100 },
{ label: 'Any', value: 0 },
];
const WEIGHT_LABELS: Record<string, string> = {
verification: 'Verified profile',
photoBase: 'Per photo (max 6)',
photoVerified: 'Per verified photo',
bioLong: 'Bio > 200 chars',
bioMedium: 'Bio > 100 chars',
bioShort: 'Bio > 30 chars',
desiresMany: 'Desires >= 5',
desiresSome: 'Desires >= 3',
connectionGoals: 'Has goals',
distanceClose: 'Within 15mi',
distanceMedium: 'Within 30mi',
distanceFar: 'Within 50mi',
ageSweetSpot: 'Age 24-40',
ageOk: 'Age 21-45',
ageOutOfRange: 'Age out of range',
theyLikedYou: 'They liked you',
connectionDesires: 'Connection desires',
};
const BREAKDOWN_LABELS: Record<string, string> = {
verification: 'Verified',
photos: 'Photos',
verifiedPhotos: 'Verified photos',
bio: 'Bio quality',
desires: 'Desires',
connectionGoals: 'Goals',
distance: 'Distance',
age: 'Age',
theyLikedYou: 'They liked you',
connectionDesires: 'Connection',
};
export function MatchesPage() {
const {
matches, total, loading, filters, setFilters,
fetchMatches, loadMore, removeMatch, hasMore,
weights, updateWeights,
} = useMatches();
const navigate = useNavigate();
const [matchMode, setMatchMode] = useState<'smart' | 'api'>('smart');
// API Matches queries (only run when in 'api' mode)
const { data: matchesData, loading: matchesLoading, refetch: refetchMatches } = useQuery(PROFILE_MATCHES_QUERY, {
variables: { profileId: authManager.getProfileId(), limit: 25 },
skip: matchMode !== 'api',
fetchPolicy: 'cache-and-network',
});
const { data: connectionsData, loading: connectionsLoading, refetch: refetchConnections } = useQuery(GET_PROFILE_CONNECTIONS_QUERY, {
variables: { limit: 25 },
skip: matchMode !== 'api',
fetchPolicy: 'cache-and-network',
});
const apiMatches = matchesData?.profile?.matches?.nodes || [];
const apiMatchesPageInfo = matchesData?.profile?.matches?.pageInfo;
const apiConnections = connectionsData?.connections?.nodes || [];
const apiConnectionsPageInfo = connectionsData?.connections?.pageInfo;
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [hoveredScore, setHoveredScore] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [localWeights, setLocalWeights] = useState<MatchWeights | null>(null);
const [refreshedPhotos, setRefreshedPhotos] = useState<Record<string, any[]>>({});
const refreshedIdsRef = useRef<Set<string>>(new Set());
const refreshQueueRef = useRef<Set<string>>(new Set());
const isProcessingRef = useRef(false);
// Process the refresh queue one at a time
const processRefreshQueue = useCallback(async () => {
if (isProcessingRef.current) return;
isProcessingRef.current = true;
while (refreshQueueRef.current.size > 0) {
const profileId = refreshQueueRef.current.values().next().value!;
refreshQueueRef.current.delete(profileId);
try {
const result = await apolloClient.query({
query: PROFILE_QUERY,
variables: { profileId },
fetchPolicy: 'network-only',
});
const fresh = result.data?.profile;
if (fresh?.photos?.length) {
setRefreshedPhotos(prev => ({ ...prev, [profileId]: fresh.photos }));
fetch('/api/discovered-profiles/update-photos', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileId, photos: fresh.photos }),
}).catch(() => {});
}
} catch (e) {
// silently skip failed refreshes
}
await new Promise(r => setTimeout(r, 150));
}
isProcessingRef.current = false;
}, []);
// Queue a profile for photo refresh when it enters the viewport
const queueRefresh = useCallback((profileId: string) => {
if (refreshedIdsRef.current.has(profileId)) return;
refreshedIdsRef.current.add(profileId);
refreshQueueRef.current.add(profileId);
processRefreshQueue();
}, [processRefreshQueue]);
// IntersectionObserver to detect cards entering viewport
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const profileId = (entry.target as HTMLElement).dataset.profileId;
if (profileId) queueRefresh(profileId);
}
}
},
{ rootMargin: '200px' }
);
return () => observerRef.current?.disconnect();
}, [queueRefresh]);
// Ref callback for each card to observe/unobserve
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const setCardRef = useCallback((profileId: string, el: HTMLDivElement | null) => {
const prev = cardRefs.current.get(profileId);
if (prev && observerRef.current) observerRef.current.unobserve(prev);
if (el) {
cardRefs.current.set(profileId, el);
if (observerRef.current) observerRef.current.observe(el);
} else {
cardRefs.current.delete(profileId);
}
}, []);
// Merge refreshed photos into match profiles
const displayMatches = useMemo(() => matches.map(m => {
const freshPhotos = refreshedPhotos[m.id];
return freshPhotos ? { ...m, photos: freshPhotos } : m;
}), [matches, refreshedPhotos]);
const activeFiltersCount = [
filters.search, filters.minAge, filters.maxAge,
filters.maxDistance, filters.gender,
filters.verifiedOnly, filters.theyLikedOnly,
].filter(Boolean).length;
const handleFilterChange = useCallback((key: keyof MatchFilters, value: any) => {
setFilters((prev: MatchFilters) => ({ ...prev, [key]: value || undefined }));
}, [setFilters]);
const clearFilters = useCallback(() => {
setFilters({});
}, [setFilters]);
const handleWeightChange = useCallback((key: string, value: number) => {
setLocalWeights(prev => ({ ...(prev || weights || {}), [key]: value }));
}, [weights]);
const saveWeights = useCallback(() => {
if (localWeights) {
updateWeights(localWeights);
}
}, [localWeights, updateWeights]);
const resetWeights = useCallback(() => {
setLocalWeights(null);
updateWeights({});
}, [updateWeights]);
const displayWeights = localWeights || weights || {};
return (
<div style={styles.container}>
{/* Header */}
<div style={styles.header}>
<h1 style={styles.title}>{matchMode === 'smart' ? 'Smart Matches' : 'API Matches'}</h1>
<p style={styles.subtitle}>
{matchMode === 'smart'
? `${total} ranked profiles from your discovery cache`
: `${apiMatchesPageInfo?.total ?? apiMatches.length} matches, ${apiConnectionsPageInfo?.total ?? apiConnections.length} connections from Feeld API`}
</p>
<div style={styles.headerActions}>
<button
style={styles.iconBtn}
onClick={() => {
if (matchMode === 'smart') fetchMatches();
else { refetchMatches(); refetchConnections(); }
}}
title="Refresh"
>
&#x21bb;
</button>
{matchMode === 'smart' && (
<button
style={{
...styles.iconBtn,
...(showSettings ? { background: 'rgba(124,58,237,0.15)', borderColor: 'rgba(124,58,237,0.3)', color: '#a78bfa' } : {}),
}}
onClick={() => {
setShowSettings(v => !v);
if (!localWeights && weights) setLocalWeights({ ...weights });
}}
title="Scoring Settings"
>
&#x2699;
</button>
)}
</div>
</div>
{/* Mode Toggle */}
<div style={styles.modeToggleBar}>
<button
style={styles.modeBtn(matchMode === 'smart')}
onClick={() => setMatchMode('smart')}
>
Smart Matches
</button>
<button
style={styles.modeBtn(matchMode === 'api')}
onClick={() => setMatchMode('api')}
>
API Matches
</button>
</div>
{/* ===== SMART MATCHES MODE ===== */}
{matchMode === 'smart' && <>
{/* Scoring Settings */}
{showSettings && (
<div style={styles.settingsPanel}>
<div style={styles.settingsHeader}>
<span style={styles.settingsTitle}>Scoring Weights</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button style={styles.clearBtn} onClick={resetWeights}>Reset</button>
<button
style={{ ...styles.toggleBtn(true), background: 'rgba(124,58,237,0.2)' }}
onClick={saveWeights}
>
Apply
</button>
</div>
</div>
{Object.entries(WEIGHT_LABELS).map(([key, label]) => (
<div key={key} style={styles.weightRow}>
<span style={styles.weightLabel}>{label}</span>
<input
type="range"
min="0"
max="50"
value={displayWeights[key] ?? 0}
onChange={e => handleWeightChange(key, parseInt(e.target.value))}
style={styles.weightSlider}
/>
<span style={styles.weightValue}>{displayWeights[key] ?? 0}</span>
</div>
))}
</div>
)}
{/* Filter Bar */}
<div style={styles.filterBar}>
<input
type="text"
placeholder="Search name or bio..."
value={filters.search || ''}
onChange={e => handleFilterChange('search', e.target.value)}
style={styles.searchInput}
/>
<span style={styles.filterLabel}>Age</span>
<input
type="number"
placeholder="Min"
value={filters.minAge || ''}
onChange={e => handleFilterChange('minAge', e.target.value ? parseInt(e.target.value) : undefined)}
style={styles.numberInput}
/>
<span style={{ color: 'rgba(255,255,255,0.3)' }}>-</span>
<input
type="number"
placeholder="Max"
value={filters.maxAge || ''}
onChange={e => handleFilterChange('maxAge', e.target.value ? parseInt(e.target.value) : undefined)}
style={styles.numberInput}
/>
<select
value={filters.maxDistance || 0}
onChange={e => handleFilterChange('maxDistance', parseInt(e.target.value) || undefined)}
style={styles.select}
>
<option value={0}>Any distance</option>
{DISTANCE_OPTIONS.filter(d => d.value > 0).map(d => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
<div style={styles.genderPills}>
{GENDER_OPTIONS.map(g => {
const selected = filters.gender?.split(',').includes(g);
return (
<button
key={g}
style={styles.toggleBtn(!!selected)}
onClick={() => {
const current = filters.gender ? filters.gender.split(',') : [];
const next = selected
? current.filter(c => c !== g)
: [...current, g];
handleFilterChange('gender', next.length > 0 ? next.join(',') : undefined);
}}
>
{g === 'NON_BINARY' ? 'NB' : g.charAt(0) + g.slice(1).toLowerCase()}
</button>
);
})}
</div>
<button
style={styles.toggleBtn(!!filters.verifiedOnly)}
onClick={() => handleFilterChange('verifiedOnly', !filters.verifiedOnly || undefined)}
>
Verified
</button>
<button
style={styles.toggleBtn(!!filters.theyLikedOnly)}
onClick={() => handleFilterChange('theyLikedOnly', !filters.theyLikedOnly || undefined)}
>
Liked you
</button>
{activeFiltersCount > 0 && (
<button style={styles.clearBtn} onClick={clearFilters}>
Clear ({activeFiltersCount})
</button>
)}
</div>
{/* Sort Bar */}
<div style={styles.sortBar}>
<span style={styles.sortLabel}>Sort:</span>
{(['score', 'distance', 'recent'] as const).map(s => (
<button
key={s}
style={styles.sortBtn((filters.sort || 'score') === s)}
onClick={() => handleFilterChange('sort', s === 'score' ? undefined : s)}
>
{s === 'score' ? 'Score' : s === 'distance' ? 'Distance' : 'Recent'}
</button>
))}
</div>
{/* Loading */}
{loading && matches.length === 0 && (
<div style={styles.loadingContainer}>Loading matches...</div>
)}
{/* Empty State */}
{!loading && matches.length === 0 && (
<div style={styles.emptyState}>
<div style={styles.emptyIcon}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 36, height: 36, color: '#a78bfa' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
</div>
<div style={styles.emptyTitle}>No matches found</div>
<div style={styles.emptyText}>
{activeFiltersCount > 0
? 'Try adjusting your filters to see more profiles.'
: 'Scan profiles on the Discover page to build your match pool.'}
</div>
</div>
)}
{/* Profile Grid */}
{displayMatches.length > 0 && (
<div style={styles.profileGrid}>
{displayMatches.map((profile, index) => (
<div
key={profile.id}
style={styles.cardWrapper}
data-profile-id={profile.id}
ref={(el) => setCardRef(profile.id, el)}
>
<ProfileCard
profile={profile as any}
onClick={() => setSelectedProfileId(profile.id)}
index={index}
/>
{/* Score badge */}
<div
style={styles.scoreBadge}
onMouseEnter={() => setHoveredScore(profile.id)}
onMouseLeave={() => setHoveredScore(null)}
>
{profile._score} pts
{profile.interactionStatus?.theirs === 'LIKED' && ' \u2764'}
</div>
{/* Score tooltip */}
{hoveredScore === profile.id && profile._scoreBreakdown && (
<div style={styles.scoreTooltip}>
{Object.entries(profile._scoreBreakdown).map(([key, val]) => (
<div key={key} style={styles.tooltipRow}>
<span style={styles.tooltipLabel}>{BREAKDOWN_LABELS[key] || key}</span>
<span style={styles.tooltipValue}>+{val}</span>
</div>
))}
{Object.keys(profile._scoreBreakdown).length === 0 && (
<div style={{ color: 'rgba(255,255,255,0.4)' }}>No scoring factors</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Load More */}
{hasMore && !loading && (
<button style={styles.loadMoreBtn} onClick={loadMore}>
Load More ({matches.length} of {total})
</button>
)}
{loading && matches.length > 0 && (
<div style={styles.loadingContainer}>Loading more...</div>
)}
</>}
{/* ===== API MATCHES MODE ===== */}
{matchMode === 'api' && (
<>
{/* Loading */}
{(matchesLoading || connectionsLoading) && apiMatches.length === 0 && apiConnections.length === 0 && (
<div style={styles.loadingContainer}>Loading API matches...</div>
)}
{/* Matches Section */}
{apiMatches.length > 0 && (
<>
<h2 style={styles.sectionTitle}>
Matches {apiMatchesPageInfo?.total != null && `(${apiMatchesPageInfo.total})`}
</h2>
<div style={styles.profileGrid}>
{apiMatches.map((node: any, index: number) => (
<div key={node.profile?.id || index} style={styles.matchCardWrapper}>
<ProfileCard
profile={node.profile}
onClick={() => node.profile?.id && setSelectedProfileId(node.profile.id)}
index={index}
/>
{node.chat?.streamChatId && (
<div style={styles.chatStatusBadge}>
{safeText(node.chat.status) || 'Matched'}
</div>
)}
{node.chat?.streamChatId && (
<div style={{ textAlign: 'center', marginTop: '16px' }}>
<button
style={styles.messageBtn}
onClick={() => navigate(`/chat/${node.chat.streamChatId}`)}
>
Message
</button>
</div>
)}
</div>
))}
</div>
</>
)}
{/* Divider between sections */}
{apiMatches.length > 0 && apiConnections.length > 0 && (
<hr style={styles.sectionDivider} />
)}
{/* Connections Section */}
{apiConnections.length > 0 && (
<>
<h2 style={styles.sectionTitle}>
Connections {apiConnectionsPageInfo?.total != null && `(${apiConnectionsPageInfo.total})`}
</h2>
<div style={styles.profileGrid}>
{apiConnections.map((profile: any, index: number) => (
<div key={profile.imaginaryName + '-' + index} style={styles.matchCardWrapper}>
<ProfileCard
profile={profile}
onClick={() => profile.id && setSelectedProfileId(profile.id)}
index={index}
/>
</div>
))}
</div>
</>
)}
{/* Empty State */}
{!matchesLoading && !connectionsLoading && apiMatches.length === 0 && apiConnections.length === 0 && (
<div style={styles.emptyState}>
<div style={styles.emptyIcon}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ width: 36, height: 36, color: '#a78bfa' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
</div>
<div style={styles.emptyTitle}>No API matches found</div>
<div style={styles.emptyText}>
No matches or connections returned from the Feeld API.
</div>
</div>
)}
</>
)}
{/* Profile Detail Modal */}
{selectedProfileId && (
<ProfileDetailModal
profileId={selectedProfileId}
onClose={() => setSelectedProfileId(null)}
onMatch={() => {
removeMatch(selectedProfileId);
setSelectedProfileId(null);
}}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,8 @@
import { useQuery } from '@apollo/client/react';
import { useEffect, useState } from 'react';
import { useQuery, useMutation } from '@apollo/client/react';
import { useNavigate } from 'react-router-dom';
import { LIST_SUMMARIES_QUERY } from '../api/operations/queries';
import { CHATS_MARK_READ_MUTATION } from '../api/operations/mutations';
import { ChatListItem } from '../components/chat/ChatListItem';
import { LoadingPage } from '../components/ui/Loading';
@@ -11,6 +13,37 @@ export function MessagesPage() {
variables: { limit: 30 },
});
// Build a profileId -> discoveredLocation map from our cache
const [locationByProfileId, setLocationByProfileId] = useState<Record<string, string>>({});
useEffect(() => {
fetch('/api/discovered-profiles')
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d?.profiles) return;
const map: Record<string, string> = {};
for (const p of d.profiles) {
if (p.id && typeof p.discoveredLocation === 'string' && p.discoveredLocation) {
map[p.id] = p.discoveredLocation;
}
}
setLocationByProfileId(map);
})
.catch(() => {});
}, []);
const [chatsMarkRead, { loading: markingRead }] = useMutation(CHATS_MARK_READ_MUTATION);
const handleMarkAllRead = async () => {
const chats = data?.summaries?.nodes || [];
const chatIds = chats.map((c: any) => c.id).filter(Boolean);
if (chatIds.length === 0) return;
try {
await chatsMarkRead({ variables: { input: { chatIds } } });
} catch (err) {
console.error('Failed to mark chats as read:', err);
}
};
if (loading) return <LoadingPage message="Loading conversations..." />;
if (error) {
@@ -77,13 +110,43 @@ export function MessagesPage() {
return (
<div className="pb-8">
{/* Header */}
<div className="mb-8 animate-fade-up">
<h1 className="font-display text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-2">
Messages
</h1>
<p className="text-[var(--color-text-muted)]">
{chats.length} {chats.length === 1 ? 'conversation' : 'conversations'}
</p>
<div className="mb-8 animate-fade-up flex items-start justify-between">
<div>
<h1 className="font-display text-3xl md:text-4xl font-bold text-[var(--color-text-primary)] mb-2">
Messages
</h1>
<p className="text-[var(--color-text-muted)]">
{chats.length} {chats.length === 1 ? 'conversation' : 'conversations'}
</p>
</div>
<button
onClick={handleMarkAllRead}
disabled={markingRead}
className="
mt-1 px-4 py-2 rounded-full
bg-white/[0.06] hover:bg-white/[0.1]
text-sm text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]
transition-all duration-200
border border-white/[0.06]
font-body
disabled:opacity-50 disabled:cursor-not-allowed
flex items-center gap-2
"
>
{markingRead ? (
<>
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Marking...
</>
) : (
<>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Mark All Read
</>
)}
</button>
</div>
{/* Chat List */}
@@ -95,7 +158,7 @@ export function MessagesPage() {
style={{ animationDelay: `${(index + 1) * 30}ms` }}
>
<ChatListItem
chat={chat}
chat={{ ...chat, discoveredLocation: locationByProfileId[chat.targetProfileId] ?? null }}
onClick={() => {
const params = new URLSearchParams();
if (chat.name) params.set('name', chat.name);

1376
web/src/pages/OkCupid.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useQuery, useMutation } from '@apollo/client/react';
import { PROFILE_QUERY } from '../api/operations/queries';
import { PROFILE_UPDATE_MUTATION } from '../api/operations/mutations';
import { TEST_CREDENTIALS } from '../config/constants';
import { authManager } from '../api/auth';
import { LoadingPage } from '../components/ui/Loading';
import { ProxiedImage } from '../components/ui/ProxiedImage';
import { getBestImageUrl } from '../utils/images';
@@ -455,13 +455,13 @@ export function ProfilePage() {
});
const { data, loading, error, refetch } = useQuery(PROFILE_QUERY, {
variables: { profileId: TEST_CREDENTIALS.PROFILE_ID },
variables: { profileId: authManager.getProfileId() },
fetchPolicy: 'network-only', // Always fetch from network, don't use cache
});
const [updateProfile] = useMutation(PROFILE_UPDATE_MUTATION, {
refetchQueries: [
{ query: PROFILE_QUERY, variables: { profileId: TEST_CREDENTIALS.PROFILE_ID } }
{ query: PROFILE_QUERY, variables: { profileId: authManager.getProfileId() } }
],
});

View File

@@ -209,12 +209,13 @@ const styles = {
},
// Badge
badge: (variant: 'pending' | 'matched' | 'expired' | 'liked') => {
badge: (variant: 'pending' | 'matched' | 'expired' | 'liked' | 'declined') => {
const colors = {
pending: { bg: 'rgba(245,158,11,0.15)', color: '#fcd34d', border: 'rgba(245,158,11,0.3)' },
matched: { bg: 'rgba(34,197,94,0.15)', color: '#86efac', border: 'rgba(34,197,94,0.3)' },
expired: { bg: 'rgba(156,163,175,0.15)', color: '#9ca3af', border: 'rgba(156,163,175,0.3)' },
liked: { bg: 'rgba(190,49,68,0.15)', color: '#f4a5b0', border: 'rgba(190,49,68,0.3)' },
declined: { bg: 'rgba(239,68,68,0.12)', color: '#f87171', border: 'rgba(239,68,68,0.25)' },
};
return {
display: 'inline-flex',
@@ -390,6 +391,7 @@ export function SentPingsPage() {
const getStatusBadge = (ping: EnrichedSentPing) => {
// Check if they liked back
const theyLikedBack = ping.profile?.interactionStatus?.theirs === 'LIKED';
const theyDeclined = ping.profile?.interactionStatus?.theirs === 'DISLIKED';
if (theyLikedBack) {
return (
@@ -402,6 +404,17 @@ export function SentPingsPage() {
);
}
if (theyDeclined) {
return (
<span style={styles.badge('declined')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '12px', height: '12px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
Declined
</span>
);
}
switch (ping.status) {
case 'MATCHED':
return (

View File

@@ -1,8 +1,7 @@
import { useQuery, useMutation } from '@apollo/client/react';
import { DISCOVER_SEARCH_SETTINGS_QUERY, IS_INCOGNITO_QUERY } from '../api/operations/queries';
import { DEVICE_LOCATION_UPDATE_MUTATION, SEARCH_SETTINGS_UPDATE_MUTATION } from '../api/operations/mutations';
import { TEST_CREDENTIALS, getCredentials, setCredentials, clearCredentials } from '../config/constants';
import { saveCredentials as syncCredentialsToServer, saveCustomLocation } from '../api/dataSync';
import { DISCOVER_SEARCH_SETTINGS_QUERY, IS_INCOGNITO_QUERY, POPULAR_LOCATIONS_QUERY, APP_SETTINGS_QUERY, REDEEMED_OFFERS_QUERY, HAS_LINKED_REFLECTION_QUERY } from '../api/operations/queries';
import { DEVICE_LOCATION_UPDATE_MUTATION, SEARCH_SETTINGS_UPDATE_MUTATION, PROFILE_LOCATION_UPDATE_MUTATION, APP_SETTINGS_UPDATE_MUTATION, SYNC_ACCOUNT_MUTATION, ACCOUNT_REDEEM_OFFER_MUTATION, ACCOUNT_DEACTIVATE_MUTATION, ACCOUNT_TERMINATE_MUTATION } from '../api/operations/mutations';
import { saveCustomLocation } from '../api/dataSync';
import { useAuth } from '../hooks/useAuth';
import { authManager } from '../api/auth';
import type { AuthStatus } from '../api/auth';
@@ -220,10 +219,9 @@ export function SettingsPage() {
const [tempDesiringFor, setTempDesiringFor] = useState<string[]>([]);
const [savingDesiringFor, setSavingDesiringFor] = useState(false);
// Auth credentials state
const currentCreds = getCredentials();
const [profileIdInput, setProfileIdInput] = useState(currentCreds.PROFILE_ID);
const [refreshTokenInput, setRefreshTokenInput] = useState(currentCreds.REFRESH_TOKEN);
// Auth credentials state — backend is single source of truth
const [profileIdInput, setProfileIdInput] = useState(authManager.getProfileId());
const [refreshTokenInput, setRefreshTokenInput] = useState('');
const [authStatus, setAuthStatus] = useState<AuthStatus>(authManager.getStatus());
const [authSaving, setAuthSaving] = useState(false);
const [authMessage, setAuthMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@@ -268,29 +266,160 @@ export function SettingsPage() {
return () => clearInterval(interval);
}, []);
// Seed token to backend on load
// Load current profile ID from backend on mount
useEffect(() => {
const seedToken = async () => {
const creds = getCredentials();
if (creds.REFRESH_TOKEN && creds.PROFILE_ID) {
try {
await fetch('/api/location-rotation/seed-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refreshToken: creds.REFRESH_TOKEN,
profileId: creds.PROFILE_ID,
analyticsId: creds.EVENT_ANALYTICS_ID,
}),
});
} catch (e) {}
}
};
seedToken();
setProfileIdInput(authManager.getProfileId());
}, []);
const [rotationError, setRotationError] = useState<string | null>(null);
// Teleport state
const [teleportLoading, setTeleportLoading] = useState(false);
const [teleportStatus, setTeleportStatus] = useState<string | null>(null);
const [teleportCity, setTeleportCity] = useState<string | null>(null);
// Notifications/Preferences state
const [syncingAccount, setSyncingAccount] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
// Account management state
const [offerCode, setOfferCode] = useState('');
const [redeemingOffer, setRedeemingOffer] = useState(false);
const [offerResult, setOfferResult] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [pauseConfirm, setPauseConfirm] = useState(false);
const [pausingAccount, setPausingAccount] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(0); // 0=none, 1=first confirm, 2=second confirm
const [deletingAccount, setDeletingAccount] = useState(false);
const [accountActionResult, setAccountActionResult] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Teleport queries/mutations
const { data: popularLocationsData } = useQuery(POPULAR_LOCATIONS_QUERY);
const [profileLocationUpdate] = useMutation(PROFILE_LOCATION_UPDATE_MUTATION);
// App settings queries/mutations
const { data: appSettingsData, refetch: refetchAppSettings } = useQuery(APP_SETTINGS_QUERY);
const [appSettingsUpdate] = useMutation(APP_SETTINGS_UPDATE_MUTATION);
const [syncAccount] = useMutation(SYNC_ACCOUNT_MUTATION);
// Account queries/mutations
const { data: redeemedOffersData, refetch: refetchOffers } = useQuery(REDEEMED_OFFERS_QUERY);
const { data: linkedReflectionData } = useQuery(HAS_LINKED_REFLECTION_QUERY);
const [redeemOffer] = useMutation(ACCOUNT_REDEEM_OFFER_MUTATION);
const [deactivateAccount] = useMutation(ACCOUNT_DEACTIVATE_MUTATION);
const [terminateAccount] = useMutation(ACCOUNT_TERMINATE_MUTATION);
const handleTeleport = async (loc: any) => {
setTeleportLoading(true);
setTeleportStatus(null);
try {
await profileLocationUpdate({
variables: {
input: {
teleportLocation: {
latitude: loc.latitude,
longitude: loc.longitude,
city: loc.geocode?.city || '',
country: loc.geocode?.country || '',
},
},
},
});
setTeleportCity(`${loc.geocode?.city || 'Unknown'}, ${loc.geocode?.country || ''}`);
setTeleportStatus('success');
} catch (err) {
setTeleportStatus('error');
} finally {
setTeleportLoading(false);
}
};
const handleResetTeleport = async () => {
setTeleportLoading(true);
setTeleportStatus(null);
try {
await profileLocationUpdate({
variables: {
input: {
deviceLocation: { latitude: 0, longitude: 0 },
},
},
});
setTeleportCity(null);
setTeleportStatus('reset');
} catch (err) {
setTeleportStatus('error');
} finally {
setTeleportLoading(false);
}
};
const handleToggleNotification = async (field: string, currentValue: boolean) => {
try {
await appSettingsUpdate({ variables: { [field]: !currentValue } });
refetchAppSettings();
} catch (err) {
console.error('Failed to update setting:', err);
}
};
const handleSyncAccount = async () => {
setSyncingAccount(true);
setSyncResult(null);
try {
const result = await syncAccount();
const data = result.data?.syncAccount;
setSyncResult(`Synced: Majestic=${data?.isMajestic ? 'Yes' : 'No'}, Uplift=${data?.isUplift ? 'Yes' : 'No'}, Pings=${data?.availablePings ?? 'N/A'}`);
} catch (err) {
setSyncResult('Sync failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
} finally {
setSyncingAccount(false);
}
};
const handleRedeemOffer = async () => {
if (!offerCode.trim()) return;
setRedeemingOffer(true);
setOfferResult(null);
try {
await redeemOffer({ variables: { input: { offerName: offerCode.trim() } } });
setOfferResult({ type: 'success', text: `Offer "${offerCode.trim()}" redeemed!` });
setOfferCode('');
refetchOffers();
} catch (err) {
setOfferResult({ type: 'error', text: err instanceof Error ? err.message : 'Failed to redeem offer' });
} finally {
setRedeemingOffer(false);
}
};
const handlePauseAccount = async () => {
setPausingAccount(true);
setAccountActionResult(null);
try {
await deactivateAccount();
setAccountActionResult({ type: 'success', text: 'Account paused (deactivated).' });
setPauseConfirm(false);
} catch (err) {
setAccountActionResult({ type: 'error', text: err instanceof Error ? err.message : 'Failed to pause account' });
} finally {
setPausingAccount(false);
}
};
const handleDeleteAccount = async () => {
setDeletingAccount(true);
setAccountActionResult(null);
try {
await terminateAccount();
setAccountActionResult({ type: 'success', text: 'Account terminated. You will be logged out.' });
setDeleteConfirm(0);
} catch (err) {
setAccountActionResult({ type: 'error', text: err instanceof Error ? err.message : 'Failed to delete account' });
} finally {
setDeletingAccount(false);
}
};
const updateRotation = async (updates: Record<string, any>) => {
setRotationError(null);
try {
@@ -388,31 +517,34 @@ export function SettingsPage() {
const profileId = profileIdInput.trim();
const refreshToken = refreshTokenInput.trim();
// Save to localStorage
setCredentials({ profileId, refreshToken });
if (!refreshToken) {
setAuthMessage({ type: 'error', text: 'Refresh token is required' });
return;
}
// Sync to server for cross-browser persistence
await syncCredentialsToServer(profileId, refreshToken);
// Seed directly to backend — single source of truth
const resp = await fetch('/api/auth/seed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken, profileId }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
await authManager.forceRefresh();
setAuthMessage({ type: 'success', text: 'Credentials saved and synced to server!' });
// Reload page to apply new credentials everywhere
setAuthMessage({ type: 'success', text: 'Credentials saved to server! All devices will use the new token.' });
setRefreshTokenInput('');
setTimeout(() => window.location.reload(), 1500);
} catch (err) {
setAuthMessage({ type: 'error', text: err instanceof Error ? err.message : 'Failed to refresh token' });
setAuthMessage({ type: 'error', text: err instanceof Error ? err.message : 'Failed to save credentials' });
} finally {
setAuthSaving(false);
}
};
const handleResetCredentials = () => {
clearCredentials();
const defaultCreds = getCredentials();
setProfileIdInput(defaultCreds.PROFILE_ID);
setRefreshTokenInput(defaultCreds.REFRESH_TOKEN);
setAuthMessage({ type: 'success', text: 'Credentials reset to defaults. Reload to apply.' });
};
const handleRefreshToken = async () => {
setAuthSaving(true);
setAuthMessage(null);
@@ -438,19 +570,20 @@ export function SettingsPage() {
const { data: settingsData, loading: settingsLoading } = useQuery(
DISCOVER_SEARCH_SETTINGS_QUERY,
{
variables: { profileId: TEST_CREDENTIALS.PROFILE_ID },
variables: { profileId: authManager.getProfileId() },
}
);
const { data: incognitoData, loading: incognitoLoading } = useQuery(
IS_INCOGNITO_QUERY,
{
variables: { profileId: TEST_CREDENTIALS.PROFILE_ID },
variables: { profileId: authManager.getProfileId() },
}
);
const [updateLocation] = useMutation(DEVICE_LOCATION_UPDATE_MUTATION);
const [updateSearchSettings] = useMutation(SEARCH_SETTINGS_UPDATE_MUTATION);
const [locationStatus, setLocationStatus] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
if (settingsLoading || incognitoLoading) return <LoadingPage message="Loading settings..." />;
@@ -483,6 +616,8 @@ export function SettingsPage() {
},
});
setLocationStatus({ type: 'success', text: `Location set to ${result.displayName} — you'll appear to users in this area` });
setTimeout(() => setLocationStatus(null), 4000);
setSearchQuery('');
} else {
setSearchError('Location not found. Try a different search.');
@@ -501,9 +636,10 @@ export function SettingsPage() {
name: saved.name,
};
setLocation(newLocation);
setLocationStatus(null);
try {
const result = await updateLocation({
await updateLocation({
variables: {
input: {
latitude: saved.latitude,
@@ -511,15 +647,13 @@ export function SettingsPage() {
},
},
});
console.log('DeviceLocationUpdate response:', result.data);
// Check if API actually updated the location
const deviceLocation = result.data?.deviceLocationUpdate?.location?.device;
if (deviceLocation && (deviceLocation.latitude === 0 && deviceLocation.longitude === 0)) {
console.warn('API returned 0,0 - location update may require premium/Majestic membership');
}
// API returns 0,0 as privacy masking — location IS set server-side
setLocationStatus({ type: 'success', text: `Location set to ${saved.name} — you'll appear to users in this area` });
setTimeout(() => setLocationStatus(null), 4000);
} catch (error) {
console.error('Failed to update location:', error);
setLocationStatus({ type: 'error', text: 'Failed to update location' });
setTimeout(() => setLocationStatus(null), 4000);
}
};
@@ -742,6 +876,22 @@ export function SettingsPage() {
})}
</div>
)}
{/* Location status toast */}
{locationStatus && (
<div style={{
marginTop: '12px',
padding: '10px 16px',
borderRadius: '10px',
fontSize: '13px',
fontFamily: "'Satoshi', sans-serif",
background: locationStatus.type === 'success' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: locationStatus.type === 'success' ? '#86efac' : '#f87171',
border: `1px solid ${locationStatus.type === 'success' ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
}}>
{locationStatus.text}
</div>
)}
</div>
</div>
@@ -1263,6 +1413,422 @@ export function SettingsPage() {
</div>
</div>
{/* Teleport */}
<div style={styles.card}>
<div style={styles.cardContent}>
<div style={styles.sectionHeader}>
<div style={styles.iconBox('#8b5cf6')}>
<svg viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A8.966 8.966 0 013 12c0-1.97.633-3.794 1.706-5.277" />
</svg>
</div>
<h2 style={styles.sectionTitle}>Teleport</h2>
</div>
{/* Teleport Status Badge */}
<div style={{ marginBottom: '20px' }}>
<label style={styles.label}>Current Status</label>
{teleportCity ? (
<span style={{ ...styles.badge('primary'), fontSize: '14px' }}>
Teleporting to {teleportCity}
</span>
) : (
<span style={{ ...styles.badge('success'), fontSize: '14px' }}>
Device Location
</span>
)}
{teleportStatus === 'error' && (
<p style={{ color: '#ef4444', fontSize: '13px', marginTop: '8px' }}>Failed to update teleport location.</p>
)}
</div>
{/* Popular Locations Grid */}
<div style={{ marginBottom: '20px' }}>
<label style={styles.label}>Popular Locations</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px' }}>
{(popularLocationsData?.popularLocations || []).map((loc: any, i: number) => (
<button
key={i}
onClick={() => handleTeleport(loc)}
disabled={teleportLoading}
style={{
padding: '12px 10px',
background: '#24242f',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '10px',
color: '#ffffff',
fontSize: '13px',
fontWeight: 500,
cursor: teleportLoading ? 'wait' : 'pointer',
opacity: teleportLoading ? 0.6 : 1,
transition: 'all 0.2s',
textAlign: 'center' as const,
}}
onMouseEnter={(e) => { if (!teleportLoading) { e.currentTarget.style.background = '#2e2e3a'; e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.4)'; } }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#24242f'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; }}
>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{loc.geocode?.city || 'Unknown'}</div>
<div style={{ fontSize: '11px', color: '#6b7280' }}>{loc.geocode?.country || ''}</div>
</button>
))}
</div>
{teleportLoading && (
<p style={{ color: '#8b5cf6', fontSize: '13px', marginTop: '12px' }}>Teleporting...</p>
)}
</div>
{/* Reset to Device Location */}
<button
onClick={handleResetTeleport}
disabled={teleportLoading}
style={{
width: '100%',
padding: '14px 24px',
background: '#24242f',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '12px',
color: '#ffffff',
fontSize: '15px',
fontWeight: 600,
cursor: teleportLoading ? 'wait' : 'pointer',
opacity: teleportLoading ? 0.6 : 1,
transition: 'opacity 0.2s',
}}
>
{teleportLoading ? 'Resetting...' : 'Reset to Device Location'}
</button>
<p style={styles.note}>
Teleport your profile to appear in a different city. Popular locations are provided by Feeld. Reset to return to your device location.
</p>
</div>
</div>
{/* Notifications & Preferences */}
<div style={styles.card}>
<div style={styles.cardContent}>
<div style={styles.sectionHeader}>
<div style={styles.iconBox('#f97316')}>
<svg viewBox="0 0 24 24" fill="none" stroke="#f97316" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
</div>
<h2 style={styles.sectionTitle}>Notifications & Preferences</h2>
</div>
{(() => {
const acct = appSettingsData?.account;
const appSettings = acct?.appSettings;
if (!appSettings) return <p style={{ color: '#6b7280' }}>Loading notification settings...</p>;
const toggles: Array<{ label: string; field: string; value: boolean }> = [
{ label: 'New Connection', field: 'receiveNewConnectionPushNotifications', value: !!appSettings.receiveNewConnectionPushNotifications },
{ label: 'New Ping', field: 'receiveNewPingPushNotifications', value: !!appSettings.receiveNewPingPushNotifications },
{ label: 'New Message', field: 'receiveNewMessagePushNotifications', value: !!appSettings.receiveNewMessagePushNotifications },
{ label: 'New Like', field: 'receiveNewLikePushNotifications', value: !!appSettings.receiveNewLikePushNotifications },
{ label: 'Marketing', field: 'receiveMarketingNotifications', value: !!appSettings.receiveMarketingNotifications },
{ label: 'News Email', field: 'receiveNewsEmailNotifications', value: !!appSettings.receiveNewsEmailNotifications },
{ label: 'Promotions Email', field: 'receivePromotionsEmailNotifications', value: !!appSettings.receivePromotionsEmailNotifications },
{ label: 'News Push', field: 'receiveNewsPushNotifications', value: !!appSettings.receiveNewsPushNotifications },
{ label: 'Promotions Push', field: 'receivePromotionsPushNotifications', value: !!appSettings.receivePromotionsPushNotifications },
];
return (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '24px' }}>
{toggles.map(({ label, field, value }) => (
<div
key={field}
style={{ ...styles.toggleContainer, cursor: 'pointer' }}
onClick={() => handleToggleNotification(field, value)}
>
<div>
<p style={{ color: '#ffffff', fontWeight: 500, margin: 0 }}>{label}</p>
</div>
<div style={{ ...styles.toggle(value), cursor: 'pointer' }}>
<div style={styles.toggleKnob(value)} />
</div>
</div>
))}
</div>
{/* Distance Units */}
<div
style={{ ...styles.toggleContainer, cursor: 'pointer', marginBottom: '24px' }}
onClick={() => handleToggleNotification('isDistanceInMiles', !!acct?.isDistanceInMiles)}
>
<div>
<p style={{ color: '#ffffff', fontWeight: 500, margin: 0 }}>Distance in Miles</p>
<p style={{ color: '#6b7280', fontSize: '13px', margin: '4px 0 0 0' }}>
{acct?.isDistanceInMiles ? 'Using miles' : 'Using kilometers'}
</p>
</div>
<div style={{ ...styles.toggle(!!acct?.isDistanceInMiles), cursor: 'pointer' }}>
<div style={styles.toggleKnob(!!acct?.isDistanceInMiles)} />
</div>
</div>
{/* Sync Account */}
<button
onClick={handleSyncAccount}
disabled={syncingAccount}
style={{
...styles.button,
width: '100%',
background: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)',
opacity: syncingAccount ? 0.7 : 1,
}}
>
{syncingAccount ? 'Syncing...' : 'Sync Account'}
</button>
{syncResult && (
<p style={{ color: '#f97316', fontSize: '13px', marginTop: '12px' }}>{syncResult}</p>
)}
</>
);
})()}
<p style={styles.note}>
Toggle notification preferences for your account. Changes are saved immediately. Sync Account refreshes your subscription status from the server.
</p>
</div>
</div>
{/* Account Management */}
<div style={styles.card}>
<div style={styles.cardContent}>
<div style={styles.sectionHeader}>
<div style={styles.iconBox('#06b6d4')}>
<svg viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h2 style={styles.sectionTitle}>Account</h2>
</div>
{/* Subscription Status */}
<div style={{ marginBottom: '24px' }}>
<label style={styles.label}>Subscription</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<span style={styles.badge(appSettingsData?.account?.isMajestic ? 'success' : 'default')}>
{appSettingsData?.account?.isMajestic ? 'Majestic' : 'Free'}
</span>
{appSettingsData?.account?.isUplift && (
<span style={styles.badge('primary')}>Uplift Active</span>
)}
<span style={styles.badge('default')}>
{appSettingsData?.account?.availablePings ?? '?'} Pings Available
</span>
</div>
</div>
{/* Reflect Link Status */}
<div style={{ marginBottom: '24px' }}>
<label style={styles.label}>Reflect Link</label>
<span style={styles.badge(linkedReflectionData?.hasLinkedReflection ? 'success' : 'default')}>
{linkedReflectionData?.hasLinkedReflection ? 'Linked' : 'Not Linked'}
</span>
</div>
{/* Redeemed Offers */}
<div style={{ marginBottom: '24px' }}>
<label style={styles.label}>Redeemed Offers</label>
{(redeemedOffersData?.account?.redeemedOffers || []).length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{(redeemedOffersData?.account?.redeemedOffers || []).map((offer: any, i: number) => (
<div key={i} style={styles.infoBox}>
<p style={{ color: '#ffffff', fontWeight: 500, margin: 0 }}>{offer.offerName}</p>
{offer.redeemedAt && (
<p style={{ color: '#6b7280', fontSize: '12px', margin: '4px 0 0 0' }}>
Redeemed: {new Date(offer.redeemedAt).toLocaleDateString()}
</p>
)}
</div>
))}
</div>
) : (
<p style={{ color: '#6b7280', fontSize: '14px' }}>No offers redeemed</p>
)}
</div>
{/* Redeem Offer */}
<div style={{ marginBottom: '24px' }}>
<label style={styles.label}>Redeem Offer Code</label>
<div style={{ display: 'flex', gap: '10px' }}>
<input
type="text"
value={offerCode}
onChange={(e) => setOfferCode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRedeemOffer()}
placeholder="Enter offer code..."
style={styles.input}
/>
<button
onClick={handleRedeemOffer}
disabled={redeemingOffer || !offerCode.trim()}
style={{
...styles.button,
opacity: (redeemingOffer || !offerCode.trim()) ? 0.6 : 1,
}}
>
{redeemingOffer ? 'Redeeming...' : 'Redeem'}
</button>
</div>
{offerResult && (
<p style={{
color: offerResult.type === 'success' ? '#22c55e' : '#ef4444',
fontSize: '13px',
marginTop: '8px',
}}>
{offerResult.text}
</p>
)}
</div>
{/* Pause Account */}
<div style={{ marginBottom: '16px' }}>
{!pauseConfirm ? (
<button
onClick={() => setPauseConfirm(true)}
style={{
width: '100%',
padding: '14px 24px',
background: 'rgba(245, 158, 11, 0.15)',
border: '1px solid rgba(245, 158, 11, 0.3)',
borderRadius: '12px',
color: '#f59e0b',
fontSize: '15px',
fontWeight: 600,
cursor: 'pointer',
transition: 'background 0.2s',
}}
>
Pause Account
</button>
) : (
<div style={{ ...styles.infoBox, border: '1px solid rgba(245, 158, 11, 0.3)' }}>
<p style={{ color: '#f59e0b', fontWeight: 500, marginBottom: '12px' }}>
Are you sure you want to pause your account? Your profile will be hidden.
</p>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={handlePauseAccount}
disabled={pausingAccount}
style={{
...styles.button,
flex: 1,
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
opacity: pausingAccount ? 0.7 : 1,
}}
>
{pausingAccount ? 'Pausing...' : 'Yes, Pause'}
</button>
<button
onClick={() => setPauseConfirm(false)}
style={{ ...styles.buttonSecondary, flex: 1 }}
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* Delete Account */}
<div style={{ marginBottom: '16px' }}>
{deleteConfirm === 0 && (
<button
onClick={() => setDeleteConfirm(1)}
style={{
width: '100%',
padding: '14px 24px',
background: 'rgba(239, 68, 68, 0.15)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '12px',
color: '#ef4444',
fontSize: '15px',
fontWeight: 600,
cursor: 'pointer',
transition: 'background 0.2s',
}}
>
Delete Account
</button>
)}
{deleteConfirm === 1 && (
<div style={{ ...styles.infoBox, border: '1px solid rgba(239, 68, 68, 0.3)' }}>
<p style={{ color: '#ef4444', fontWeight: 500, marginBottom: '12px' }}>
This will permanently delete your account. This action cannot be undone. Are you sure?
</p>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={() => setDeleteConfirm(2)}
style={{
...styles.button,
flex: 1,
background: 'rgba(239, 68, 68, 0.3)',
}}
>
Yes, I'm sure
</button>
<button
onClick={() => setDeleteConfirm(0)}
style={{ ...styles.buttonSecondary, flex: 1 }}
>
Cancel
</button>
</div>
</div>
)}
{deleteConfirm === 2 && (
<div style={{ ...styles.infoBox, border: '2px solid #ef4444' }}>
<p style={{ color: '#ef4444', fontWeight: 700, marginBottom: '12px', fontSize: '16px' }}>
FINAL WARNING: This is irreversible. Your account, matches, and messages will be permanently deleted.
</p>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={handleDeleteAccount}
disabled={deletingAccount}
style={{
...styles.button,
flex: 1,
background: 'linear-gradient(135deg, #dc2626 0%, #ef4444 100%)',
opacity: deletingAccount ? 0.7 : 1,
}}
>
{deletingAccount ? 'Deleting...' : 'DELETE PERMANENTLY'}
</button>
<button
onClick={() => setDeleteConfirm(0)}
style={{ ...styles.buttonSecondary, flex: 1 }}
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* Account Action Result */}
{accountActionResult && (
<div style={{
padding: '12px 16px',
borderRadius: '10px',
background: accountActionResult.type === 'success' ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.15)',
border: `1px solid ${accountActionResult.type === 'success' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'}`,
color: accountActionResult.type === 'success' ? '#22c55e' : '#ef4444',
fontSize: '14px',
}}>
{accountActionResult.text}
</div>
)}
<p style={styles.note}>
Pausing hides your profile temporarily. Deleting permanently removes your account, all data, matches, and messages.
</p>
</div>
</div>
{/* Auth Credentials */}
<div style={styles.card}>
<div style={styles.cardContent}>
@@ -1314,15 +1880,15 @@ export function SettingsPage() {
<div style={{ marginBottom: '12px' }}>
<label style={{ fontSize: '10px', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Profile ID</label>
<p style={{ color: '#9ca3af', fontSize: '11px', fontFamily: 'monospace', margin: '4px 0 0 0', wordBreak: 'break-all' }}>
{currentCreds.PROFILE_ID}
{authManager.getProfileId()}
</p>
</div>
{/* Current Refresh Token */}
{/* Refresh Token (managed by backend) */}
<div style={{ marginBottom: '12px' }}>
<label style={{ fontSize: '10px', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Refresh Token</label>
<p style={{ color: '#9ca3af', fontSize: '10px', fontFamily: 'monospace', margin: '4px 0 0 0', wordBreak: 'break-all', maxHeight: '60px', overflow: 'auto' }}>
{currentCreds.REFRESH_TOKEN}
Managed by backend server
</p>
</div>
@@ -1391,12 +1957,6 @@ export function SettingsPage() {
>
{authSaving ? 'Saving...' : 'Save & Refresh Token'}
</button>
<button
style={styles.buttonSecondary}
onClick={handleResetCredentials}
>
Reset to Defaults
</button>
</div>
{/* Status Message */}

View File

@@ -13,6 +13,20 @@ export default defineConfig({
// Access http://localhost:3000 directly for HMR during development
hmr: false,
proxy: {
'/api/okcupid': {
target: 'https://e2p-okapi.api.okcupid.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/okcupid/, ''),
secure: false,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.removeHeader('origin');
proxyReq.removeHeader('referer');
proxyReq.setHeader('User-Agent', 'OkCupid/111.1.0 iOS/26.2.1');
proxyReq.setHeader('x-okcupid-locale', 'en');
});
},
},
'/api/graphql': {
target: 'https://core.api.fldcore.com',
changeOrigin: true,
@@ -70,16 +84,20 @@ export default defineConfig({
proxyReq.removeHeader('sec-ch-ua-platform');
// Set mobile app headers to match iOS app
// APP_VERSION: keep in sync with src/config/constants.ts APP_VERSION
proxyReq.setHeader('User-Agent', 'Feeld/8.8.3 (com.3nder.ios; build:1; iOS 18.6.2) Alamofire/5.9.1');
proxyReq.setHeader('User-Agent', 'Feeld/8.11.0 (com.3nder.ios; build:1; iOS 26.2.1) Alamofire/5.9.1');
proxyReq.setHeader('Accept', '*/*');
proxyReq.setHeader('Accept-Language', 'en-US,en;q=0.9');
proxyReq.setHeader('x-app-version', '8.8.3');
proxyReq.setHeader('x-app-version', '8.11.0');
proxyReq.setHeader('x-device-os', 'ios');
proxyReq.setHeader('x-os-version', '18.6.2');
proxyReq.setHeader('x-os-version', '26.2.1');
});
},
},
// Local backend endpoints (must be last to not override specific proxies above)
'/api/matches': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/api/who-liked-you': {
target: 'http://localhost:3001',
changeOrigin: true,