Block banned-country locations and align GraphQL ops
Defense-in-depth banned-country gate covering every entry point that could set a location Feeld's policy disallows (~60 countries from their support article): - New src/config/bannedCountries.ts — single source of truth (ISO codes + aliases) - New src/utils/reverseGeocode.ts — Nominatim reverse lookup w/ localStorage cache - New src/api/links/bannedCountryLink.ts — Apollo link chokepoint; intercepts every DeviceLocationUpdate mutation and refuses to forward if reverse-geocode resolves to a banned country. Catches Settings, Discover, Likes scanner, and ApiExplorer raw GraphQL alike. - useLocation.tsx — setLocation throws BannedCountryError; saveLocation gate; sanitize banned entries on localStorage and server hydration - Settings.tsx — block at search, saved-location pick, and save-current - Likes.tsx — skip banned saved locations in scanForLikes and "Fuck It" scan - server/index.js — PUT /api/saved-locations filters; readSavedLocations filters legacy banned entries so rotation cron is safe too - nginx.conf — route additions for new backend endpoints Plus the broader rc/realign-graphql-ops session work: GraphQL query/mutation realignment after Feeld API changes, ApiExplorer updates, Profile/Discover/Likes refinements, useFavorites hook, dataSync extensions, vite proxy adjustments. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,15 @@ http {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Favorites (saved-for-later profiles) endpoint
|
||||
location /api/favorites {
|
||||
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;
|
||||
}
|
||||
|
||||
# Disliked profiles endpoint
|
||||
location /api/disliked-profiles {
|
||||
proxy_pass http://backend;
|
||||
|
||||
+126
-4
@@ -315,6 +315,81 @@ app.delete('/api/sent-pings/:targetProfileId', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/favorites - Get all saved/favorited profiles
|
||||
app.get('/api/favorites', (req, res) => {
|
||||
const filePath = path.join(DATA_DIR, 'favorites.json');
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to read favorites.json:', e);
|
||||
res.json({ favorites: [] });
|
||||
}
|
||||
} else {
|
||||
res.json({ favorites: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/favorites - Add a favorited profile (idempotent)
|
||||
app.post('/api/favorites', (req, res) => {
|
||||
const { targetProfileId, profile } = req.body;
|
||||
if (!targetProfileId) {
|
||||
return res.status(400).json({ success: false, error: 'targetProfileId required' });
|
||||
}
|
||||
const filePath = path.join(DATA_DIR, 'favorites.json');
|
||||
|
||||
let data = { favorites: [], updatedAt: null };
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error('Failed to read favorites.json:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.favorites.some(f => f.targetProfileId === targetProfileId)) {
|
||||
data.favorites.unshift({
|
||||
targetProfileId,
|
||||
savedAt: Date.now(),
|
||||
profile: profile || null,
|
||||
});
|
||||
data.updatedAt = new Date().toISOString();
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
res.json({ success: true, favorites: data.favorites });
|
||||
});
|
||||
|
||||
// DELETE /api/favorites/:targetProfileId - Remove a favorite
|
||||
app.delete('/api/favorites/:targetProfileId', (req, res) => {
|
||||
const { targetProfileId } = req.params;
|
||||
const filePath = path.join(DATA_DIR, 'favorites.json');
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
data.favorites = data.favorites.filter(f => f.targetProfileId !== targetProfileId);
|
||||
data.updatedAt = new Date().toISOString();
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
res.json({ success: true, favorites: data.favorites });
|
||||
} catch (e) {
|
||||
console.error('Failed to update favorites.json:', e);
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
} else {
|
||||
res.json({ success: true, favorites: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/favorites - Clear all favorites
|
||||
app.delete('/api/favorites', (req, res) => {
|
||||
const filePath = path.join(DATA_DIR, 'favorites.json');
|
||||
const data = { favorites: [], updatedAt: new Date().toISOString() };
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
res.json({ success: true, favorites: [] });
|
||||
});
|
||||
|
||||
// GET /api/disliked-profiles - Get all disliked profiles
|
||||
app.get('/api/disliked-profiles', (req, res) => {
|
||||
const filePath = path.join(DATA_DIR, 'dislikedProfiles.json');
|
||||
@@ -1186,6 +1261,35 @@ 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');
|
||||
|
||||
// Countries where Feeld is NOT available — mirror of web/src/config/bannedCountries.ts.
|
||||
// Keep in sync if that file changes. Used to filter incoming saved-location syncs.
|
||||
const BANNED_COUNTRY_CODES = new Set([
|
||||
'AL','DZ','AO','AM','AZ','BH','BD','BJ','BW','BN','BF','CM','TD','CN','CG','CU',
|
||||
'EG','GA','GM','HK','IR','IQ','JO','KZ','KE','LA','LR','MO','MG','MW','ML','MR',
|
||||
'MU','MM','MA','MZ','NA','NE','OM','PK','PG','PH','QA','RU','SA','SN','SL','SD',
|
||||
'TW','TJ','TZ','TH','TG','TN','TM','UG','UZ','YE','ZM','ZW',
|
||||
]);
|
||||
const BANNED_COUNTRY_NAMES = new Set([
|
||||
'albania','algeria','angola','armenia','azerbaijan','bahrain','bangladesh','benin','botswana',
|
||||
'brunei darussalam','brunei','burkina faso','cameroon','chad','china',"people's republic of china",
|
||||
'republic of the congo','congo','congo, republic of','congo-brazzaville','cuba','egypt','gabon',
|
||||
'gambia','the gambia','hong kong','iran','islamic republic of iran','iraq','jordan','kazakhstan',
|
||||
'kenya','laos',"lao people's democratic republic",'lao pdr','liberia','macau','macao','madagascar',
|
||||
'malawi','mali','mauritania','mauritius','myanmar','burma','mayanmar','morocco','mozambique',
|
||||
'namibia','niger','oman','pakistan','papua new guinea','philippines','qatar','russia',
|
||||
'russian federation','saudi arabia','senegal','sierra leone','sudan','taiwan',
|
||||
'taiwan, province of china','republic of china','tajikistan','tanzania',
|
||||
'united republic of tanzania','thailand','togo','tunisia','turkmenistan','uganda','uzbekistan',
|
||||
'yemen','zambia','zimbabwe',
|
||||
]);
|
||||
|
||||
function isBannedLocation(loc) {
|
||||
if (!loc) return false;
|
||||
if (loc.countryCode && BANNED_COUNTRY_CODES.has(String(loc.countryCode).trim().toUpperCase())) return true;
|
||||
if (loc.country && BANNED_COUNTRY_NAMES.has(String(loc.country).trim().toLowerCase())) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
class FeeldAPIClient {
|
||||
constructor() {
|
||||
this.accessToken = null;
|
||||
@@ -1393,7 +1497,11 @@ function writeRotationState(state) {
|
||||
function readSavedLocations() {
|
||||
if (fs.existsSync(SAVED_LOCATIONS_FILE)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(SAVED_LOCATIONS_FILE, 'utf8'));
|
||||
const raw = JSON.parse(fs.readFileSync(SAVED_LOCATIONS_FILE, 'utf8'));
|
||||
const arr = Array.isArray(raw) ? raw : [];
|
||||
// Strip any banned-country entries that might be left over from before
|
||||
// the country filter was added. Rotation and selection both go through here.
|
||||
return arr.filter((loc) => !isBannedLocation(loc));
|
||||
} catch (e) {
|
||||
console.error('[Rotation] Failed to read saved locations:', e.message);
|
||||
}
|
||||
@@ -1683,14 +1791,28 @@ app.get('/api/saved-locations', (req, res) => {
|
||||
res.json(readSavedLocations());
|
||||
});
|
||||
|
||||
// PUT /api/saved-locations — Browser syncs its saved locations here
|
||||
// PUT /api/saved-locations — Browser syncs its saved locations here.
|
||||
// Strips any locations in Feeld-restricted countries (defense in depth against
|
||||
// stale clients or directly-crafted requests).
|
||||
app.put('/api/saved-locations', (req, res) => {
|
||||
const { locations } = req.body;
|
||||
if (!Array.isArray(locations)) {
|
||||
return res.status(400).json({ success: false, error: 'locations array required' });
|
||||
}
|
||||
writeSavedLocations(locations);
|
||||
res.json({ success: true, count: locations.length });
|
||||
const filtered = [];
|
||||
const rejected = [];
|
||||
for (const loc of locations) {
|
||||
if (isBannedLocation(loc)) {
|
||||
rejected.push({ id: loc.id, name: loc.name, country: loc.country, countryCode: loc.countryCode });
|
||||
} else {
|
||||
filtered.push(loc);
|
||||
}
|
||||
}
|
||||
if (rejected.length) {
|
||||
console.warn(`[saved-locations] dropped ${rejected.length} banned-country location(s):`, rejected);
|
||||
}
|
||||
writeSavedLocations(filtered);
|
||||
res.json({ success: true, count: filtered.length, rejected: rejected.length });
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onError } from '@apollo/client/link/error';
|
||||
import { setContext } from '@apollo/client/link/context';
|
||||
import { API_CONFIG, REQUEST_HEADERS } from '../config/constants';
|
||||
import { authManager } from './auth';
|
||||
import { bannedCountryLink } from './links/bannedCountryLink';
|
||||
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
@@ -105,7 +106,7 @@ const errorLink = onError(({ graphQLErrors, operation, forward }) => {
|
||||
});
|
||||
|
||||
export const apolloClient = new ApolloClient({
|
||||
link: ApolloLink.from([errorLink, authLink, httpLink]),
|
||||
link: ApolloLink.from([bannedCountryLink, errorLink, authLink, httpLink]),
|
||||
cache: new InMemoryCache({
|
||||
typePolicies: {
|
||||
Profile: {
|
||||
|
||||
@@ -557,5 +557,77 @@ export const clearAllDislikedProfiles = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
// Favorites - "saved for later" queue, local-only (no remote Feeld action)
|
||||
export interface Favorite {
|
||||
targetProfileId: string;
|
||||
savedAt: number;
|
||||
profile?: any;
|
||||
}
|
||||
|
||||
export const addFavorite = async (targetProfileId: string, profile?: any): Promise<void> => {
|
||||
const newFav: Favorite = { targetProfileId, savedAt: Date.now(), profile };
|
||||
|
||||
const current: Favorite[] = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}favorites`) || '[]');
|
||||
if (!current.some(f => f.targetProfileId === targetProfileId)) {
|
||||
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}favorites`, JSON.stringify([newFav, ...current]));
|
||||
}
|
||||
|
||||
if (await checkServerAvailable()) {
|
||||
try {
|
||||
await fetch(`${SERVER_URL}/favorites`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ targetProfileId, profile }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to sync favorite to server:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const removeFavorite = async (targetProfileId: string): Promise<void> => {
|
||||
const current: Favorite[] = JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}favorites`) || '[]');
|
||||
localStorage.setItem(
|
||||
`${LOCAL_STORAGE_PREFIX}favorites`,
|
||||
JSON.stringify(current.filter(f => f.targetProfileId !== targetProfileId)),
|
||||
);
|
||||
|
||||
if (await checkServerAvailable()) {
|
||||
try {
|
||||
await fetch(`${SERVER_URL}/favorites/${encodeURIComponent(targetProfileId)}`, { method: 'DELETE' });
|
||||
} catch (e) {
|
||||
console.error('Failed to remove favorite from server:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getFavorites = async (): Promise<Favorite[]> => {
|
||||
if (await checkServerAvailable()) {
|
||||
try {
|
||||
const r = await fetch(`${SERVER_URL}/favorites`);
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
const favs = data.favorites || [];
|
||||
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}favorites`, JSON.stringify(favs));
|
||||
return favs;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch favorites from server:', e);
|
||||
}
|
||||
}
|
||||
return JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}favorites`) || '[]');
|
||||
};
|
||||
|
||||
export const clearAllFavorites = async (): Promise<void> => {
|
||||
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}favorites`, '[]');
|
||||
if (await checkServerAvailable()) {
|
||||
try {
|
||||
await fetch(`${SERVER_URL}/favorites`, { method: 'DELETE' });
|
||||
} catch (e) {
|
||||
console.error('Failed to clear favorites on server:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Export server status check
|
||||
export const isServerAvailable = checkServerAvailable;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ApolloLink, Observable } from '@apollo/client/core';
|
||||
import { isCountryBanned, findBannedCountry } from '../../config/bannedCountries';
|
||||
import { reverseGeocode } from '../../utils/reverseGeocode';
|
||||
|
||||
// Chokepoint: intercept every DeviceLocationUpdate mutation, reverse-geocode the
|
||||
// input coords, and refuse to forward if the country is on Feeld's restricted list.
|
||||
// This catches paths that bypass the React-level gates (ApiExplorer raw GraphQL,
|
||||
// scanner loops, anything that calls the mutation directly).
|
||||
|
||||
export const bannedCountryLink = new ApolloLink((operation, forward) => {
|
||||
if (operation.operationName !== 'DeviceLocationUpdate') {
|
||||
return forward(operation);
|
||||
}
|
||||
|
||||
const input = (operation.variables as any)?.input ?? {};
|
||||
const lat = Number(input.latitude);
|
||||
const lng = Number(input.longitude);
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
||||
return forward(operation);
|
||||
}
|
||||
|
||||
return new Observable((observer) => {
|
||||
let cancelled = false;
|
||||
let subscription: { unsubscribe: () => void } | null = null;
|
||||
|
||||
reverseGeocode(lat, lng)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (result.resolved && isCountryBanned(result.country, result.countryCode)) {
|
||||
const hit = findBannedCountry(result.country, result.countryCode);
|
||||
const name = hit?.name ?? result.country ?? result.countryCode ?? 'this country';
|
||||
const msg = `[bannedCountryLink] Refusing DeviceLocationUpdate — Feeld is not available in ${name} (${lat.toFixed(3)}, ${lng.toFixed(3)})`;
|
||||
console.warn(msg);
|
||||
observer.error(new Error(`Feeld is not available in ${name}. Pick a different location.`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.resolved) {
|
||||
console.warn(
|
||||
`[bannedCountryLink] Could not resolve country for (${lat.toFixed(3)}, ${lng.toFixed(3)}); forwarding mutation.`
|
||||
);
|
||||
}
|
||||
|
||||
subscription = forward(operation).subscribe(observer);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Reverse-geocode crashed unexpectedly — fail open (forward) to avoid breaking legitimate ops.
|
||||
if (cancelled) return;
|
||||
console.warn('[bannedCountryLink] Reverse-geocode error, forwarding anyway:', err);
|
||||
subscription = forward(operation).subscribe(observer);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
subscription?.unsubscribe();
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -1,62 +1,18 @@
|
||||
import { gql } from '@apollo/client/core';
|
||||
import {
|
||||
DISCOVERY_ANALYTICS_METADATA_FRAGMENT,
|
||||
LIKES_PROFILE_FRAGMENT,
|
||||
PICTURE_FRAGMENT,
|
||||
} from './queries';
|
||||
|
||||
// Real endpoints discovered in v8.11.0 - replaces old wrong guesses
|
||||
// pastLikes / pastDislikes — shapes verbatim from live app v8.11.0.
|
||||
// LikesProfileFragment lives in queries.ts (single source of truth, also used
|
||||
// by FilteredWhoLikesMe / FilteredWhoPingsMe mutations).
|
||||
|
||||
export const LIKES_PROFILE_FRAGMENT = gql`
|
||||
fragment LikesProfileFragment on Profile {
|
||||
id
|
||||
age
|
||||
gender
|
||||
status
|
||||
lastSeen
|
||||
desires
|
||||
connectionGoals
|
||||
isUplift
|
||||
sexuality
|
||||
isMajestic
|
||||
dateOfBirth
|
||||
streamUserId
|
||||
imaginaryName
|
||||
bio
|
||||
hiddenBio
|
||||
hasHiddenBio
|
||||
allowPWM
|
||||
interests
|
||||
verificationStatus
|
||||
interactionStatus {
|
||||
message
|
||||
mine
|
||||
theirs
|
||||
__typename
|
||||
}
|
||||
distance {
|
||||
km
|
||||
mi
|
||||
__typename
|
||||
}
|
||||
photos {
|
||||
id
|
||||
pictureIsPrivate
|
||||
pictureIsSafe
|
||||
pictureStatus
|
||||
pictureType
|
||||
pictureUrl
|
||||
pictureUrls {
|
||||
small
|
||||
medium
|
||||
large
|
||||
__typename
|
||||
}
|
||||
publicId
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
`;
|
||||
|
||||
// pastLikes - profiles you've liked (new in v8.11.0)
|
||||
export const PAST_LIKES_QUERY = gql`
|
||||
${LIKES_PROFILE_FRAGMENT}
|
||||
${DISCOVERY_ANALYTICS_METADATA_FRAGMENT}
|
||||
${PICTURE_FRAGMENT}
|
||||
query pastLikes($cursor: String, $input: PastLikesQueryInput!, $limit: Int) {
|
||||
pastLikes(cursor: $cursor, input: $input, limit: $limit) {
|
||||
nodes {
|
||||
@@ -64,6 +20,11 @@ export const PAST_LIKES_QUERY = gql`
|
||||
interactionSentAt
|
||||
profile {
|
||||
...LikesProfileFragment
|
||||
...DiscoveryAnalyticsMetadata
|
||||
photos {
|
||||
...GetPictureUrlFragment
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
@@ -80,15 +41,21 @@ export const PAST_LIKES_QUERY = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// pastDislikes - profiles you've passed (new in v8.11.0)
|
||||
export const PAST_DISLIKES_QUERY = gql`
|
||||
${LIKES_PROFILE_FRAGMENT}
|
||||
${DISCOVERY_ANALYTICS_METADATA_FRAGMENT}
|
||||
${PICTURE_FRAGMENT}
|
||||
query pastDislikes($cursor: String, $input: PastDislikesQueryInput!, $limit: Int) {
|
||||
pastDislikes(cursor: $cursor, input: $input, limit: $limit) {
|
||||
nodes {
|
||||
interactionSentAt
|
||||
profile {
|
||||
...LikesProfileFragment
|
||||
...DiscoveryAnalyticsMetadata
|
||||
photos {
|
||||
...GetPictureUrlFragment
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { gql } from '@apollo/client/core';
|
||||
import {
|
||||
LIKES_PROFILE_FRAGMENT,
|
||||
PROFILE_LOCATION_FRAGMENT,
|
||||
SEARCH_SETTINGS_PROFILE_FRAGMENT,
|
||||
} from './queries';
|
||||
|
||||
// Mutations - exact from Proxyman
|
||||
// Mutations - shapes verified against live app v8.11.0 capture
|
||||
|
||||
export const PROFILE_LIKE_MUTATION = gql`
|
||||
mutation ProfileLike($targetProfileId: String!) {
|
||||
@@ -48,6 +53,7 @@ export const PROFILE_LIKE_MUTATION = gql`
|
||||
`;
|
||||
|
||||
export const DEVICE_LOCATION_UPDATE_MUTATION = gql`
|
||||
${PROFILE_LOCATION_FRAGMENT}
|
||||
mutation DeviceLocationUpdate($input: DeviceLocationInput!) {
|
||||
deviceLocationUpdate(input: $input) {
|
||||
id
|
||||
@@ -67,37 +73,7 @@ export const DEVICE_LOCATION_UPDATE_MUTATION = gql`
|
||||
profiles {
|
||||
id
|
||||
location {
|
||||
... on DeviceLocation {
|
||||
device {
|
||||
latitude
|
||||
longitude
|
||||
geocode {
|
||||
city
|
||||
country
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
... on TeleportLocation {
|
||||
current: device {
|
||||
city
|
||||
country
|
||||
__typename
|
||||
}
|
||||
teleport {
|
||||
latitude
|
||||
longitude
|
||||
geocode {
|
||||
city
|
||||
country
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
...ProfileLocationFragment
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
@@ -108,12 +84,14 @@ export const DEVICE_LOCATION_UPDATE_MUTATION = gql`
|
||||
`;
|
||||
|
||||
export const SEARCH_SETTINGS_UPDATE_MUTATION = gql`
|
||||
${SEARCH_SETTINGS_PROFILE_FRAGMENT}
|
||||
mutation SearchSettingsUpdate(
|
||||
$ageRange: [Int]
|
||||
$distanceMax: Float
|
||||
$desiringFor: [Desire!]
|
||||
$lookingFor: [LookingFor!]
|
||||
$recentlyOnline: Boolean
|
||||
$lgbtqiaMode: LgbtqiaMode
|
||||
) {
|
||||
profileUpdate(
|
||||
input: {
|
||||
@@ -122,52 +100,10 @@ export const SEARCH_SETTINGS_UPDATE_MUTATION = gql`
|
||||
desiringFor: $desiringFor
|
||||
lookingFor: $lookingFor
|
||||
recentlyOnline: $recentlyOnline
|
||||
lgbtqiaMode: $lgbtqiaMode
|
||||
}
|
||||
) {
|
||||
id
|
||||
ageRange
|
||||
distanceMax
|
||||
desiringFor
|
||||
lookingFor
|
||||
location {
|
||||
... on DeviceLocation {
|
||||
device {
|
||||
latitude
|
||||
longitude
|
||||
geocode {
|
||||
city
|
||||
country
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
... on VirtualLocation {
|
||||
core
|
||||
__typename
|
||||
}
|
||||
... on TeleportLocation {
|
||||
current: device {
|
||||
city
|
||||
country
|
||||
__typename
|
||||
}
|
||||
teleport {
|
||||
latitude
|
||||
longitude
|
||||
geocode {
|
||||
city
|
||||
country
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
recentlyOnline
|
||||
...SearchSettingsProfileFragment
|
||||
__typename
|
||||
}
|
||||
}
|
||||
@@ -175,11 +111,7 @@ export const SEARCH_SETTINGS_UPDATE_MUTATION = gql`
|
||||
|
||||
export const LAST_SEEN_UPDATE_MUTATION = gql`
|
||||
mutation LastSeenProviderUpdateProfile($profileId: String!) {
|
||||
lastSeenProviderUpdateProfile(profileId: $profileId) {
|
||||
id
|
||||
lastSeen
|
||||
__typename
|
||||
}
|
||||
updatedProfileLastSeen: profileUpdateLastSeen(profileId: $profileId)
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -626,45 +558,48 @@ export const APP_SETTINGS_UPDATE_MUTATION = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// Filtered interactions
|
||||
export const FILTERED_WHO_PINGS_ME_MUTATION = gql`
|
||||
mutation FilteredWhoPingsMe($input: FilteredPingInteractionInput!, $cursor: String) {
|
||||
filteredWhoPingsMe(input: $input, cursor: $cursor) {
|
||||
// Filtered interactions — both Likes and Pings tabs use these (mutations, not queries).
|
||||
export const FILTERED_WHO_LIKES_ME_MUTATION = gql`
|
||||
${LIKES_PROFILE_FRAGMENT}
|
||||
mutation FilteredWhoLikesMe($input: FilteredInteractionInput!, $cursor: String) {
|
||||
filteredWhoLikesMe(input: $input, cursor: $cursor) {
|
||||
filters {
|
||||
ageRange
|
||||
desires
|
||||
lookingFor
|
||||
sexualities
|
||||
__typename
|
||||
}
|
||||
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
|
||||
}
|
||||
...LikesProfileFragment
|
||||
__typename
|
||||
}
|
||||
pageInfo {
|
||||
total
|
||||
unfilteredTotal
|
||||
hasNextPage
|
||||
nextPageCursor
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const FILTERED_WHO_PINGS_ME_MUTATION = gql`
|
||||
${LIKES_PROFILE_FRAGMENT}
|
||||
mutation FilteredWhoPingsMe($input: FilteredPingInteractionInput!, $cursor: String) {
|
||||
filteredWhoPingsMe(input: $input, cursor: $cursor) {
|
||||
filters {
|
||||
ageRange
|
||||
__typename
|
||||
}
|
||||
profiles {
|
||||
nodes {
|
||||
...LikesProfileFragment
|
||||
__typename
|
||||
}
|
||||
pageInfo {
|
||||
|
||||
+467
-242
@@ -1,6 +1,9 @@
|
||||
import { gql } from '@apollo/client/core';
|
||||
|
||||
// Profile fragments - exact from Proxyman
|
||||
// ============================================================================
|
||||
// Profile fragments — verbatim from live app v8.11.0 capture
|
||||
// ============================================================================
|
||||
|
||||
export const PROFILE_LOCATION_FRAGMENT = gql`
|
||||
fragment ProfileLocationFragment on ProfileLocation {
|
||||
... on DeviceLocation {
|
||||
@@ -130,6 +133,54 @@ export const ANALYTICS_PROFILE_FRAGMENT = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// Analytics fragment for the OWN profile (extra fields like analyticsId, lgbtqiaMode, lookingFor, etc.)
|
||||
export const ANALYTICS_OWN_PROFILE_FRAGMENT = gql`
|
||||
fragment AnalyticsOwnProfileFragment on Profile {
|
||||
id
|
||||
age
|
||||
ageRange
|
||||
bio
|
||||
desires
|
||||
desiringFor
|
||||
analyticsId
|
||||
distanceMax
|
||||
hasHiddenBio
|
||||
hiddenBio
|
||||
interests
|
||||
isUplift
|
||||
recentlyOnline
|
||||
lgbtqiaMode
|
||||
isIncognito
|
||||
status
|
||||
isMajestic
|
||||
gender
|
||||
dateOfBirth
|
||||
lookingFor
|
||||
sexuality
|
||||
allowPWM
|
||||
enableChatContentModeration
|
||||
location {
|
||||
...ProfileLocationFragment
|
||||
__typename
|
||||
}
|
||||
profilePairs {
|
||||
identityId
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
`;
|
||||
|
||||
export const DISCOVERY_ANALYTICS_METADATA_FRAGMENT = gql`
|
||||
fragment DiscoveryAnalyticsMetadata on Profile {
|
||||
metadata {
|
||||
source
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
`;
|
||||
|
||||
export const PARTNER_FRAGMENT = gql`
|
||||
${PICTURE_FRAGMENT}
|
||||
${PROFILE_INTERACTION_STATUS_FRAGMENT}
|
||||
@@ -170,206 +221,291 @@ export const CONSTELLATION_FRAGMENT = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// Main queries - exact from Proxyman
|
||||
export const PROFILE_QUERY = gql`
|
||||
// Profile content fragment — what the app uses for ProfileQuery + DiscoverProfiles nodes
|
||||
export const PROFILE_CONTENT_PROFILE_FRAGMENT = gql`
|
||||
${PROFILE_LOCATION_FRAGMENT}
|
||||
${PHOTO_CAROUSEL_FRAGMENT}
|
||||
${CONSTELLATION_FRAGMENT}
|
||||
${ANALYTICS_PROFILE_FRAGMENT}
|
||||
${DISCOVERY_ANALYTICS_METADATA_FRAGMENT}
|
||||
fragment ProfileContentProfileFragment on Profile {
|
||||
bio
|
||||
hiddenBio
|
||||
hasHiddenBio
|
||||
age
|
||||
streamUserId
|
||||
dateOfBirth
|
||||
distance {
|
||||
km
|
||||
mi
|
||||
__typename
|
||||
}
|
||||
connectionGoals
|
||||
desires
|
||||
gender
|
||||
id
|
||||
status
|
||||
imaginaryName
|
||||
interactionStatus {
|
||||
message
|
||||
mine
|
||||
theirs
|
||||
__typename
|
||||
}
|
||||
interests
|
||||
isMajestic
|
||||
isIncognito
|
||||
lastSeen
|
||||
location {
|
||||
...ProfileLocationFragment
|
||||
__typename
|
||||
}
|
||||
sexuality
|
||||
photos {
|
||||
...PhotoCarouselPictureFragment
|
||||
__typename
|
||||
}
|
||||
...Constellation
|
||||
allowPWM
|
||||
verificationStatus
|
||||
enableChatContentModeration
|
||||
...AnalyticsProfileFragment
|
||||
...DiscoveryAnalyticsMetadata
|
||||
isParticipantToEventChat
|
||||
__typename
|
||||
}
|
||||
`;
|
||||
|
||||
// Likes-tab profile fragment — used by FilteredWhoLikesMe / pastLikes / pastDislikes
|
||||
export const LIKES_PROFILE_FRAGMENT = gql`
|
||||
${PROFILE_LOCATION_FRAGMENT}
|
||||
${PHOTO_CAROUSEL_FRAGMENT}
|
||||
fragment LikesProfileFragment on Profile {
|
||||
id
|
||||
age
|
||||
gender
|
||||
status
|
||||
lastSeen
|
||||
desires
|
||||
connectionGoals
|
||||
isUplift
|
||||
sexuality
|
||||
isMajestic
|
||||
dateOfBirth
|
||||
streamUserId
|
||||
imaginaryName
|
||||
bio
|
||||
hiddenBio
|
||||
hasHiddenBio
|
||||
allowPWM
|
||||
interests
|
||||
verificationStatus
|
||||
interactionStatus {
|
||||
message
|
||||
mine
|
||||
theirs
|
||||
__typename
|
||||
}
|
||||
profilePairs {
|
||||
identityId
|
||||
__typename
|
||||
}
|
||||
distance {
|
||||
km
|
||||
mi
|
||||
__typename
|
||||
}
|
||||
location {
|
||||
...ProfileLocationFragment
|
||||
__typename
|
||||
}
|
||||
photos {
|
||||
...PhotoCarouselPictureFragment
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Account / auth fragments — verbatim from live app
|
||||
// ============================================================================
|
||||
|
||||
export const PICTURE_VERIFICATION_META_FRAGMENT = gql`
|
||||
fragment PictureVerificationMeta on Picture {
|
||||
id
|
||||
verification {
|
||||
status
|
||||
updatedAt
|
||||
sessionUrl
|
||||
failureReason
|
||||
attempts
|
||||
__typename
|
||||
}
|
||||
enrollment {
|
||||
sessionId
|
||||
status
|
||||
updatedAt
|
||||
failureReason
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
`;
|
||||
|
||||
export const CHAT_USER_FRAGMENT = gql`
|
||||
fragment ChatUser on Profile {
|
||||
id
|
||||
streamToken
|
||||
streamUserId
|
||||
__typename
|
||||
}
|
||||
`;
|
||||
|
||||
export const APP_SETTINGS_OPTIONS_ACCOUNT_FRAGMENT = gql`
|
||||
fragment AppSettingsOptionsAccountFragment on Account {
|
||||
appSettings {
|
||||
receiveMarketingNotifications
|
||||
receiveNewsEmailNotifications
|
||||
receivePromotionsEmailNotifications
|
||||
receiveNewsPushNotifications
|
||||
receivePromotionsPushNotifications
|
||||
receiveNewConnectionPushNotifications
|
||||
receiveNewPingPushNotifications
|
||||
receiveNewMessagePushNotifications
|
||||
receiveNewLikePushNotifications
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
`;
|
||||
|
||||
export const AUTH_PROFILE_FRAGMENT = gql`
|
||||
${PICTURE_VERIFICATION_META_FRAGMENT}
|
||||
${PICTURE_FRAGMENT}
|
||||
${CHAT_USER_FRAGMENT}
|
||||
${ANALYTICS_OWN_PROFILE_FRAGMENT}
|
||||
fragment AuthProfile on Profile {
|
||||
imaginaryName
|
||||
verificationLimits {
|
||||
attemptsAvailable
|
||||
__typename
|
||||
}
|
||||
connectionGoals
|
||||
canVerify
|
||||
photos {
|
||||
pictureUrl
|
||||
pictureStatus
|
||||
...PictureVerificationMeta
|
||||
...GetPictureUrlFragment
|
||||
__typename
|
||||
}
|
||||
verificationStatus
|
||||
reflectionResponseIds
|
||||
...ChatUser
|
||||
...AnalyticsOwnProfileFragment
|
||||
showTerminatedConnectionBanner
|
||||
__typename
|
||||
}
|
||||
`;
|
||||
|
||||
export const AUTH_PROVIDER_FRAGMENT = gql`
|
||||
${APP_SETTINGS_OPTIONS_ACCOUNT_FRAGMENT}
|
||||
${AUTH_PROFILE_FRAGMENT}
|
||||
fragment AuthProviderFragment on Account {
|
||||
id
|
||||
email
|
||||
analyticsId
|
||||
status
|
||||
createdAt
|
||||
isFinishedOnboarding
|
||||
isMajestic
|
||||
availablePings
|
||||
upliftExpirationTimestamp
|
||||
isUplift
|
||||
isDistanceInMiles
|
||||
language
|
||||
ageVerificationStatus
|
||||
verificationNumber
|
||||
challenges
|
||||
...AppSettingsOptionsAccountFragment
|
||||
location {
|
||||
device {
|
||||
country
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
profiles {
|
||||
...AuthProfile
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Search-settings fragment — used by SearchSettingsQuery + SearchSettingsUpdate
|
||||
// ============================================================================
|
||||
|
||||
export const SEARCH_SETTINGS_PROFILE_FRAGMENT = gql`
|
||||
${PROFILE_LOCATION_FRAGMENT}
|
||||
fragment SearchSettingsProfileFragment on Profile {
|
||||
id
|
||||
ageRange
|
||||
distanceMax
|
||||
desiringFor
|
||||
lookingFor
|
||||
location {
|
||||
...ProfileLocationFragment
|
||||
__typename
|
||||
}
|
||||
recentlyOnline
|
||||
lgbtqiaMode
|
||||
__typename
|
||||
}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Queries — verbatim shape from live app v8.11.0
|
||||
// ============================================================================
|
||||
|
||||
export const PROFILE_QUERY = gql`
|
||||
${PROFILE_CONTENT_PROFILE_FRAGMENT}
|
||||
query ProfileQuery($profileId: String!) {
|
||||
profile(id: $profileId) {
|
||||
bio
|
||||
hiddenBio
|
||||
hasHiddenBio
|
||||
age
|
||||
...ProfileContentProfileFragment
|
||||
streamUserId
|
||||
dateOfBirth
|
||||
distance {
|
||||
km
|
||||
mi
|
||||
__typename
|
||||
}
|
||||
connectionGoals
|
||||
desires
|
||||
lookingFor
|
||||
ageRange
|
||||
distanceMax
|
||||
recentlyOnline
|
||||
gender
|
||||
id
|
||||
status
|
||||
imaginaryName
|
||||
interactionStatus {
|
||||
message
|
||||
mine
|
||||
theirs
|
||||
__typename
|
||||
}
|
||||
interests
|
||||
isMajestic
|
||||
isIncognito
|
||||
lastSeen
|
||||
location {
|
||||
...ProfileLocationFragment
|
||||
__typename
|
||||
}
|
||||
sexuality
|
||||
photos {
|
||||
...PhotoCarouselPictureFragment
|
||||
__typename
|
||||
}
|
||||
...Constellation
|
||||
allowPWM
|
||||
verificationStatus
|
||||
enableChatContentModeration
|
||||
...AnalyticsProfileFragment
|
||||
isParticipantToEventChat
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DISCOVER_PROFILES_QUERY = gql`
|
||||
${PICTURE_FRAGMENT}
|
||||
${PROFILE_INTERACTION_STATUS_FRAGMENT}
|
||||
${ANALYTICS_PROFILE_FRAGMENT}
|
||||
${PROFILE_CONTENT_PROFILE_FRAGMENT}
|
||||
${CONSTELLATION_FRAGMENT}
|
||||
${DISCOVERY_ANALYTICS_METADATA_FRAGMENT}
|
||||
query DiscoverProfiles($input: ProfileDiscoveryInput!) {
|
||||
discovery(input: $input) {
|
||||
nodes {
|
||||
id
|
||||
...ProfileContentProfileFragment
|
||||
...DiscoveryAnalyticsMetadata
|
||||
...Constellation
|
||||
streamUserId
|
||||
analyticsId
|
||||
age
|
||||
imaginaryName
|
||||
gender
|
||||
sexuality
|
||||
isIncognito
|
||||
isMajestic
|
||||
verificationStatus
|
||||
connectionGoals
|
||||
desires
|
||||
bio
|
||||
interests
|
||||
distance {
|
||||
km
|
||||
mi
|
||||
__typename
|
||||
}
|
||||
photos {
|
||||
...GetPictureUrlFragment
|
||||
pictureType
|
||||
__typename
|
||||
}
|
||||
...ProfileInteractionStatusFragment
|
||||
...AnalyticsProfileFragment
|
||||
__typename
|
||||
}
|
||||
hasNextBatch
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const WHO_LIKES_ME_QUERY = gql`
|
||||
${PICTURE_FRAGMENT}
|
||||
${PROFILE_INTERACTION_STATUS_FRAGMENT}
|
||||
${ANALYTICS_PROFILE_FRAGMENT}
|
||||
query WhoLikesMe($sortBy: SortBy!, $limit: Int, $cursor: String) {
|
||||
interactions: whoLikesMe(input: { sortBy: $sortBy }, limit: $limit, cursor: $cursor) {
|
||||
nodes {
|
||||
id
|
||||
age
|
||||
imaginaryName
|
||||
gender
|
||||
sexuality
|
||||
isIncognito
|
||||
isMajestic
|
||||
verificationStatus
|
||||
desires
|
||||
connectionGoals
|
||||
distance {
|
||||
km
|
||||
mi
|
||||
__typename
|
||||
}
|
||||
photos {
|
||||
...GetPictureUrlFragment
|
||||
pictureType
|
||||
__typename
|
||||
}
|
||||
...ProfileInteractionStatusFragment
|
||||
...AnalyticsProfileFragment
|
||||
__typename
|
||||
}
|
||||
pageInfo {
|
||||
total
|
||||
hasNextPage
|
||||
nextPageCursor
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const WHO_PINGS_ME_QUERY = gql`
|
||||
${PROFILE_LOCATION_FRAGMENT}
|
||||
${PHOTO_CAROUSEL_FRAGMENT}
|
||||
query WhoPingsMe($sortBy: SortBy!, $limit: Int, $cursor: String) {
|
||||
interactions: whoPingsMe(
|
||||
input: { sortBy: $sortBy }
|
||||
limit: $limit
|
||||
cursor: $cursor
|
||||
) {
|
||||
nodes {
|
||||
id
|
||||
age
|
||||
gender
|
||||
status
|
||||
lastSeen
|
||||
desires
|
||||
connectionGoals
|
||||
isUplift
|
||||
sexuality
|
||||
isMajestic
|
||||
dateOfBirth
|
||||
streamUserId
|
||||
imaginaryName
|
||||
bio
|
||||
hiddenBio
|
||||
hasHiddenBio
|
||||
allowPWM
|
||||
interests
|
||||
verificationStatus
|
||||
interactionStatus {
|
||||
message
|
||||
mine
|
||||
theirs
|
||||
__typename
|
||||
}
|
||||
profilePairs {
|
||||
identityId
|
||||
__typename
|
||||
}
|
||||
distance {
|
||||
km
|
||||
mi
|
||||
__typename
|
||||
}
|
||||
location {
|
||||
...ProfileLocationFragment
|
||||
__typename
|
||||
}
|
||||
photos {
|
||||
...PhotoCarouselPictureFragment
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
pageInfo {
|
||||
total
|
||||
hasNextPage
|
||||
nextPageCursor
|
||||
__typename
|
||||
}
|
||||
feedGeneratedAt
|
||||
generatedWithProfileUpdatedAt
|
||||
feedSize
|
||||
feedCapacity
|
||||
__typename
|
||||
}
|
||||
}
|
||||
@@ -415,7 +551,7 @@ export const HEADER_SUMMARIES_QUERY = gql`
|
||||
`;
|
||||
|
||||
export const LIST_SUMMARIES_QUERY = gql`
|
||||
query ListSummaries($limit: Int = 30, $cursor: String) {
|
||||
query ListSummaries($limit: Int = 10, $cursor: String) {
|
||||
summaries: getChatSummariesForChatList(limit: $limit, cursor: $cursor) {
|
||||
nodes {
|
||||
id
|
||||
@@ -484,27 +620,48 @@ export const GET_CHAT_SUMMARY_QUERY = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// SearchSettingsQuery — own-profile search settings (the app fetches these
|
||||
// separately from ProfileQuery, which doesn't include them).
|
||||
export const SEARCH_SETTINGS_QUERY = gql`
|
||||
${SEARCH_SETTINGS_PROFILE_FRAGMENT}
|
||||
query SearchSettingsQuery($profileId: String!) {
|
||||
profile(id: $profileId) {
|
||||
...SearchSettingsProfileFragment
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DISCOVER_SEARCH_SETTINGS_QUERY = gql`
|
||||
${PROFILE_LOCATION_FRAGMENT}
|
||||
query DiscoverSearchSettingsQuery($profileId: String!) {
|
||||
profile(id: $profileId) {
|
||||
id
|
||||
status
|
||||
ageRange
|
||||
distanceMax
|
||||
lookingFor
|
||||
desiringFor
|
||||
desires
|
||||
distanceMax
|
||||
location {
|
||||
...ProfileLocationFragment
|
||||
__typename
|
||||
}
|
||||
lookingFor
|
||||
recentlyOnline
|
||||
lgbtqiaMode
|
||||
allowPWM
|
||||
imaginaryName
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AVAILABLE_DESIRES_QUERY = gql`
|
||||
query AvailableDesires($locale: String!, $version: Int!) {
|
||||
availableDesires(locale: $locale, version: $version) {
|
||||
id
|
||||
name
|
||||
description
|
||||
query AvailableDesires($locale: String!, $version: Int) {
|
||||
desiresByCategoryLocalised(locale: $locale, version: $version) {
|
||||
category
|
||||
desires
|
||||
localisedCategory
|
||||
__typename
|
||||
}
|
||||
}
|
||||
@@ -520,7 +677,111 @@ export const IS_INCOGNITO_QUERY = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// Query for Stream Chat credentials
|
||||
export const IS_PROFILE_VALID_QUERY = gql`
|
||||
query IsProfileValidQuery($profileId: String!) {
|
||||
profile(id: $profileId) {
|
||||
id
|
||||
status
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LGBTQIA_MODE_CHECK_QUERY = gql`
|
||||
query LgbtqiaModeCheck($profileId: String!) {
|
||||
profile(id: $profileId) {
|
||||
id
|
||||
gender
|
||||
sexuality
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPLIFT_PURCHASE_QUERY = gql`
|
||||
query UpliftPurchaseQuery($profileId: String!) {
|
||||
profile(id: $profileId) {
|
||||
id
|
||||
status
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const PHOTO_SELECTOR_QUERY = gql`
|
||||
query PhotoSelectorQuery($profileId: String!) {
|
||||
profile(id: $profileId) {
|
||||
id
|
||||
photos {
|
||||
id
|
||||
pictureIsPrivate
|
||||
pictureIsSafe
|
||||
pictureOrder
|
||||
pictureStatus
|
||||
pictureType
|
||||
pictureUrl
|
||||
pictureUrls {
|
||||
small
|
||||
medium
|
||||
large
|
||||
__typename
|
||||
}
|
||||
publicId
|
||||
verification {
|
||||
status
|
||||
failureReason
|
||||
attempts
|
||||
__typename
|
||||
}
|
||||
enrollment {
|
||||
status
|
||||
failureReason
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const WLY_FILTERS_QUERY = gql`
|
||||
query WLYFiltersQuery($profileId: String!) {
|
||||
profile(id: $profileId) {
|
||||
id
|
||||
ageRange
|
||||
whoLikesMeFilters {
|
||||
ageRange
|
||||
desires
|
||||
lookingFor
|
||||
sexualities
|
||||
__typename
|
||||
}
|
||||
whoPingsMeFilters {
|
||||
ageRange
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ANALYTICS_QUERY = gql`
|
||||
${ANALYTICS_OWN_PROFILE_FRAGMENT}
|
||||
query AnalyticsQuery($profileId: String!) {
|
||||
account {
|
||||
id
|
||||
analyticsId
|
||||
__typename
|
||||
}
|
||||
profile(id: $profileId) {
|
||||
...AnalyticsOwnProfileFragment
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Stream Chat credentials (own-profile streamToken)
|
||||
export const STREAM_CREDENTIALS_QUERY = gql`
|
||||
query StreamCredentialsQuery($profileId: String!) {
|
||||
profile(id: $profileId) {
|
||||
@@ -533,22 +794,19 @@ export const STREAM_CREDENTIALS_QUERY = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// AuthProviderQuery — fetches the full account snapshot via AuthProviderFragment.
|
||||
// The live app uses this to populate its account state at startup.
|
||||
export const AUTH_PROVIDER_QUERY = gql`
|
||||
${AUTH_PROVIDER_FRAGMENT}
|
||||
query AuthProviderQuery {
|
||||
account {
|
||||
id
|
||||
status
|
||||
profiles {
|
||||
id
|
||||
status
|
||||
imaginaryName
|
||||
__typename
|
||||
}
|
||||
...AuthProviderFragment
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// AuthProviderStatusSyncQuery — incremental account/profile sync for status changes.
|
||||
export const ACCOUNT_STATUS_QUERY = gql`
|
||||
${PICTURE_FRAGMENT}
|
||||
${PROFILE_INTERACTION_STATUS_FRAGMENT}
|
||||
@@ -578,7 +836,16 @@ export const ACCOUNT_STATUS_QUERY = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// === New queries discovered via API probing + APK analysis (v8.11.0) ===
|
||||
// AppSettings — same shape as AuthProviderQuery (live app reuses the fragment).
|
||||
export const APP_SETTINGS_QUERY = gql`
|
||||
${AUTH_PROVIDER_FRAGMENT}
|
||||
query AppSettings {
|
||||
account {
|
||||
...AuthProviderFragment
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const POPULAR_LOCATIONS_QUERY = gql`
|
||||
query PopularLocationsQuery {
|
||||
@@ -637,53 +904,6 @@ export const REDEEMED_OFFERS_QUERY = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
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!) {
|
||||
@@ -766,8 +986,13 @@ export const PROFILE_MATCHES_QUERY = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// hasLinkedReflection — operationName is lowercase to match live app exactly.
|
||||
export const HAS_LINKED_REFLECTION_QUERY = gql`
|
||||
query HasLinkedReflection {
|
||||
query hasLinkedReflection {
|
||||
hasLinkedReflection
|
||||
}
|
||||
`;
|
||||
|
||||
// Note: the legacy WhoLikesMe / WhoPingsMe *queries* are no longer used.
|
||||
// The live app uses FilteredWhoLikesMe / FilteredWhoPingsMe *mutations*
|
||||
// (see mutations.ts). Don't add them back.
|
||||
|
||||
@@ -36,6 +36,10 @@ interface ProfileCardProps {
|
||||
onDislike?: (profile: any) => Promise<any>;
|
||||
isDisliking?: boolean;
|
||||
showDislike?: boolean;
|
||||
// Favorite ("save for later") — local-only, no remote action.
|
||||
// Star button renders only when both props are supplied.
|
||||
onToggleFavorite?: (profile: any) => void | Promise<void>;
|
||||
isFavorited?: boolean;
|
||||
}
|
||||
|
||||
const safeText = (v: any): string => {
|
||||
@@ -45,7 +49,7 @@ const safeText = (v: any): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
export function ProfileCard({ profile, onClick, index = 0, onRefresh, isRefreshing, onDislike, isDisliking, showDislike }: ProfileCardProps) {
|
||||
export function ProfileCard({ profile, onClick, index = 0, onRefresh, isRefreshing, onDislike, isDisliking, showDislike, onToggleFavorite, isFavorited }: ProfileCardProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -76,6 +80,11 @@ export function ProfileCard({ profile, onClick, index = 0, onRefresh, isRefreshi
|
||||
if (onDislike && !isDisliking) await onDislike(profile);
|
||||
};
|
||||
|
||||
const handleToggleFavorite = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onToggleFavorite) onToggleFavorite(profile);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
@@ -192,6 +201,37 @@ export function ProfileCard({ profile, onClick, index = 0, onRefresh, isRefreshi
|
||||
|
||||
{/* Top right badges */}
|
||||
<div style={{ position: 'absolute', top: '10px', right: '10px', zIndex: 10, display: 'flex', gap: '6px' }}>
|
||||
{onToggleFavorite && (
|
||||
<button
|
||||
onClick={handleToggleFavorite}
|
||||
title={isFavorited ? 'Remove from saved' : 'Save for later'}
|
||||
className="btn-press"
|
||||
style={{
|
||||
width: '26px', height: '26px', borderRadius: '50%',
|
||||
background: isFavorited
|
||||
? 'linear-gradient(135deg, #a855f7, #7c3aed)'
|
||||
: 'rgba(0,0,0,0.5)',
|
||||
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', backdropFilter: 'blur(8px)',
|
||||
boxShadow: isFavorited ? '0 2px 8px rgba(168,85,247,0.4)' : 'none',
|
||||
transition: 'all 200ms',
|
||||
}}
|
||||
>
|
||||
{/* Bookmark icon — distinct from the Majestic star badge so the
|
||||
"save for later" action is visually unambiguous. */}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill={isFavorited ? '#fff' : 'none'}
|
||||
stroke={isFavorited ? '#fff' : 'rgba(255,255,255,0.9)'}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ width: 13, height: 13 }}
|
||||
>
|
||||
<path d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{profile.isMajestic && (
|
||||
<div style={{
|
||||
width: '26px', height: '26px', borderRadius: '50%',
|
||||
|
||||
@@ -12,6 +12,10 @@ interface ProfileDetailModalProps {
|
||||
profileId: string;
|
||||
onClose: () => void;
|
||||
onMatch?: () => void;
|
||||
// Fires after any committed action on this profile (like, ping, dislike).
|
||||
// Used by the Saved tab to auto-remove the profile from favorites after
|
||||
// an action is taken from within the modal.
|
||||
onActionTaken?: (action: 'like' | 'ping' | 'dislike') => void;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
@@ -521,7 +525,7 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetailModalProps) {
|
||||
export function ProfileDetailModal({ profileId, onClose, onMatch, onActionTaken }: ProfileDetailModalProps) {
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [showPingModal, setShowPingModal] = useState(false);
|
||||
@@ -606,6 +610,7 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai
|
||||
if (data.profileLike.status === 'MATCHED') {
|
||||
onMatch?.();
|
||||
}
|
||||
onActionTaken?.('like');
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
@@ -617,6 +622,7 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai
|
||||
await addSentPing(viewingProfileId, profile?.imaginaryName);
|
||||
refetchAccount();
|
||||
setShowPingModal(false);
|
||||
onActionTaken?.('ping');
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
@@ -637,6 +643,7 @@ export function ProfileDetailModal({ profileId, onClose, onMatch }: ProfileDetai
|
||||
});
|
||||
console.log('Disliked profile:', profile?.imaginaryName);
|
||||
}
|
||||
onActionTaken?.('dislike');
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Countries where Feeld is NOT available.
|
||||
// Source: https://support.feeld.co/hc/en-gb/articles/14876019037212-Countries-where-Feeld-is-NOT-available
|
||||
//
|
||||
// Using a location in any of these countries will get the account flagged inactive
|
||||
// per Feeld's policy. The web client refuses to set/save them.
|
||||
|
||||
export interface BannedCountry {
|
||||
name: string;
|
||||
code: string; // ISO 3166-1 alpha-2
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
export const BANNED_COUNTRIES: BannedCountry[] = [
|
||||
{ name: 'Albania', code: 'AL' },
|
||||
{ name: 'Algeria', code: 'DZ' },
|
||||
{ name: 'Angola', code: 'AO' },
|
||||
{ name: 'Armenia', code: 'AM' },
|
||||
{ name: 'Azerbaijan', code: 'AZ' },
|
||||
{ name: 'Bahrain', code: 'BH' },
|
||||
{ name: 'Bangladesh', code: 'BD' },
|
||||
{ name: 'Benin', code: 'BJ' },
|
||||
{ name: 'Botswana', code: 'BW' },
|
||||
{ name: 'Brunei Darussalam', code: 'BN', aliases: ['Brunei'] },
|
||||
{ name: 'Burkina Faso', code: 'BF' },
|
||||
{ name: 'Cameroon', code: 'CM' },
|
||||
{ name: 'Chad', code: 'TD' },
|
||||
{ name: 'China', code: 'CN', aliases: ["People's Republic of China", 'PRC'] },
|
||||
{ name: 'Republic of the Congo', code: 'CG', aliases: ['Congo', 'Congo, Republic of', 'Congo-Brazzaville'] },
|
||||
{ name: 'Cuba', code: 'CU' },
|
||||
{ name: 'Egypt', code: 'EG' },
|
||||
{ name: 'Gabon', code: 'GA' },
|
||||
{ name: 'Gambia', code: 'GM', aliases: ['The Gambia'] },
|
||||
{ name: 'Hong Kong', code: 'HK' },
|
||||
{ name: 'Iran', code: 'IR', aliases: ['Islamic Republic of Iran'] },
|
||||
{ name: 'Iraq', code: 'IQ' },
|
||||
{ name: 'Jordan', code: 'JO' },
|
||||
{ name: 'Kazakhstan', code: 'KZ' },
|
||||
{ name: 'Kenya', code: 'KE' },
|
||||
{ name: 'Laos', code: 'LA', aliases: ["Lao People's Democratic Republic", 'Lao PDR'] },
|
||||
{ name: 'Liberia', code: 'LR' },
|
||||
{ name: 'Macau', code: 'MO', aliases: ['Macao'] },
|
||||
{ name: 'Madagascar', code: 'MG' },
|
||||
{ name: 'Malawi', code: 'MW' },
|
||||
{ name: 'Mali', code: 'ML' },
|
||||
{ name: 'Mauritania', code: 'MR' },
|
||||
{ name: 'Mauritius', code: 'MU' },
|
||||
{ name: 'Myanmar', code: 'MM', aliases: ['Burma', 'Mayanmar'] }, // "Mayanmar" is Feeld's page typo
|
||||
{ name: 'Morocco', code: 'MA' },
|
||||
{ name: 'Mozambique', code: 'MZ' },
|
||||
{ name: 'Namibia', code: 'NA' },
|
||||
{ name: 'Niger', code: 'NE' },
|
||||
{ name: 'Oman', code: 'OM' },
|
||||
{ name: 'Pakistan', code: 'PK' },
|
||||
{ name: 'Papua New Guinea', code: 'PG' },
|
||||
{ name: 'Philippines', code: 'PH' },
|
||||
{ name: 'Qatar', code: 'QA' },
|
||||
{ name: 'Russia', code: 'RU', aliases: ['Russian Federation'] },
|
||||
{ name: 'Saudi Arabia', code: 'SA' },
|
||||
{ name: 'Senegal', code: 'SN' },
|
||||
{ name: 'Sierra Leone', code: 'SL' },
|
||||
{ name: 'Sudan', code: 'SD' },
|
||||
{ name: 'Taiwan', code: 'TW', aliases: ['Taiwan, Province of China', 'Republic of China'] },
|
||||
{ name: 'Tajikistan', code: 'TJ' },
|
||||
{ name: 'Tanzania', code: 'TZ', aliases: ['United Republic of Tanzania'] },
|
||||
{ name: 'Thailand', code: 'TH' },
|
||||
{ name: 'Togo', code: 'TG' },
|
||||
{ name: 'Tunisia', code: 'TN' },
|
||||
{ name: 'Turkmenistan', code: 'TM' },
|
||||
{ name: 'Uganda', code: 'UG' },
|
||||
{ name: 'Uzbekistan', code: 'UZ' },
|
||||
{ name: 'Yemen', code: 'YE' },
|
||||
{ name: 'Zambia', code: 'ZM' },
|
||||
{ name: 'Zimbabwe', code: 'ZW' },
|
||||
];
|
||||
|
||||
const BANNED_CODES = new Set(BANNED_COUNTRIES.map((c) => c.code.toUpperCase()));
|
||||
const BANNED_NAMES = new Set<string>();
|
||||
for (const c of BANNED_COUNTRIES) {
|
||||
BANNED_NAMES.add(c.name.toLowerCase());
|
||||
for (const alias of c.aliases ?? []) BANNED_NAMES.add(alias.toLowerCase());
|
||||
}
|
||||
|
||||
const normalize = (s: string) => s.trim().toLowerCase();
|
||||
|
||||
export function isCountryBanned(country?: string | null, countryCode?: string | null): boolean {
|
||||
if (countryCode && BANNED_CODES.has(countryCode.trim().toUpperCase())) return true;
|
||||
if (country && BANNED_NAMES.has(normalize(country))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findBannedCountry(country?: string | null, countryCode?: string | null): BannedCountry | null {
|
||||
if (countryCode) {
|
||||
const code = countryCode.trim().toUpperCase();
|
||||
const hit = BANNED_COUNTRIES.find((c) => c.code === code);
|
||||
if (hit) return hit;
|
||||
}
|
||||
if (country) {
|
||||
const n = normalize(country);
|
||||
const hit = BANNED_COUNTRIES.find(
|
||||
(c) => c.name.toLowerCase() === n || c.aliases?.some((a) => a.toLowerCase() === n)
|
||||
);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as dataSync from '../api/dataSync';
|
||||
import { apolloClient } from '../api/client';
|
||||
import { PROFILE_QUERY } from '../api/operations/queries';
|
||||
|
||||
export type FavoriteProfile = {
|
||||
id: string;
|
||||
imaginaryName?: string;
|
||||
age?: number;
|
||||
gender?: string;
|
||||
sexuality?: string;
|
||||
bio?: string | null;
|
||||
desires?: string[];
|
||||
connectionGoals?: string[];
|
||||
interests?: string[];
|
||||
verificationStatus?: string | null;
|
||||
isMajestic?: boolean;
|
||||
distance?: { km: number; mi: number } | null;
|
||||
photos?: Array<{
|
||||
id: string;
|
||||
publicId?: string;
|
||||
pictureUrls?: { small?: string; medium?: string; large?: string };
|
||||
}>;
|
||||
interactionStatus?: {
|
||||
mine?: string | null;
|
||||
theirs?: string | null;
|
||||
message?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export interface EnrichedFavorite {
|
||||
targetProfileId: string;
|
||||
savedAt: number;
|
||||
profile?: FavoriteProfile;
|
||||
profileError?: string;
|
||||
}
|
||||
|
||||
// Strip volatile fields (signed photo URLs that expire) before saving the
|
||||
// snapshot. Keep enough to render a card if Feeld's API isn't available.
|
||||
const snapshotForStorage = (p: any): FavoriteProfile => ({
|
||||
id: p?.id,
|
||||
imaginaryName: p?.imaginaryName,
|
||||
age: p?.age,
|
||||
gender: typeof p?.gender === 'string' ? p.gender : undefined,
|
||||
sexuality: typeof p?.sexuality === 'string' ? p.sexuality : undefined,
|
||||
bio: typeof p?.bio === 'string' ? p.bio : null,
|
||||
desires: Array.isArray(p?.desires) ? p.desires : [],
|
||||
connectionGoals: Array.isArray(p?.connectionGoals) ? p.connectionGoals : [],
|
||||
interests: Array.isArray(p?.interests) ? p.interests : [],
|
||||
verificationStatus: typeof p?.verificationStatus === 'string' ? p.verificationStatus : null,
|
||||
isMajestic: !!p?.isMajestic,
|
||||
distance: p?.distance ?? null,
|
||||
photos: Array.isArray(p?.photos)
|
||||
? p.photos.map((ph: any) => ({
|
||||
id: ph?.id,
|
||||
publicId: ph?.publicId,
|
||||
// pictureUrls intentionally omitted — they're signed and expire
|
||||
}))
|
||||
: [],
|
||||
});
|
||||
|
||||
export function useFavorites() {
|
||||
const [favorites, setFavorites] = useState<EnrichedFavorite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profilesLoading, setProfilesLoading] = useState(false);
|
||||
|
||||
const refreshFromServer = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const raw = await dataSync.getFavorites();
|
||||
// Start with snapshot data so the UI renders immediately
|
||||
setFavorites(
|
||||
raw.map((f) => ({
|
||||
targetProfileId: f.targetProfileId,
|
||||
savedAt: f.savedAt,
|
||||
profile: f.profile as FavoriteProfile | undefined,
|
||||
})),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
refreshFromServer();
|
||||
}, [refreshFromServer]);
|
||||
|
||||
// Re-fetch full profile data + fresh signed photo URLs + current
|
||||
// interactionStatus for every favorite via ProfileQuery. Uses network-only
|
||||
// so we never serve stale interaction state from the Apollo cache.
|
||||
const enrichProfiles = useCallback(async () => {
|
||||
const current = favorites;
|
||||
if (current.length === 0) return;
|
||||
setProfilesLoading(true);
|
||||
try {
|
||||
const enriched = await Promise.all(
|
||||
current.map(async (f) => {
|
||||
try {
|
||||
const result = await apolloClient.query({
|
||||
query: PROFILE_QUERY,
|
||||
variables: { profileId: f.targetProfileId },
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
const profile = result.data?.profile;
|
||||
return profile
|
||||
? { ...f, profile }
|
||||
: { ...f, profileError: 'Profile not found' };
|
||||
} catch (e: any) {
|
||||
return { ...f, profileError: e?.message || 'Fetch failed' };
|
||||
}
|
||||
}),
|
||||
);
|
||||
setFavorites(enriched);
|
||||
} finally {
|
||||
setProfilesLoading(false);
|
||||
}
|
||||
// We intentionally read the current `favorites` snapshot — re-running on
|
||||
// every change would cause an infinite loop after we setFavorites below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [favorites.length]);
|
||||
|
||||
// Re-fetch profile data whenever the list changes
|
||||
useEffect(() => {
|
||||
if (!loading && favorites.length > 0) {
|
||||
enrichProfiles();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, favorites.length]);
|
||||
|
||||
const addFavorite = useCallback(async (profile: any) => {
|
||||
if (!profile?.id) return;
|
||||
const snapshot = snapshotForStorage(profile);
|
||||
await dataSync.addFavorite(profile.id, snapshot);
|
||||
setFavorites((prev) =>
|
||||
prev.some((f) => f.targetProfileId === profile.id)
|
||||
? prev
|
||||
: [{ targetProfileId: profile.id, savedAt: Date.now(), profile: snapshot }, ...prev],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const removeFavorite = useCallback(async (targetProfileId: string) => {
|
||||
await dataSync.removeFavorite(targetProfileId);
|
||||
setFavorites((prev) => prev.filter((f) => f.targetProfileId !== targetProfileId));
|
||||
}, []);
|
||||
|
||||
const isFavorite = useCallback(
|
||||
(targetProfileId: string) => favorites.some((f) => f.targetProfileId === targetProfileId),
|
||||
[favorites],
|
||||
);
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
async (profile: any) => {
|
||||
if (!profile?.id) return;
|
||||
if (isFavorite(profile.id)) {
|
||||
await removeFavorite(profile.id);
|
||||
} else {
|
||||
await addFavorite(profile);
|
||||
}
|
||||
},
|
||||
[isFavorite, addFavorite, removeFavorite],
|
||||
);
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
await dataSync.clearAllFavorites();
|
||||
setFavorites([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
favorites,
|
||||
loading,
|
||||
profilesLoading,
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
isFavorite,
|
||||
toggleFavorite,
|
||||
clearAll,
|
||||
refreshFromServer,
|
||||
refreshProfiles: enrichProfiles,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import * as dataSync from '../api/dataSync';
|
||||
import { isCountryBanned, findBannedCountry } from '../config/bannedCountries';
|
||||
|
||||
// UUID generator that works in non-secure contexts (HTTP)
|
||||
function generateUUID(): string {
|
||||
@@ -18,19 +19,39 @@ export interface SavedLocation {
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
export class BannedCountryError extends Error {
|
||||
bannedCountry: string;
|
||||
constructor(country: string) {
|
||||
super(`Feeld is not available in ${country}. Pick a different location.`);
|
||||
this.name = 'BannedCountryError';
|
||||
this.bannedCountry = country;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocationState {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
name?: string;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
interface LocationContextType {
|
||||
location: LocationState | null;
|
||||
savedLocations: SavedLocation[];
|
||||
// Throws BannedCountryError if the LocationState has a banned country/countryCode.
|
||||
setLocation: (location: LocationState | null) => void;
|
||||
saveLocation: (name: string, lat: number, lng: number) => void;
|
||||
// Throws BannedCountryError if the country is on the Feeld-restricted list.
|
||||
saveLocation: (
|
||||
name: string,
|
||||
lat: number,
|
||||
lng: number,
|
||||
meta?: { country?: string; countryCode?: string }
|
||||
) => void;
|
||||
deleteLocation: (id: string) => void;
|
||||
clearLocation: () => void;
|
||||
}
|
||||
@@ -40,15 +61,25 @@ const LocationContext = createContext<LocationContextType | null>(null);
|
||||
const STORAGE_KEY = 'feeld_locations';
|
||||
const CURRENT_LOCATION_KEY = 'feeld_current_location';
|
||||
|
||||
// Strip any banned-country entry coming off the wire / off disk — legacy data
|
||||
// from before the country filter existed should not be re-applied.
|
||||
const sanitizeCurrent = (loc: LocationState | null | undefined): LocationState | null => {
|
||||
if (!loc) return null;
|
||||
if (isCountryBanned(loc.country, loc.countryCode)) return null;
|
||||
return loc;
|
||||
};
|
||||
const sanitizeSaved = (arr: SavedLocation[]): SavedLocation[] =>
|
||||
arr.filter((l) => !isCountryBanned(l.country, l.countryCode));
|
||||
|
||||
export function LocationProvider({ children }: { children: ReactNode }) {
|
||||
const [location, setLocationState] = useState<LocationState | null>(() => {
|
||||
const saved = localStorage.getItem(CURRENT_LOCATION_KEY);
|
||||
return saved ? JSON.parse(saved) : null;
|
||||
return sanitizeCurrent(saved ? JSON.parse(saved) : null);
|
||||
});
|
||||
|
||||
const [savedLocations, setSavedLocations] = useState<SavedLocation[]>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
return sanitizeSaved(saved ? JSON.parse(saved) : []);
|
||||
});
|
||||
|
||||
const [hasSyncedFromServer, setHasSyncedFromServer] = useState(false);
|
||||
@@ -59,10 +90,11 @@ export function LocationProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const serverData = await dataSync.getAllFromServer();
|
||||
if (serverData) {
|
||||
// Merge saved locations from server
|
||||
// Merge saved locations from server, dropping any banned-country legacy entries
|
||||
if (serverData.savedLocations && serverData.savedLocations.length > 0) {
|
||||
setSavedLocations(prev => {
|
||||
const merged = [...serverData.savedLocations];
|
||||
const serverClean = sanitizeSaved(serverData.savedLocations);
|
||||
const merged = [...serverClean];
|
||||
for (const local of prev) {
|
||||
if (!merged.some((s: SavedLocation) => s.id === local.id)) {
|
||||
merged.push(local);
|
||||
@@ -75,15 +107,17 @@ export function LocationProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
// Restore current location if we don't have one
|
||||
if (serverData.currentLocation && !location) {
|
||||
setLocationState(serverData.currentLocation);
|
||||
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(serverData.currentLocation));
|
||||
const cleanCurrent = sanitizeCurrent(serverData.currentLocation);
|
||||
if (cleanCurrent && !location) {
|
||||
setLocationState(cleanCurrent);
|
||||
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(cleanCurrent));
|
||||
}
|
||||
|
||||
// Restore custom location if we don't have one
|
||||
if (serverData.customLocation && !location) {
|
||||
setLocationState(serverData.customLocation);
|
||||
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(serverData.customLocation));
|
||||
const cleanCustom = sanitizeCurrent(serverData.customLocation);
|
||||
if (cleanCustom && !location) {
|
||||
setLocationState(cleanCustom);
|
||||
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(cleanCustom));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -120,6 +154,10 @@ export function LocationProvider({ children }: { children: ReactNode }) {
|
||||
}, [savedLocations, hasSyncedFromServer]);
|
||||
|
||||
const setLocation = (loc: LocationState | null) => {
|
||||
if (loc && isCountryBanned(loc.country, loc.countryCode)) {
|
||||
const hit = findBannedCountry(loc.country, loc.countryCode);
|
||||
throw new BannedCountryError(hit?.name ?? loc.country ?? loc.countryCode ?? 'this country');
|
||||
}
|
||||
setLocationState(loc);
|
||||
// Sync to server
|
||||
if (loc) {
|
||||
@@ -127,12 +165,23 @@ export function LocationProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
const saveLocation = (name: string, latitude: number, longitude: number) => {
|
||||
const saveLocation = (
|
||||
name: string,
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
meta?: { country?: string; countryCode?: string }
|
||||
) => {
|
||||
if (isCountryBanned(meta?.country, meta?.countryCode)) {
|
||||
const hit = findBannedCountry(meta?.country, meta?.countryCode);
|
||||
throw new BannedCountryError(hit?.name ?? meta?.country ?? meta?.countryCode ?? 'this country');
|
||||
}
|
||||
const newLocation: SavedLocation = {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
latitude,
|
||||
longitude,
|
||||
country: meta?.country,
|
||||
countryCode: meta?.countryCode,
|
||||
};
|
||||
setSavedLocations((prev) => [...prev, newLocation]);
|
||||
};
|
||||
@@ -169,11 +218,19 @@ export function useLocation() {
|
||||
return context;
|
||||
}
|
||||
|
||||
export interface GeocodeResult {
|
||||
lat: number;
|
||||
lng: number;
|
||||
displayName: string;
|
||||
country?: string;
|
||||
countryCode?: string; // ISO 3166-1 alpha-2 (uppercase)
|
||||
}
|
||||
|
||||
// Geocoding helper using OpenStreetMap Nominatim (free, no API key needed)
|
||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lng: number; displayName: string } | null> {
|
||||
export async function geocodeAddress(address: string): Promise<GeocodeResult | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1`,
|
||||
`https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&q=${encodeURIComponent(address)}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'FeeldWebApp/1.0',
|
||||
@@ -184,10 +241,14 @@ export async function geocodeAddress(address: string): Promise<{ lat: number; ln
|
||||
const data = await response.json();
|
||||
|
||||
if (data.length > 0) {
|
||||
const hit = data[0];
|
||||
const addr = hit.address || {};
|
||||
return {
|
||||
lat: parseFloat(data[0].lat),
|
||||
lng: parseFloat(data[0].lon),
|
||||
displayName: data[0].display_name,
|
||||
lat: parseFloat(hit.lat),
|
||||
lng: parseFloat(hit.lon),
|
||||
displayName: hit.display_name,
|
||||
country: addr.country,
|
||||
countryCode: addr.country_code ? String(addr.country_code).toUpperCase() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,10 @@ export function useSentPings() {
|
||||
const result = await apolloClient.query({
|
||||
query: PROFILE_QUERY,
|
||||
variables: { profileId: ping.targetProfileId },
|
||||
fetchPolicy: 'network-first',
|
||||
// Apollo doesn't have 'network-first'; using network-only ensures we
|
||||
// always pull the latest interactionStatus.theirs from the server so
|
||||
// declined/liked-back states aren't masked by stale cache.
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
const profile = result.data?.profile;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { apolloClient } from '../api/client';
|
||||
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';
|
||||
import { DISCOVER_PROFILES_QUERY, PROFILE_QUERY } from '../api/operations/queries';
|
||||
import { FILTERED_WHO_LIKES_ME_MUTATION } from '../api/operations/mutations';
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
@@ -208,14 +209,16 @@ export function ApiExplorerPage() {
|
||||
const testDirectProfileLookup = async () => {
|
||||
setLoading('directLookup');
|
||||
try {
|
||||
// First, get WhoLikesMe to see the anonymized profiles
|
||||
const likesResult = await apolloClient.query({
|
||||
query: WHO_LIKES_ME_QUERY,
|
||||
variables: { sortBy: 'LAST_INTERACTION' },
|
||||
// First, get FilteredWhoLikesMe to see the anonymized profiles
|
||||
const likesResult: any = await apolloClient.mutate({
|
||||
mutation: FILTERED_WHO_LIKES_ME_MUTATION,
|
||||
variables: {
|
||||
input: { filters: { ageRange: false }, sortBy: 'LAST_INTERACTION' },
|
||||
},
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
|
||||
const anonymizedProfiles = likesResult.data?.interactions?.nodes || [];
|
||||
const anonymizedProfiles = likesResult.data?.filteredWhoLikesMe?.profiles?.nodes || [];
|
||||
|
||||
if (anonymizedProfiles.length === 0) {
|
||||
setResults(prev => [...prev, {
|
||||
@@ -314,13 +317,15 @@ export function ApiExplorerPage() {
|
||||
const crossReferenceProfiles = async () => {
|
||||
setLoading('crossReference');
|
||||
try {
|
||||
// Fetch WhoLikesMe first
|
||||
const likesResult = await apolloClient.query({
|
||||
query: WHO_LIKES_ME_QUERY,
|
||||
variables: { sortBy: 'LAST_INTERACTION' },
|
||||
// Fetch FilteredWhoLikesMe first
|
||||
const likesResult: any = await apolloClient.mutate({
|
||||
mutation: FILTERED_WHO_LIKES_ME_MUTATION,
|
||||
variables: {
|
||||
input: { filters: { ageRange: false }, sortBy: 'LAST_INTERACTION' },
|
||||
},
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
const whoLikesMeProfiles = likesResult.data?.interactions?.nodes || [];
|
||||
const whoLikesMeProfiles = likesResult.data?.filteredWhoLikesMe?.profiles?.nodes || [];
|
||||
|
||||
// Fetch discover profiles multiple times (4 batches)
|
||||
const allDiscoverProfiles: any[] = [];
|
||||
@@ -445,13 +450,15 @@ export function ApiExplorerPage() {
|
||||
const maxBatches = 50; // Safety limit
|
||||
|
||||
try {
|
||||
// First get WhoLikesMe names for comparison
|
||||
const likesResult = await apolloClient.query({
|
||||
query: WHO_LIKES_ME_QUERY,
|
||||
variables: { sortBy: 'LAST_INTERACTION' },
|
||||
// First get FilteredWhoLikesMe names for comparison
|
||||
const likesResult: any = await apolloClient.mutate({
|
||||
mutation: FILTERED_WHO_LIKES_ME_MUTATION,
|
||||
variables: {
|
||||
input: { filters: { ageRange: false }, sortBy: 'LAST_INTERACTION' },
|
||||
},
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
const whoLikesMeProfiles = likesResult.data?.interactions?.nodes || [];
|
||||
const whoLikesMeProfiles = likesResult.data?.filteredWhoLikesMe?.profiles?.nodes || [];
|
||||
const whoLikesMeNames = whoLikesMeProfiles.map((p: any) => p.imaginaryName);
|
||||
|
||||
// Keep fetching until we find someone who liked us
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { useFavorites } from '../hooks/useFavorites';
|
||||
import { apolloClient } from '../api/client';
|
||||
import { authManager } from '../api/auth';
|
||||
|
||||
@@ -291,6 +292,7 @@ export function DiscoverPage() {
|
||||
const initialLoadDone = useRef(false);
|
||||
const savedLikedMeProfiles = useRef<Set<string>>(new Set());
|
||||
const { location } = useLocation();
|
||||
const { isFavorite, toggleFavorite } = useFavorites();
|
||||
|
||||
// Set device location on Feeld when Discover page loads (emulates app open GPS)
|
||||
const [updateDeviceLocation] = useMutation(DEVICE_LOCATION_UPDATE_MUTATION);
|
||||
@@ -683,6 +685,8 @@ export function DiscoverPage() {
|
||||
profile={profile}
|
||||
onClick={() => setSelectedProfileId(profile.id)}
|
||||
index={index}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
isFavorited={isFavorite(profile.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+238
-63
@@ -1,15 +1,25 @@
|
||||
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, UNDO_PROFILE_DISLIKE_MUTATION, PROFILE_ACCEPT_PING_MUTATION, PROFILE_REJECT_PING_MUTATION } from '../api/operations/mutations';
|
||||
import { PROFILE_QUERY } from '../api/operations/queries';
|
||||
import {
|
||||
DEVICE_LOCATION_UPDATE_MUTATION,
|
||||
UNDO_PROFILE_DISLIKE_MUTATION,
|
||||
PROFILE_ACCEPT_PING_MUTATION,
|
||||
PROFILE_REJECT_PING_MUTATION,
|
||||
FILTERED_WHO_LIKES_ME_MUTATION,
|
||||
FILTERED_WHO_PINGS_ME_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 { useDislikedProfiles } from '../hooks/useDislikedProfiles';
|
||||
import { useFavorites } from '../hooks/useFavorites';
|
||||
import { apolloClient } from '../api/client';
|
||||
import { useLocation } from '../hooks/useLocation';
|
||||
import { isCountryBanned, findBannedCountry } from '../config/bannedCountries';
|
||||
import * as dataSync from '../api/dataSync';
|
||||
|
||||
// Same query as Discover's load more - includes alreadyShownProfileIDs support
|
||||
const SCAN_PROFILES_QUERY = gql`
|
||||
@@ -75,12 +85,13 @@ const styles = {
|
||||
gap: '12px',
|
||||
marginBottom: '32px',
|
||||
},
|
||||
tab: (isActive: boolean, variant: 'likes' | 'pings' | 'youLiked' | 'disliked') => {
|
||||
tab: (isActive: boolean, variant: 'likes' | 'pings' | 'youLiked' | 'disliked' | 'saved') => {
|
||||
const gradients: Record<string, string> = {
|
||||
likes: 'linear-gradient(135deg, #be3144 0%, #c41e3a 100%)',
|
||||
pings: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
youLiked: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
||||
disliked: 'linear-gradient(135deg, #64748b 0%, #475569 100%)',
|
||||
saved: 'linear-gradient(135deg, #a855f7 0%, #7c3aed 100%)',
|
||||
};
|
||||
return {
|
||||
display: 'flex',
|
||||
@@ -99,12 +110,13 @@ const styles = {
|
||||
boxShadow: isActive ? '0 4px 20px rgba(0,0,0,0.3)' : 'none',
|
||||
};
|
||||
},
|
||||
tabBadge: (isActive: boolean, variant: 'likes' | 'pings' | 'youLiked' | 'disliked') => {
|
||||
tabBadge: (isActive: boolean, variant: 'likes' | 'pings' | 'youLiked' | 'disliked' | 'saved') => {
|
||||
const colors: Record<string, { bg: string; color: string }> = {
|
||||
likes: { bg: 'rgba(190,49,68,0.25)', color: '#f4a5b0' },
|
||||
pings: { bg: 'rgba(245,158,11,0.25)', color: '#fcd34d' },
|
||||
youLiked: { bg: 'rgba(34,197,94,0.25)', color: '#86efac' },
|
||||
disliked: { bg: 'rgba(100,116,139,0.25)', color: '#94a3b8' },
|
||||
saved: { bg: 'rgba(168,85,247,0.25)', color: '#d8b4fe' },
|
||||
};
|
||||
return {
|
||||
padding: '4px 10px',
|
||||
@@ -132,12 +144,13 @@ const styles = {
|
||||
justifyContent: 'center',
|
||||
padding: '80px 20px',
|
||||
},
|
||||
emptyIcon: (variant: 'likes' | 'pings' | 'youLiked' | 'disliked') => {
|
||||
emptyIcon: (variant: 'likes' | 'pings' | 'youLiked' | 'disliked' | 'saved') => {
|
||||
const backgrounds: Record<string, string> = {
|
||||
likes: 'linear-gradient(135deg, rgba(190,49,68,0.15) 0%, rgba(190,49,68,0.05) 100%)',
|
||||
pings: 'linear-gradient(135deg, rgba(245,158,11,0.15) 0%, rgba(245,158,11,0.05) 100%)',
|
||||
youLiked: 'linear-gradient(135deg, rgba(34,197,94,0.15) 0%, rgba(34,197,94,0.05) 100%)',
|
||||
disliked: 'linear-gradient(135deg, rgba(100,116,139,0.15) 0%, rgba(100,116,139,0.05) 100%)',
|
||||
saved: 'linear-gradient(135deg, rgba(168,85,247,0.15) 0%, rgba(168,85,247,0.05) 100%)',
|
||||
};
|
||||
return {
|
||||
width: '80px',
|
||||
@@ -318,7 +331,7 @@ async function scanLocationBatches(opts: {
|
||||
return { profiles: allProfiles, matches: matchCount };
|
||||
}
|
||||
|
||||
type Tab = 'likes' | 'pings' | 'youLiked' | 'disliked';
|
||||
type Tab = 'likes' | 'pings' | 'youLiked' | 'disliked' | 'saved';
|
||||
|
||||
interface WhoLikedYouProfile {
|
||||
id: string;
|
||||
@@ -340,6 +353,21 @@ export function LikesPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('likes');
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
|
||||
// Saved-for-later (favorites) tab data
|
||||
const { favorites, removeFavorite } = useFavorites();
|
||||
|
||||
// Flatten favorites to profile objects for the grid (preferring enriched
|
||||
// profile data when available, falling back to the saved snapshot).
|
||||
// Declared near the hook so it stays above any early-return paths below
|
||||
// — Rules of Hooks: same order every render.
|
||||
const savedProfiles = useMemo(
|
||||
() =>
|
||||
favorites
|
||||
.map((f) => f.profile)
|
||||
.filter((p): p is NonNullable<typeof p> => !!p),
|
||||
[favorites],
|
||||
);
|
||||
|
||||
// Scanner state
|
||||
const [selectedLocationId, setSelectedLocationId] = useState<string>('');
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
@@ -420,7 +448,8 @@ export function LikesPage() {
|
||||
fetchDiscoveredProfiles();
|
||||
}, []);
|
||||
|
||||
// Fetch all pages of likes
|
||||
// Fetch all pages of likes via FilteredWhoLikesMe (mutation, not a query —
|
||||
// this is what the live app uses; the old WhoLikesMe query path is gone).
|
||||
const fetchAllLikes = useCallback(async () => {
|
||||
setLikesLoading(true);
|
||||
const allNodes: any[] = [];
|
||||
@@ -430,23 +459,25 @@ export function LikesPage() {
|
||||
|
||||
try {
|
||||
while (hasMore) {
|
||||
const result = await apolloClient.query({
|
||||
query: WHO_LIKES_ME_QUERY,
|
||||
variables: { sortBy: 'LAST_INTERACTION', cursor },
|
||||
const result: any = await apolloClient.mutate({
|
||||
mutation: FILTERED_WHO_LIKES_ME_MUTATION,
|
||||
variables: {
|
||||
input: { filters: { ageRange: false }, sortBy: 'LAST_INTERACTION' },
|
||||
cursor,
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
const nodes = result.data?.interactions?.nodes || [];
|
||||
const pageInfo = result.data?.interactions?.pageInfo;
|
||||
|
||||
console.log('WhoLikesMe pageInfo:', JSON.stringify(pageInfo));
|
||||
const profiles = result.data?.filteredWhoLikesMe?.profiles;
|
||||
const nodes = profiles?.nodes || [];
|
||||
const pageInfo = profiles?.pageInfo;
|
||||
|
||||
allNodes.push(...nodes);
|
||||
total = pageInfo?.total || allNodes.length;
|
||||
hasMore = pageInfo?.hasNextPage || false;
|
||||
cursor = pageInfo?.nextPageCursor || null;
|
||||
|
||||
console.log(`Fetched ${nodes.length} likes, total so far: ${allNodes.length}/${total}, hasMore: ${hasMore}, cursor: ${cursor}`);
|
||||
console.log(`Fetched ${nodes.length} likes, total so far: ${allNodes.length}/${total}, hasMore: ${hasMore}`);
|
||||
}
|
||||
|
||||
setAllLikes(allNodes);
|
||||
@@ -458,7 +489,7 @@ export function LikesPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch all pages of pings
|
||||
// Fetch all pages of pings via FilteredWhoPingsMe (mutation).
|
||||
const fetchAllPings = useCallback(async () => {
|
||||
setPingsLoading(true);
|
||||
const allNodes: any[] = [];
|
||||
@@ -468,14 +499,18 @@ export function LikesPage() {
|
||||
|
||||
try {
|
||||
while (hasMore) {
|
||||
const result = await apolloClient.query({
|
||||
query: WHO_PINGS_ME_QUERY,
|
||||
variables: { sortBy: 'LAST_INTERACTION', cursor },
|
||||
const result: any = await apolloClient.mutate({
|
||||
mutation: FILTERED_WHO_PINGS_ME_MUTATION,
|
||||
variables: {
|
||||
input: { filters: { ageRange: false }, sortBy: 'LAST_INTERACTION' },
|
||||
cursor,
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
const nodes = result.data?.interactions?.nodes || [];
|
||||
const pageInfo = result.data?.interactions?.pageInfo;
|
||||
const profiles = result.data?.filteredWhoPingsMe?.profiles;
|
||||
const nodes = profiles?.nodes || [];
|
||||
const pageInfo = profiles?.pageInfo;
|
||||
|
||||
allNodes.push(...nodes);
|
||||
total = pageInfo?.total || allNodes.length;
|
||||
@@ -573,6 +608,12 @@ export function LikesPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCountryBanned(location.country, location.countryCode)) {
|
||||
const hit = findBannedCountry(location.country, location.countryCode);
|
||||
setScanStatus(`Cannot scan: Feeld is not available in ${hit?.name ?? location.country ?? 'this country'}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsScanning(true);
|
||||
setScanStatus(`Setting location to ${location.name}...`);
|
||||
scanAbortRef.current = new AbortController();
|
||||
@@ -581,7 +622,13 @@ export function LikesPage() {
|
||||
await updateLocation({
|
||||
variables: { input: { latitude: location.latitude, longitude: location.longitude } },
|
||||
});
|
||||
setLocation({ latitude: location.latitude, longitude: location.longitude, name: location.name });
|
||||
setLocation({
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
name: location.name,
|
||||
country: location.country,
|
||||
countryCode: location.countryCode,
|
||||
});
|
||||
|
||||
const alreadyShownProfileIDs: string[] = [];
|
||||
const { profiles, matches } = await scanLocationBatches({
|
||||
@@ -612,8 +659,14 @@ export function LikesPage() {
|
||||
|
||||
// "Fuck It" - scan ALL locations (deduplicated, cross-location dedup)
|
||||
const scanAllLocations = useCallback(async () => {
|
||||
if (savedLocations.length === 0) {
|
||||
setScanStatus('No saved locations to scan');
|
||||
// Strip banned-country entries before iterating — defense in depth on top of the
|
||||
// useLocation save gate and server-side filter.
|
||||
const scannable = savedLocations.filter(l => !isCountryBanned(l.country, l.countryCode));
|
||||
const skipped = savedLocations.length - scannable.length;
|
||||
if (scannable.length === 0) {
|
||||
setScanStatus(savedLocations.length === 0
|
||||
? 'No saved locations to scan'
|
||||
: 'All saved locations are in countries where Feeld is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -621,20 +674,22 @@ export function LikesPage() {
|
||||
scanAbortRef.current = new AbortController();
|
||||
const signal = scanAbortRef.current.signal;
|
||||
const scanRadius = 400; // miles
|
||||
const totalLocations = savedLocations.length;
|
||||
const totalLocations = scannable.length;
|
||||
let grandTotalProfiles = 0;
|
||||
let grandTotalMatches = 0;
|
||||
|
||||
setFuckItProgress({ current: 0, total: totalLocations, currentLocation: '', totalProfiles: 0, totalMatches: 0 });
|
||||
setScanStatus(`Scanning ${totalLocations} locations...`);
|
||||
setScanStatus(skipped > 0
|
||||
? `Scanning ${totalLocations} locations (skipped ${skipped} in restricted countries)...`
|
||||
: `Scanning ${totalLocations} locations...`);
|
||||
|
||||
// Single shared list across ALL locations — avoids re-fetching already-seen profiles
|
||||
const alreadyShownProfileIDs: string[] = [];
|
||||
|
||||
try {
|
||||
for (let locIndex = 0; locIndex < savedLocations.length; locIndex++) {
|
||||
for (let locIndex = 0; locIndex < scannable.length; locIndex++) {
|
||||
if (signal.aborted) break;
|
||||
const location = savedLocations[locIndex];
|
||||
const location = scannable[locIndex];
|
||||
setFuckItProgress(prev => ({
|
||||
...prev,
|
||||
current: locIndex + 1,
|
||||
@@ -644,7 +699,13 @@ export function LikesPage() {
|
||||
await updateLocation({
|
||||
variables: { input: { latitude: location.latitude, longitude: location.longitude } },
|
||||
});
|
||||
setLocation({ latitude: location.latitude, longitude: location.longitude, name: location.name });
|
||||
setLocation({
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
name: location.name,
|
||||
country: location.country,
|
||||
countryCode: location.countryCode,
|
||||
});
|
||||
|
||||
try {
|
||||
const { profiles, matches } = await scanLocationBatches({
|
||||
@@ -673,7 +734,7 @@ export function LikesPage() {
|
||||
}
|
||||
|
||||
// Small delay between locations
|
||||
if (locIndex < savedLocations.length - 1) {
|
||||
if (locIndex < scannable.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
@@ -1076,34 +1137,58 @@ export function LikesPage() {
|
||||
}
|
||||
}, [dislikeProfile, fetchAllLikes]);
|
||||
|
||||
// Fetch past likes from API (You Liked tab)
|
||||
const fetchPastLikes = useCallback(async (cursor?: string | null) => {
|
||||
// Fetch past likes (You Liked tab).
|
||||
// Feeld's pastLikes API returns synthetic (v5) profile IDs that don't resolve
|
||||
// via ProfileQuery — deliberate anonymization for non-Majestic viewers.
|
||||
// We sidestep this by using the REAL profile IDs we stored locally at like-time
|
||||
// (`liked_profiles` in localStorage / backend) and fetching each via ProfileQuery
|
||||
// directly. This returns full profile data + real photo URLs.
|
||||
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',
|
||||
});
|
||||
const localLikes = await dataSync.getLikedProfiles();
|
||||
|
||||
const nodes = result.data?.pastLikes?.nodes || [];
|
||||
const pageInfo = result.data?.pastLikes?.pageInfo;
|
||||
const enriched = await Promise.all(
|
||||
localLikes.map(async (l) => {
|
||||
try {
|
||||
const r = await apolloClient.query({
|
||||
query: PROFILE_QUERY,
|
||||
variables: { profileId: l.id },
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
const profile = r.data?.profile;
|
||||
if (profile) {
|
||||
return {
|
||||
profile,
|
||||
isPing: false,
|
||||
interactionSentAt: new Date(l.likedAt).toISOString(),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`ProfileQuery failed for ${l.id} (${l.name}):`, e);
|
||||
}
|
||||
return {
|
||||
profile: { id: l.id, imaginaryName: l.name, photos: [] },
|
||||
isPing: false,
|
||||
interactionSentAt: new Date(l.likedAt).toISOString(),
|
||||
_profileFetchFailed: true,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
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}`);
|
||||
enriched.sort(
|
||||
(a, b) =>
|
||||
new Date(b.interactionSentAt).getTime() -
|
||||
new Date(a.interactionSentAt).getTime()
|
||||
);
|
||||
|
||||
setPastLikes(enriched);
|
||||
setPastLikesTotal(enriched.length);
|
||||
setPastLikesCursor(null);
|
||||
setPastLikesHasMore(false);
|
||||
console.log(`Loaded ${enriched.length} past likes from local IDs`);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch past likes:', e);
|
||||
console.error('Failed to load past likes from local:', e);
|
||||
} finally {
|
||||
setPastLikesLoading(false);
|
||||
}
|
||||
@@ -1221,7 +1306,15 @@ export function LikesPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch past likes/dislikes when tab is selected
|
||||
// Prefetch past likes + dislikes on mount so tab badges show accurate counts
|
||||
// before the user clicks into either tab.
|
||||
useEffect(() => {
|
||||
fetchPastLikes();
|
||||
fetchPastDislikes();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Refetch when user switches to the tab (keeps data fresh on revisit)
|
||||
useEffect(() => {
|
||||
if (activeTab === 'youLiked') {
|
||||
fetchPastLikes();
|
||||
@@ -1230,9 +1323,19 @@ export function LikesPage() {
|
||||
}
|
||||
}, [activeTab, fetchPastLikes, fetchPastDislikes]);
|
||||
|
||||
// Scanner-cache index by profile ID — used to enrich pastLikes with real photos
|
||||
// when Feeld gates them. Must be declared before any early return.
|
||||
const discoveredById = useMemo(() => {
|
||||
const map = new Map<string, any>();
|
||||
for (const p of discoveredProfiles) {
|
||||
if (p?.id) map.set(p.id, p);
|
||||
}
|
||||
return map;
|
||||
}, [discoveredProfiles]);
|
||||
|
||||
const loading = likesLoading || pingsLoading;
|
||||
|
||||
if (loading && activeTab !== 'youLiked' && activeTab !== 'disliked') return <LoadingPage message="Loading interactions..." />;
|
||||
if (loading && activeTab !== 'youLiked' && activeTab !== 'disliked' && activeTab !== 'saved') return <LoadingPage message="Loading interactions..." />;
|
||||
|
||||
const likes = enrichedLikes;
|
||||
const pings = allPings;
|
||||
@@ -1240,12 +1343,20 @@ export function LikesPage() {
|
||||
// Count how many likes have been matched with real data
|
||||
const matchedCount = enrichedLikes.filter((p: any) => p._matched).length;
|
||||
|
||||
// Map pastLikes nodes to profiles for the grid
|
||||
const pastLikesProfiles = pastLikes.map((node: any) => ({
|
||||
...node.profile,
|
||||
_isPing: node.isPing,
|
||||
_interactionSentAt: node.interactionSentAt,
|
||||
}));
|
||||
// Each node.profile already has fresh data from ProfileQuery using the real local ID.
|
||||
// Scanner cache is used only as a last-resort fallback if the fetch failed.
|
||||
const pastLikesProfiles = pastLikes.map((node: any) => {
|
||||
const fresh = node.profile || {};
|
||||
const cached = node._profileFetchFailed ? discoveredById.get(fresh.id) : null;
|
||||
return {
|
||||
...(cached || {}),
|
||||
...fresh,
|
||||
photos: fresh.photos?.length ? fresh.photos : (cached?.photos || []),
|
||||
_isPing: node.isPing,
|
||||
_interactionSentAt: node.interactionSentAt,
|
||||
_profileFetchFailed: node._profileFetchFailed,
|
||||
};
|
||||
});
|
||||
|
||||
// Map pastDislikes nodes to profiles for the grid
|
||||
const pastDislikesProfiles = pastDislikes.map((node: any) => ({
|
||||
@@ -1253,7 +1364,12 @@ export function LikesPage() {
|
||||
_interactionSentAt: node.interactionSentAt,
|
||||
}));
|
||||
|
||||
const currentProfiles = activeTab === 'likes' ? likes : activeTab === 'pings' ? pings : activeTab === 'disliked' ? pastDislikesProfiles : pastLikesProfiles;
|
||||
const currentProfiles =
|
||||
activeTab === 'likes' ? likes :
|
||||
activeTab === 'pings' ? pings :
|
||||
activeTab === 'disliked' ? pastDislikesProfiles :
|
||||
activeTab === 'saved' ? savedProfiles :
|
||||
pastLikesProfiles;
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
@@ -1354,6 +1470,19 @@ export function LikesPage() {
|
||||
{pastDislikesTotal || pastDislikesProfiles.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('saved')}
|
||||
style={styles.tab(activeTab === 'saved', 'saved')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: '18px', height: '18px' }}>
|
||||
<path d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z" />
|
||||
</svg>
|
||||
Saved
|
||||
<span style={styles.tabBadge(activeTab === 'saved', 'saved')}>
|
||||
{favorites.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Location Scanner */}
|
||||
@@ -1536,6 +1665,10 @@ export function LikesPage() {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#64748b" strokeWidth="1" style={{ width: '40px', height: '40px' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : activeTab === 'saved' ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#a855f7" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ width: '40px', height: '40px' }}>
|
||||
<path d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="1" style={{ width: '40px', height: '40px' }}>
|
||||
<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" />
|
||||
@@ -1543,7 +1676,10 @@ export function LikesPage() {
|
||||
)}
|
||||
</div>
|
||||
<h2 style={styles.emptyTitle}>
|
||||
{activeTab === 'youLiked' ? 'No likes sent yet' : activeTab === 'disliked' ? 'No passes yet' : `No ${activeTab} yet`}
|
||||
{activeTab === 'youLiked' ? 'No likes sent yet'
|
||||
: activeTab === 'disliked' ? 'No passes yet'
|
||||
: activeTab === 'saved' ? 'Nothing saved yet'
|
||||
: `No ${activeTab} yet`}
|
||||
</h2>
|
||||
<p style={styles.emptyText}>
|
||||
{activeTab === 'likes'
|
||||
@@ -1552,6 +1688,8 @@ export function LikesPage() {
|
||||
? 'Pings from interested members will show up here'
|
||||
: activeTab === 'disliked'
|
||||
? "Profiles you've passed on will appear here"
|
||||
: activeTab === 'saved'
|
||||
? 'Tap the star on a Discover card to save profiles for later'
|
||||
: "Profiles you've liked will appear here"
|
||||
}
|
||||
</p>
|
||||
@@ -1592,6 +1730,35 @@ export function LikesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reciprocation status for You Liked tab */}
|
||||
{activeTab === 'youLiked' && (() => {
|
||||
const theirs = profile.interactionStatus?.theirs;
|
||||
if (theirs !== 'LIKED' && theirs !== 'DISLIKED') return null;
|
||||
const isMatch = theirs === 'LIKED';
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '8px',
|
||||
left: '8px',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 9px',
|
||||
borderRadius: '6px',
|
||||
background: isMatch ? 'rgba(34,197,94,0.85)' : 'rgba(100,116,139,0.85)',
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
fontFamily: "'Satoshi', sans-serif",
|
||||
backdropFilter: 'blur(4px)',
|
||||
letterSpacing: '0.02em',
|
||||
}}>
|
||||
{isMatch ? 'Liked back' : 'Passed'}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Timestamp badge for You Liked and Passed tabs */}
|
||||
{(activeTab === 'youLiked' || activeTab === 'disliked') && profile._interactionSentAt && (
|
||||
<div style={{
|
||||
@@ -1774,6 +1941,14 @@ export function LikesPage() {
|
||||
<ProfileDetailModal
|
||||
profileId={selectedProfileId}
|
||||
onClose={() => setSelectedProfileId(null)}
|
||||
// When acting on a profile from the Saved tab, auto-remove it from
|
||||
// favorites. The action itself (Like/Ping/Dislike) is already
|
||||
// persisted to its own queue by the modal's mutation handlers.
|
||||
onActionTaken={
|
||||
activeTab === 'saved' && selectedProfileId
|
||||
? () => { removeFavorite(selectedProfileId); }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@apollo/client/react';
|
||||
import { PROFILE_QUERY } from '../api/operations/queries';
|
||||
import { PROFILE_QUERY, SEARCH_SETTINGS_QUERY } from '../api/operations/queries';
|
||||
import { PROFILE_UPDATE_MUTATION } from '../api/operations/mutations';
|
||||
import { authManager } from '../api/auth';
|
||||
import { LoadingPage } from '../components/ui/Loading';
|
||||
@@ -459,9 +459,20 @@ export function ProfilePage() {
|
||||
fetchPolicy: 'network-only', // Always fetch from network, don't use cache
|
||||
});
|
||||
|
||||
// lookingFor (and ageRange/distanceMax/recentlyOnline) live on the SearchSettings
|
||||
// query, not on Profile — the live app fetches them separately.
|
||||
const { data: searchSettingsData, refetch: refetchSearchSettings } = useQuery(
|
||||
SEARCH_SETTINGS_QUERY,
|
||||
{
|
||||
variables: { profileId: authManager.getProfileId() },
|
||||
fetchPolicy: 'network-only',
|
||||
}
|
||||
);
|
||||
|
||||
const [updateProfile] = useMutation(PROFILE_UPDATE_MUTATION, {
|
||||
refetchQueries: [
|
||||
{ query: PROFILE_QUERY, variables: { profileId: authManager.getProfileId() } }
|
||||
{ query: PROFILE_QUERY, variables: { profileId: authManager.getProfileId() } },
|
||||
{ query: SEARCH_SETTINGS_QUERY, variables: { profileId: authManager.getProfileId() } },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -476,11 +487,12 @@ export function ProfilePage() {
|
||||
|
||||
const handleStartEdit = () => {
|
||||
const profile = data?.profile;
|
||||
const settings = searchSettingsData?.profile;
|
||||
if (profile) {
|
||||
setEditForm({
|
||||
bio: profile.bio || '',
|
||||
desires: profile.desires || [],
|
||||
lookingFor: profile.lookingFor || [],
|
||||
lookingFor: settings?.lookingFor || [],
|
||||
connectionGoals: profile.connectionGoals || [],
|
||||
});
|
||||
setIsEditing(true);
|
||||
@@ -503,6 +515,7 @@ export function ProfilePage() {
|
||||
const input: Record<string, any> = {};
|
||||
|
||||
const profile = data?.profile;
|
||||
const settings = searchSettingsData?.profile;
|
||||
if (!profile) return;
|
||||
|
||||
// Only include changed fields
|
||||
@@ -512,7 +525,7 @@ export function ProfilePage() {
|
||||
if (JSON.stringify(editForm.desires) !== JSON.stringify(profile.desires || [])) {
|
||||
input.desires = editForm.desires;
|
||||
}
|
||||
if (JSON.stringify(editForm.lookingFor) !== JSON.stringify(profile.lookingFor || [])) {
|
||||
if (JSON.stringify(editForm.lookingFor) !== JSON.stringify(settings?.lookingFor || [])) {
|
||||
input.lookingFor = editForm.lookingFor;
|
||||
}
|
||||
if (JSON.stringify(editForm.connectionGoals) !== JSON.stringify(profile.connectionGoals || [])) {
|
||||
@@ -537,8 +550,9 @@ export function ProfilePage() {
|
||||
setToast({ message: 'Profile updated successfully!', type: 'success' });
|
||||
setIsEditing(false);
|
||||
// Force network fetch to bypass Apollo cache
|
||||
const refetchResult = await refetch();
|
||||
console.log('Refetch result:', refetchResult.data?.profile?.lookingFor);
|
||||
await refetch();
|
||||
const settingsResult = await refetchSearchSettings();
|
||||
console.log('Refetch search settings result:', settingsResult.data?.profile?.lookingFor);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to update profile:', err);
|
||||
const errorMessage = err?.graphQLErrors?.[0]?.message || err?.message || 'Failed to update profile';
|
||||
@@ -798,8 +812,8 @@ export function ProfilePage() {
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.tagContainer}>
|
||||
{(profile.lookingFor && profile.lookingFor.length > 0) ? (
|
||||
profile.lookingFor.map((type: string) => (
|
||||
{(searchSettingsData?.profile?.lookingFor && searchSettingsData.profile.lookingFor.length > 0) ? (
|
||||
searchSettingsData.profile.lookingFor.map((type: string) => (
|
||||
<span key={type} style={styles.tag('interest')}>
|
||||
{type.replace(/_/g, ' ')}
|
||||
</span>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useAuth } from '../hooks/useAuth';
|
||||
import { authManager } from '../api/auth';
|
||||
import type { AuthStatus } from '../api/auth';
|
||||
import { LoadingPage } from '../components/ui/Loading';
|
||||
import { useLocation, geocodeAddress } from '../hooks/useLocation';
|
||||
import { useLocation, geocodeAddress, BannedCountryError } from '../hooks/useLocation';
|
||||
import { isCountryBanned, findBannedCountry } from '../config/bannedCountries';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// Inline styles for guaranteed rendering
|
||||
@@ -600,10 +601,20 @@ export function SettingsPage() {
|
||||
const result = await geocodeAddress(searchQuery);
|
||||
|
||||
if (result) {
|
||||
if (isCountryBanned(result.country, result.countryCode)) {
|
||||
const hit = findBannedCountry(result.country, result.countryCode);
|
||||
setSearchError(
|
||||
`Feeld is not available in ${hit?.name ?? result.country ?? 'this country'}. Using a location there will get your account flagged. Pick a different location.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLocation = {
|
||||
latitude: result.lat,
|
||||
longitude: result.lng,
|
||||
name: result.displayName,
|
||||
country: result.country,
|
||||
countryCode: result.countryCode,
|
||||
};
|
||||
setLocation(newLocation);
|
||||
|
||||
@@ -630,10 +641,22 @@ export function SettingsPage() {
|
||||
};
|
||||
|
||||
const handleSelectSavedLocation = async (saved: typeof savedLocations[0]) => {
|
||||
if (isCountryBanned(saved.country, saved.countryCode)) {
|
||||
const hit = findBannedCountry(saved.country, saved.countryCode);
|
||||
setLocationStatus({
|
||||
type: 'error',
|
||||
text: `Feeld is not available in ${hit?.name ?? saved.country ?? 'that country'}. Remove this saved location and pick a different one.`,
|
||||
});
|
||||
setTimeout(() => setLocationStatus(null), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLocation = {
|
||||
latitude: saved.latitude,
|
||||
longitude: saved.longitude,
|
||||
name: saved.name,
|
||||
country: saved.country,
|
||||
countryCode: saved.countryCode,
|
||||
};
|
||||
setLocation(newLocation);
|
||||
setLocationStatus(null);
|
||||
@@ -659,9 +682,24 @@ export function SettingsPage() {
|
||||
|
||||
const handleSaveCurrentLocation = () => {
|
||||
if (!location || !saveLocationName.trim()) return;
|
||||
saveLocation(saveLocationName.trim(), location.latitude, location.longitude);
|
||||
setSaveLocationName('');
|
||||
setShowSaveDialog(false);
|
||||
try {
|
||||
saveLocation(saveLocationName.trim(), location.latitude, location.longitude, {
|
||||
country: location.country,
|
||||
countryCode: location.countryCode,
|
||||
});
|
||||
setSaveLocationName('');
|
||||
setShowSaveDialog(false);
|
||||
} catch (err) {
|
||||
if (err instanceof BannedCountryError) {
|
||||
setLocationStatus({
|
||||
type: 'error',
|
||||
text: `Can't save — Feeld is not available in ${err.bannedCountry}.`,
|
||||
});
|
||||
setTimeout(() => setLocationStatus(null), 5000);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Desiring For options (from API LocalisedDesireCategory)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Reverse-geocode coordinates to a country via OpenStreetMap Nominatim.
|
||||
// Results cached in localStorage keyed by rounded lat/lng (~1km precision)
|
||||
// so we don't hammer Nominatim's 1 req/sec rate limit.
|
||||
|
||||
const CACHE_KEY = 'feeld_country_cache_v1';
|
||||
const PRECISION = 2; // ~1km — country boundaries are coarse enough
|
||||
|
||||
export interface CountryLookup {
|
||||
country?: string;
|
||||
countryCode?: string; // ISO 3166-1 alpha-2, uppercase
|
||||
/** true if the lookup succeeded (cached or fresh); false if unresolved */
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
type CacheShape = Record<string, { country?: string; countryCode?: string; t: number }>;
|
||||
|
||||
function readCache(): CacheShape {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(c: CacheShape) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(c));
|
||||
} catch {
|
||||
// localStorage full or unavailable — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKey(lat: number, lng: number) {
|
||||
return `${lat.toFixed(PRECISION)},${lng.toFixed(PRECISION)}`;
|
||||
}
|
||||
|
||||
// In-flight dedupe so parallel calls for the same coord share one HTTP request
|
||||
const inflight = new Map<string, Promise<CountryLookup>>();
|
||||
|
||||
export async function reverseGeocode(lat: number, lng: number): Promise<CountryLookup> {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
||||
return { resolved: false };
|
||||
}
|
||||
const key = cacheKey(lat, lng);
|
||||
|
||||
const cache = readCache();
|
||||
if (cache[key]) {
|
||||
return { country: cache[key].country, countryCode: cache[key].countryCode, resolved: true };
|
||||
}
|
||||
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const p = (async (): Promise<CountryLookup> => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=3&addressdetails=1`,
|
||||
{ headers: { 'User-Agent': 'FeeldWebApp/1.0' } }
|
||||
);
|
||||
if (!res.ok) return { resolved: false };
|
||||
const data = await res.json();
|
||||
const addr = data?.address ?? {};
|
||||
const country: string | undefined = addr.country;
|
||||
const countryCode: string | undefined = addr.country_code
|
||||
? String(addr.country_code).toUpperCase()
|
||||
: undefined;
|
||||
if (!country && !countryCode) return { resolved: false };
|
||||
|
||||
const next = readCache();
|
||||
next[key] = { country, countryCode, t: Date.now() };
|
||||
writeCache(next);
|
||||
return { country, countryCode, resolved: true };
|
||||
} catch {
|
||||
return { resolved: false };
|
||||
} finally {
|
||||
inflight.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
inflight.set(key, p);
|
||||
return p;
|
||||
}
|
||||
@@ -106,6 +106,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/favorites': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/disliked-profiles': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user