da2bab21e5
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>
2054 lines
85 KiB
TypeScript
Executable File
2054 lines
85 KiB
TypeScript
Executable File
import { useQuery, useMutation } from '@apollo/client/react';
|
|
import { DISCOVER_SEARCH_SETTINGS_QUERY, IS_INCOGNITO_QUERY, POPULAR_LOCATIONS_QUERY, APP_SETTINGS_QUERY, REDEEMED_OFFERS_QUERY, HAS_LINKED_REFLECTION_QUERY } from '../api/operations/queries';
|
|
import { DEVICE_LOCATION_UPDATE_MUTATION, SEARCH_SETTINGS_UPDATE_MUTATION, PROFILE_LOCATION_UPDATE_MUTATION, APP_SETTINGS_UPDATE_MUTATION, SYNC_ACCOUNT_MUTATION, ACCOUNT_REDEEM_OFFER_MUTATION, ACCOUNT_DEACTIVATE_MUTATION, ACCOUNT_TERMINATE_MUTATION } from '../api/operations/mutations';
|
|
import { saveCustomLocation } from '../api/dataSync';
|
|
import { useAuth } from '../hooks/useAuth';
|
|
import { authManager } from '../api/auth';
|
|
import type { AuthStatus } from '../api/auth';
|
|
import { LoadingPage } from '../components/ui/Loading';
|
|
import { useLocation, geocodeAddress, BannedCountryError } from '../hooks/useLocation';
|
|
import { isCountryBanned, findBannedCountry } from '../config/bannedCountries';
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
|
|
// Inline styles for guaranteed rendering
|
|
const styles = {
|
|
container: {
|
|
maxWidth: '640px',
|
|
margin: '0 auto',
|
|
paddingBottom: '32px',
|
|
},
|
|
header: {
|
|
marginBottom: '32px',
|
|
},
|
|
title: {
|
|
fontFamily: "'Clash Display', sans-serif",
|
|
fontSize: '32px',
|
|
fontWeight: 700,
|
|
color: '#ffffff',
|
|
marginBottom: '8px',
|
|
},
|
|
subtitle: {
|
|
color: '#6b7280',
|
|
fontSize: '16px',
|
|
},
|
|
card: {
|
|
background: '#1a1a24',
|
|
borderRadius: '20px',
|
|
border: '1px solid rgba(255,255,255,0.08)',
|
|
marginBottom: '24px',
|
|
overflow: 'hidden',
|
|
},
|
|
cardContent: {
|
|
padding: '32px',
|
|
},
|
|
sectionHeader: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '16px',
|
|
marginBottom: '28px',
|
|
},
|
|
iconBox: (color: string) => ({
|
|
width: '48px',
|
|
height: '48px',
|
|
borderRadius: '14px',
|
|
background: `linear-gradient(135deg, ${color}33 0%, ${color}15 100%)`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}),
|
|
sectionTitle: {
|
|
fontFamily: "'Clash Display', sans-serif",
|
|
fontSize: '22px',
|
|
fontWeight: 600,
|
|
color: '#ffffff',
|
|
},
|
|
label: {
|
|
fontSize: '11px',
|
|
fontWeight: 600,
|
|
textTransform: 'uppercase' as const,
|
|
letterSpacing: '0.1em',
|
|
color: '#6b7280',
|
|
marginBottom: '12px',
|
|
display: 'block',
|
|
},
|
|
value: {
|
|
fontSize: '18px',
|
|
fontWeight: 500,
|
|
color: '#ffffff',
|
|
},
|
|
input: {
|
|
flex: 1,
|
|
padding: '14px 18px',
|
|
background: '#24242f',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
borderRadius: '12px',
|
|
color: '#ffffff',
|
|
fontSize: '15px',
|
|
outline: 'none',
|
|
transition: 'border-color 0.2s',
|
|
},
|
|
button: {
|
|
padding: '14px 24px',
|
|
background: 'linear-gradient(135deg, #c41e3a 0%, #e91e63 100%)',
|
|
border: 'none',
|
|
borderRadius: '12px',
|
|
color: '#ffffff',
|
|
fontSize: '15px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
transition: 'opacity 0.2s',
|
|
},
|
|
buttonSecondary: {
|
|
padding: '10px 18px',
|
|
background: '#24242f',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
borderRadius: '10px',
|
|
color: '#ffffff',
|
|
fontSize: '14px',
|
|
fontWeight: 500,
|
|
cursor: 'pointer',
|
|
},
|
|
infoBox: {
|
|
padding: '18px',
|
|
background: '#24242f',
|
|
borderRadius: '14px',
|
|
marginBottom: '20px',
|
|
},
|
|
badge: (variant: 'default' | 'primary' | 'success') => {
|
|
const colors = {
|
|
default: { bg: '#24242f', color: '#9ca3af' },
|
|
primary: { bg: 'rgba(196, 30, 58, 0.15)', color: '#e91e63' },
|
|
success: { bg: 'rgba(34, 197, 94, 0.15)', color: '#22c55e' },
|
|
};
|
|
const { bg, color } = colors[variant];
|
|
return {
|
|
display: 'inline-block',
|
|
padding: '8px 14px',
|
|
background: bg,
|
|
borderRadius: '8px',
|
|
color: color,
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
marginRight: '8px',
|
|
marginBottom: '8px',
|
|
};
|
|
},
|
|
toggleContainer: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: '20px',
|
|
background: '#24242f',
|
|
borderRadius: '14px',
|
|
},
|
|
toggle: (isOn: boolean) => ({
|
|
width: '56px',
|
|
height: '32px',
|
|
borderRadius: '16px',
|
|
background: isOn ? 'linear-gradient(135deg, #c41e3a 0%, #e91e63 100%)' : '#1a1a24',
|
|
position: 'relative' as const,
|
|
cursor: 'not-allowed',
|
|
transition: 'background 0.3s',
|
|
}),
|
|
toggleKnob: (isOn: boolean) => ({
|
|
position: 'absolute' as const,
|
|
top: '4px',
|
|
left: isOn ? '28px' : '4px',
|
|
width: '24px',
|
|
height: '24px',
|
|
borderRadius: '12px',
|
|
background: '#ffffff',
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
|
transition: 'left 0.3s',
|
|
}),
|
|
note: {
|
|
fontSize: '12px',
|
|
color: '#6b7280',
|
|
marginTop: '16px',
|
|
paddingTop: '16px',
|
|
borderTop: '1px solid rgba(255,255,255,0.05)',
|
|
},
|
|
savedLocation: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: '16px 18px',
|
|
background: '#24242f',
|
|
borderRadius: '12px',
|
|
marginBottom: '10px',
|
|
cursor: 'pointer',
|
|
transition: 'background 0.2s',
|
|
},
|
|
setLocationButton: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
padding: '10px 16px',
|
|
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
|
border: 'none',
|
|
borderRadius: '10px',
|
|
color: '#ffffff',
|
|
fontSize: '13px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
transition: 'opacity 0.2s, transform 0.2s',
|
|
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
|
|
},
|
|
activeLocationBadge: {
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
padding: '6px 12px',
|
|
background: 'rgba(34, 197, 94, 0.15)',
|
|
borderRadius: '20px',
|
|
color: '#22c55e',
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
},
|
|
};
|
|
|
|
export function SettingsPage() {
|
|
const { logout } = useAuth();
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searching, setSearching] = useState(false);
|
|
const [searchError, setSearchError] = useState<string | null>(null);
|
|
const [saveLocationName, setSaveLocationName] = useState('');
|
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
|
|
|
// Desiring For editing state
|
|
const [editingDesiringFor, setEditingDesiringFor] = useState(false);
|
|
const [tempDesiringFor, setTempDesiringFor] = useState<string[]>([]);
|
|
const [savingDesiringFor, setSavingDesiringFor] = useState(false);
|
|
|
|
// Auth credentials state — backend is single source of truth
|
|
const [profileIdInput, setProfileIdInput] = useState(authManager.getProfileId());
|
|
const [refreshTokenInput, setRefreshTokenInput] = useState('');
|
|
const [authStatus, setAuthStatus] = useState<AuthStatus>(authManager.getStatus());
|
|
const [authSaving, setAuthSaving] = useState(false);
|
|
const [authMessage, setAuthMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
|
|
// Location rotation state
|
|
const [rotationState, setRotationState] = useState<any>(null);
|
|
const [rotationStatus, setRotationStatus] = useState<any>(null);
|
|
const [rotationLoading, setRotationLoading] = useState(true);
|
|
const [rotatingNow, setRotatingNow] = useState(false);
|
|
const [newGroupName, setNewGroupName] = useState('');
|
|
const [newGroupLocationIds, setNewGroupLocationIds] = useState<string[]>([]);
|
|
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
|
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
|
const [editGroupLocationIds, setEditGroupLocationIds] = useState<string[]>([]);
|
|
|
|
// Fetch rotation state
|
|
const fetchRotationState = useCallback(async () => {
|
|
try {
|
|
const [stateRes, statusRes] = await Promise.all([
|
|
fetch('/api/location-rotation'),
|
|
fetch('/api/location-rotation/status'),
|
|
]);
|
|
if (stateRes.ok) setRotationState(await stateRes.json());
|
|
if (statusRes.ok) setRotationStatus(await statusRes.json());
|
|
} catch (e) {
|
|
console.error('Failed to fetch rotation state:', e);
|
|
} finally {
|
|
setRotationLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { fetchRotationState(); }, [fetchRotationState]);
|
|
|
|
// Refresh rotation status every 30 seconds
|
|
useEffect(() => {
|
|
const interval = setInterval(async () => {
|
|
try {
|
|
const res = await fetch('/api/location-rotation/status');
|
|
if (res.ok) setRotationStatus(await res.json());
|
|
} catch (e) {}
|
|
}, 30000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
// Load current profile ID from backend on mount
|
|
useEffect(() => {
|
|
setProfileIdInput(authManager.getProfileId());
|
|
}, []);
|
|
|
|
const [rotationError, setRotationError] = useState<string | null>(null);
|
|
|
|
// Teleport state
|
|
const [teleportLoading, setTeleportLoading] = useState(false);
|
|
const [teleportStatus, setTeleportStatus] = useState<string | null>(null);
|
|
const [teleportCity, setTeleportCity] = useState<string | null>(null);
|
|
|
|
// Notifications/Preferences state
|
|
const [syncingAccount, setSyncingAccount] = useState(false);
|
|
const [syncResult, setSyncResult] = useState<string | null>(null);
|
|
|
|
// Account management state
|
|
const [offerCode, setOfferCode] = useState('');
|
|
const [redeemingOffer, setRedeemingOffer] = useState(false);
|
|
const [offerResult, setOfferResult] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
const [pauseConfirm, setPauseConfirm] = useState(false);
|
|
const [pausingAccount, setPausingAccount] = useState(false);
|
|
const [deleteConfirm, setDeleteConfirm] = useState(0); // 0=none, 1=first confirm, 2=second confirm
|
|
const [deletingAccount, setDeletingAccount] = useState(false);
|
|
const [accountActionResult, setAccountActionResult] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
|
|
// Teleport queries/mutations
|
|
const { data: popularLocationsData } = useQuery(POPULAR_LOCATIONS_QUERY);
|
|
const [profileLocationUpdate] = useMutation(PROFILE_LOCATION_UPDATE_MUTATION);
|
|
|
|
// App settings queries/mutations
|
|
const { data: appSettingsData, refetch: refetchAppSettings } = useQuery(APP_SETTINGS_QUERY);
|
|
const [appSettingsUpdate] = useMutation(APP_SETTINGS_UPDATE_MUTATION);
|
|
const [syncAccount] = useMutation(SYNC_ACCOUNT_MUTATION);
|
|
|
|
// Account queries/mutations
|
|
const { data: redeemedOffersData, refetch: refetchOffers } = useQuery(REDEEMED_OFFERS_QUERY);
|
|
const { data: linkedReflectionData } = useQuery(HAS_LINKED_REFLECTION_QUERY);
|
|
const [redeemOffer] = useMutation(ACCOUNT_REDEEM_OFFER_MUTATION);
|
|
const [deactivateAccount] = useMutation(ACCOUNT_DEACTIVATE_MUTATION);
|
|
const [terminateAccount] = useMutation(ACCOUNT_TERMINATE_MUTATION);
|
|
|
|
const handleTeleport = async (loc: any) => {
|
|
setTeleportLoading(true);
|
|
setTeleportStatus(null);
|
|
try {
|
|
await profileLocationUpdate({
|
|
variables: {
|
|
input: {
|
|
teleportLocation: {
|
|
latitude: loc.latitude,
|
|
longitude: loc.longitude,
|
|
city: loc.geocode?.city || '',
|
|
country: loc.geocode?.country || '',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
setTeleportCity(`${loc.geocode?.city || 'Unknown'}, ${loc.geocode?.country || ''}`);
|
|
setTeleportStatus('success');
|
|
} catch (err) {
|
|
setTeleportStatus('error');
|
|
} finally {
|
|
setTeleportLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleResetTeleport = async () => {
|
|
setTeleportLoading(true);
|
|
setTeleportStatus(null);
|
|
try {
|
|
await profileLocationUpdate({
|
|
variables: {
|
|
input: {
|
|
deviceLocation: { latitude: 0, longitude: 0 },
|
|
},
|
|
},
|
|
});
|
|
setTeleportCity(null);
|
|
setTeleportStatus('reset');
|
|
} catch (err) {
|
|
setTeleportStatus('error');
|
|
} finally {
|
|
setTeleportLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleToggleNotification = async (field: string, currentValue: boolean) => {
|
|
try {
|
|
await appSettingsUpdate({ variables: { [field]: !currentValue } });
|
|
refetchAppSettings();
|
|
} catch (err) {
|
|
console.error('Failed to update setting:', err);
|
|
}
|
|
};
|
|
|
|
const handleSyncAccount = async () => {
|
|
setSyncingAccount(true);
|
|
setSyncResult(null);
|
|
try {
|
|
const result = await syncAccount();
|
|
const data = result.data?.syncAccount;
|
|
setSyncResult(`Synced: Majestic=${data?.isMajestic ? 'Yes' : 'No'}, Uplift=${data?.isUplift ? 'Yes' : 'No'}, Pings=${data?.availablePings ?? 'N/A'}`);
|
|
} catch (err) {
|
|
setSyncResult('Sync failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
|
} finally {
|
|
setSyncingAccount(false);
|
|
}
|
|
};
|
|
|
|
const handleRedeemOffer = async () => {
|
|
if (!offerCode.trim()) return;
|
|
setRedeemingOffer(true);
|
|
setOfferResult(null);
|
|
try {
|
|
await redeemOffer({ variables: { input: { offerName: offerCode.trim() } } });
|
|
setOfferResult({ type: 'success', text: `Offer "${offerCode.trim()}" redeemed!` });
|
|
setOfferCode('');
|
|
refetchOffers();
|
|
} catch (err) {
|
|
setOfferResult({ type: 'error', text: err instanceof Error ? err.message : 'Failed to redeem offer' });
|
|
} finally {
|
|
setRedeemingOffer(false);
|
|
}
|
|
};
|
|
|
|
const handlePauseAccount = async () => {
|
|
setPausingAccount(true);
|
|
setAccountActionResult(null);
|
|
try {
|
|
await deactivateAccount();
|
|
setAccountActionResult({ type: 'success', text: 'Account paused (deactivated).' });
|
|
setPauseConfirm(false);
|
|
} catch (err) {
|
|
setAccountActionResult({ type: 'error', text: err instanceof Error ? err.message : 'Failed to pause account' });
|
|
} finally {
|
|
setPausingAccount(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteAccount = async () => {
|
|
setDeletingAccount(true);
|
|
setAccountActionResult(null);
|
|
try {
|
|
await terminateAccount();
|
|
setAccountActionResult({ type: 'success', text: 'Account terminated. You will be logged out.' });
|
|
setDeleteConfirm(0);
|
|
} catch (err) {
|
|
setAccountActionResult({ type: 'error', text: err instanceof Error ? err.message : 'Failed to delete account' });
|
|
} finally {
|
|
setDeletingAccount(false);
|
|
}
|
|
};
|
|
|
|
const updateRotation = async (updates: Record<string, any>) => {
|
|
setRotationError(null);
|
|
try {
|
|
// Only send the changed fields — backend merges with file state
|
|
const res = await fetch('/api/location-rotation', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updates),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setRotationState(data.state);
|
|
// Refresh status
|
|
const statusRes = await fetch('/api/location-rotation/status');
|
|
if (statusRes.ok) setRotationStatus(await statusRes.json());
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to update rotation:', e);
|
|
}
|
|
};
|
|
|
|
const handleRotateNow = async () => {
|
|
setRotatingNow(true);
|
|
setRotationError(null);
|
|
try {
|
|
const res = await fetch('/api/location-rotation/rotate-now', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
setRotationState(data.state);
|
|
const statusRes = await fetch('/api/location-rotation/status');
|
|
if (statusRes.ok) setRotationStatus(await statusRes.json());
|
|
} else {
|
|
setRotationError(data.error || 'Rotation failed');
|
|
}
|
|
} catch (e) {
|
|
setRotationError(e instanceof Error ? e.message : 'Rotation failed');
|
|
} finally {
|
|
setRotatingNow(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateGroup = () => {
|
|
if (!newGroupName.trim() || newGroupLocationIds.length === 0) return;
|
|
const newGroup = {
|
|
id: crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(),
|
|
name: newGroupName.trim(),
|
|
locationIds: newGroupLocationIds,
|
|
};
|
|
const groups = [...(rotationState?.groups || []), newGroup];
|
|
updateRotation({ groups });
|
|
setNewGroupName('');
|
|
setNewGroupLocationIds([]);
|
|
setShowCreateGroup(false);
|
|
};
|
|
|
|
const handleDeleteGroup = (groupId: string) => {
|
|
const groups = (rotationState?.groups || []).filter((g: any) => g.id !== groupId);
|
|
const updates: Record<string, any> = { groups };
|
|
if (rotationState?.activeGroupId === groupId) {
|
|
updates.activeGroupId = null;
|
|
updates.enabled = false;
|
|
}
|
|
updateRotation(updates);
|
|
};
|
|
|
|
const handleSaveEditGroup = (groupId: string) => {
|
|
const groups = (rotationState?.groups || []).map((g: any) =>
|
|
g.id === groupId ? { ...g, locationIds: editGroupLocationIds } : g
|
|
);
|
|
updateRotation({ groups });
|
|
setEditingGroupId(null);
|
|
setEditGroupLocationIds([]);
|
|
};
|
|
|
|
// Subscribe to auth status changes
|
|
useEffect(() => {
|
|
const unsubscribe = authManager.subscribe(() => {
|
|
setAuthStatus(authManager.getStatus());
|
|
});
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
// Refresh auth status every second while on settings page
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setAuthStatus(authManager.getStatus());
|
|
}, 1000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const handleSaveCredentials = async () => {
|
|
setAuthSaving(true);
|
|
setAuthMessage(null);
|
|
try {
|
|
const profileId = profileIdInput.trim();
|
|
const refreshToken = refreshTokenInput.trim();
|
|
|
|
if (!refreshToken) {
|
|
setAuthMessage({ type: 'error', text: 'Refresh token is required' });
|
|
return;
|
|
}
|
|
|
|
// Seed directly to backend — single source of truth
|
|
const resp = await fetch('/api/auth/seed', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ refreshToken, profileId }),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const data = await resp.json().catch(() => ({}));
|
|
throw new Error(data.error || `HTTP ${resp.status}`);
|
|
}
|
|
|
|
await authManager.forceRefresh();
|
|
setAuthMessage({ type: 'success', text: 'Credentials saved to server! All devices will use the new token.' });
|
|
setRefreshTokenInput('');
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
} catch (err) {
|
|
setAuthMessage({ type: 'error', text: err instanceof Error ? err.message : 'Failed to save credentials' });
|
|
} finally {
|
|
setAuthSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleRefreshToken = async () => {
|
|
setAuthSaving(true);
|
|
setAuthMessage(null);
|
|
try {
|
|
await authManager.forceRefresh();
|
|
setAuthMessage({ type: 'success', text: 'Token refreshed successfully!' });
|
|
} catch (err) {
|
|
setAuthMessage({ type: 'error', text: err instanceof Error ? err.message : 'Failed to refresh token' });
|
|
} finally {
|
|
setAuthSaving(false);
|
|
}
|
|
};
|
|
|
|
const {
|
|
location,
|
|
savedLocations,
|
|
setLocation,
|
|
saveLocation,
|
|
deleteLocation,
|
|
clearLocation,
|
|
} = useLocation();
|
|
|
|
const { data: settingsData, loading: settingsLoading } = useQuery(
|
|
DISCOVER_SEARCH_SETTINGS_QUERY,
|
|
{
|
|
variables: { profileId: authManager.getProfileId() },
|
|
}
|
|
);
|
|
|
|
const { data: incognitoData, loading: incognitoLoading } = useQuery(
|
|
IS_INCOGNITO_QUERY,
|
|
{
|
|
variables: { profileId: authManager.getProfileId() },
|
|
}
|
|
);
|
|
|
|
const [updateLocation] = useMutation(DEVICE_LOCATION_UPDATE_MUTATION);
|
|
const [updateSearchSettings] = useMutation(SEARCH_SETTINGS_UPDATE_MUTATION);
|
|
const [locationStatus, setLocationStatus] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
|
|
if (settingsLoading || incognitoLoading) return <LoadingPage message="Loading settings..." />;
|
|
|
|
const settings = settingsData?.profile;
|
|
const isIncognito = incognitoData?.profile?.isIncognito;
|
|
|
|
const handleSearch = async () => {
|
|
if (!searchQuery.trim()) return;
|
|
|
|
setSearching(true);
|
|
setSearchError(null);
|
|
|
|
try {
|
|
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);
|
|
|
|
await updateLocation({
|
|
variables: {
|
|
input: {
|
|
latitude: result.lat,
|
|
longitude: result.lng,
|
|
},
|
|
},
|
|
});
|
|
|
|
setLocationStatus({ type: 'success', text: `Location set to ${result.displayName} — you'll appear to users in this area` });
|
|
setTimeout(() => setLocationStatus(null), 4000);
|
|
setSearchQuery('');
|
|
} else {
|
|
setSearchError('Location not found. Try a different search.');
|
|
}
|
|
} catch (error) {
|
|
setSearchError('Failed to search location. Please try again.');
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
};
|
|
|
|
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);
|
|
|
|
try {
|
|
await updateLocation({
|
|
variables: {
|
|
input: {
|
|
latitude: saved.latitude,
|
|
longitude: saved.longitude,
|
|
},
|
|
},
|
|
});
|
|
// API returns 0,0 as privacy masking — location IS set server-side
|
|
setLocationStatus({ type: 'success', text: `Location set to ${saved.name} — you'll appear to users in this area` });
|
|
setTimeout(() => setLocationStatus(null), 4000);
|
|
} catch (error) {
|
|
console.error('Failed to update location:', error);
|
|
setLocationStatus({ type: 'error', text: 'Failed to update location' });
|
|
setTimeout(() => setLocationStatus(null), 4000);
|
|
}
|
|
};
|
|
|
|
const handleSaveCurrentLocation = () => {
|
|
if (!location || !saveLocationName.trim()) return;
|
|
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)
|
|
const DESIRE_OPTIONS = [
|
|
'AFTERCARE', 'BDSM', 'BONDAGE', 'BRAT', 'BRAT_TAMER', 'CASUAL', 'CELIBATE',
|
|
'COMMUNICATION', 'CONNECTION', 'COUPLES', 'CUDDLING', 'DATES', 'DOMINANTS',
|
|
'EDGING', 'ENM', 'EXPLORATION', 'FF', 'FFF', 'FFFF', 'FFM', 'FLIRTING',
|
|
'FOREPLAY', 'FREEDOMME', 'FRIENDSHIPS', 'FUN', 'FWB', 'GGG', 'GROUP',
|
|
'INTIMACY', 'KINK', 'KISSING', 'MASSAGE', 'MF', 'MFMF', 'MM', 'MMF', 'MMM',
|
|
'MMMM', 'MONOGAMY', 'OPEN_RELATIONSHIP', 'PARTIES', 'POLY', 'RELATIONSHIP',
|
|
'ROLE_PLAY', 'ROUGH', 'SENSUAL', 'SINGLES', 'SUBMISSIVES', 'SWITCH',
|
|
'TEXTING', 'THREESOME', 'TOYS', 'VANILLA', 'WATCHING'
|
|
];
|
|
|
|
const handleStartEditDesiringFor = () => {
|
|
setTempDesiringFor(settings?.desiringFor || []);
|
|
setEditingDesiringFor(true);
|
|
};
|
|
|
|
const handleCancelEditDesiringFor = () => {
|
|
setEditingDesiringFor(false);
|
|
setTempDesiringFor([]);
|
|
};
|
|
|
|
const handleToggleDesiringFor = (option: string) => {
|
|
setTempDesiringFor(prev =>
|
|
prev.includes(option)
|
|
? prev.filter(o => o !== option)
|
|
: [...prev, option]
|
|
);
|
|
};
|
|
|
|
const handleSaveDesiringFor = async () => {
|
|
setSavingDesiringFor(true);
|
|
try {
|
|
await updateSearchSettings({
|
|
variables: {
|
|
desiringFor: tempDesiringFor,
|
|
},
|
|
});
|
|
setEditingDesiringFor(false);
|
|
// Refetch settings to update the display
|
|
window.location.reload();
|
|
} catch (error) {
|
|
console.error('Failed to update desiring for:', error);
|
|
} finally {
|
|
setSavingDesiringFor(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={styles.container}>
|
|
{/* Header */}
|
|
<div style={styles.header}>
|
|
<h1 style={styles.title}>Settings</h1>
|
|
<p style={styles.subtitle}>Manage your preferences</p>
|
|
</div>
|
|
|
|
{/* Location Settings */}
|
|
<div style={styles.card}>
|
|
<div style={styles.cardContent}>
|
|
<div style={styles.sectionHeader}>
|
|
<div style={styles.iconBox('#3b82f6')}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
|
</svg>
|
|
</div>
|
|
<h2 style={styles.sectionTitle}>Location</h2>
|
|
</div>
|
|
|
|
{/* Current Location */}
|
|
<div style={{ marginBottom: '24px' }}>
|
|
<label style={styles.label}>Current Location</label>
|
|
{location ? (
|
|
<div style={{ ...styles.infoBox, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
|
<div>
|
|
<p style={{ color: '#ffffff', fontWeight: 500, marginBottom: '4px' }}>
|
|
{location.name || 'Custom Location'}
|
|
</p>
|
|
<p style={{ fontSize: '12px', color: '#6b7280', fontFamily: 'monospace' }}>
|
|
{location.latitude.toFixed(4)}, {location.longitude.toFixed(4)}
|
|
</p>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
{!showSaveDialog && (
|
|
<button style={styles.buttonSecondary} onClick={() => setShowSaveDialog(true)}>
|
|
Save
|
|
</button>
|
|
)}
|
|
<button style={styles.buttonSecondary} onClick={clearLocation}>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p style={{ color: '#6b7280' }}>Using default location</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Save Location Dialog */}
|
|
{showSaveDialog && location && (
|
|
<div style={{ ...styles.infoBox, marginBottom: '24px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
|
<label style={styles.label}>Save Location As</label>
|
|
<div style={{ display: 'flex', gap: '10px' }}>
|
|
<input
|
|
type="text"
|
|
value={saveLocationName}
|
|
onChange={(e) => setSaveLocationName(e.target.value)}
|
|
placeholder="e.g., Work, Home, NYC"
|
|
style={styles.input}
|
|
/>
|
|
<button style={styles.button} onClick={handleSaveCurrentLocation}>Save</button>
|
|
<button style={styles.buttonSecondary} onClick={() => setShowSaveDialog(false)}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search Location */}
|
|
<div style={{ marginBottom: '24px' }}>
|
|
<label style={styles.label}>Search Location</label>
|
|
<div style={{ display: 'flex', gap: '10px' }}>
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
placeholder="Enter city, address, or place..."
|
|
style={styles.input}
|
|
/>
|
|
<button
|
|
style={{ ...styles.button, opacity: searching ? 0.7 : 1 }}
|
|
onClick={handleSearch}
|
|
disabled={searching}
|
|
>
|
|
{searching ? 'Searching...' : 'Search'}
|
|
</button>
|
|
</div>
|
|
{searchError && (
|
|
<p style={{ color: '#ef4444', fontSize: '14px', marginTop: '12px' }}>{searchError}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Saved Locations */}
|
|
{savedLocations.length > 0 && (
|
|
<div>
|
|
<label style={styles.label}>Saved Locations</label>
|
|
{savedLocations.map((saved) => {
|
|
const isActive = location &&
|
|
Math.abs(location.latitude - saved.latitude) < 0.0001 &&
|
|
Math.abs(location.longitude - saved.longitude) < 0.0001;
|
|
|
|
return (
|
|
<div
|
|
key={saved.id}
|
|
style={{
|
|
...styles.savedLocation,
|
|
border: isActive ? '1px solid rgba(34, 197, 94, 0.3)' : '1px solid transparent',
|
|
}}
|
|
onMouseEnter={(e) => e.currentTarget.style.background = '#2e2e3a'}
|
|
onMouseLeave={(e) => e.currentTarget.style.background = '#24242f'}
|
|
>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '4px' }}>
|
|
<p style={{ color: '#ffffff', fontWeight: 500, margin: 0 }}>{saved.name}</p>
|
|
{isActive && (
|
|
<span style={styles.activeLocationBadge}>
|
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ width: '12px', height: '12px' }}>
|
|
<path fillRule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
|
|
</svg>
|
|
Active
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p style={{ fontSize: '12px', color: '#6b7280', fontFamily: 'monospace', margin: 0 }}>
|
|
{saved.latitude.toFixed(4)}, {saved.longitude.toFixed(4)}
|
|
</p>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
{!isActive && (
|
|
<button
|
|
style={styles.setLocationButton}
|
|
onClick={() => handleSelectSavedLocation(saved)}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.opacity = '0.9';
|
|
e.currentTarget.style.transform = 'scale(1.02)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.opacity = '1';
|
|
e.currentTarget.style.transform = 'scale(1)';
|
|
}}
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '14px', height: '14px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
|
</svg>
|
|
Set Location
|
|
</button>
|
|
)}
|
|
<button
|
|
style={styles.buttonSecondary}
|
|
onClick={() => deleteLocation(saved.id)}
|
|
title="Delete saved location"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '16px', height: '16px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Location status toast */}
|
|
{locationStatus && (
|
|
<div style={{
|
|
marginTop: '12px',
|
|
padding: '10px 16px',
|
|
borderRadius: '10px',
|
|
fontSize: '13px',
|
|
fontFamily: "'Satoshi', sans-serif",
|
|
background: locationStatus.type === 'success' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
|
|
color: locationStatus.type === 'success' ? '#86efac' : '#f87171',
|
|
border: `1px solid ${locationStatus.type === 'success' ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
|
|
}}>
|
|
{locationStatus.text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Location Rotation */}
|
|
<div style={styles.card}>
|
|
<div style={styles.cardContent}>
|
|
<div style={styles.sectionHeader}>
|
|
<div style={styles.iconBox('#f59e0b')}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M2.985 19.644l3.181-3.183" />
|
|
</svg>
|
|
</div>
|
|
<h2 style={styles.sectionTitle}>Location Rotation</h2>
|
|
</div>
|
|
|
|
{rotationLoading ? (
|
|
<p style={{ color: '#6b7280' }}>Loading rotation settings...</p>
|
|
) : (
|
|
<>
|
|
{/* Enable/Disable Toggle */}
|
|
<div style={{ ...styles.toggleContainer, marginBottom: '20px', cursor: 'pointer' }}
|
|
onClick={() => updateRotation({ enabled: !rotationState?.enabled })}>
|
|
<div>
|
|
<p style={{ color: '#ffffff', fontWeight: 500, marginBottom: '4px' }}>Auto-Rotate</p>
|
|
<p style={{ color: '#6b7280', fontSize: '14px' }}>
|
|
Automatically cycle between location groups
|
|
</p>
|
|
</div>
|
|
<div style={{
|
|
...styles.toggle(!!rotationState?.enabled),
|
|
cursor: 'pointer',
|
|
}}>
|
|
<div style={styles.toggleKnob(!!rotationState?.enabled)} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Interval Selector */}
|
|
<div style={{ marginBottom: '20px' }}>
|
|
<label style={styles.label}>Rotation Interval</label>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
{[2, 4, 6, 8].map(h => (
|
|
<button
|
|
key={h}
|
|
onClick={() => updateRotation({ intervalHours: h })}
|
|
style={{
|
|
padding: '10px 18px',
|
|
borderRadius: '10px',
|
|
fontSize: '14px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
border: rotationState?.intervalHours === h
|
|
? '1px solid #f59e0b'
|
|
: '1px solid rgba(255,255,255,0.1)',
|
|
background: rotationState?.intervalHours === h
|
|
? 'rgba(245, 158, 11, 0.2)'
|
|
: '#24242f',
|
|
color: rotationState?.intervalHours === h ? '#f59e0b' : '#9ca3af',
|
|
}}
|
|
>
|
|
{h}h
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Location Groups */}
|
|
<div style={{ marginBottom: '20px' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
|
<label style={{ ...styles.label, marginBottom: 0 }}>Location Groups</label>
|
|
<button
|
|
onClick={() => setShowCreateGroup(!showCreateGroup)}
|
|
style={{
|
|
padding: '6px 14px',
|
|
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
|
border: 'none',
|
|
borderRadius: '8px',
|
|
color: '#fff',
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{showCreateGroup ? 'Cancel' : '+ Create Group'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Create Group Form */}
|
|
{showCreateGroup && (
|
|
<div style={{ ...styles.infoBox, marginBottom: '16px', border: '1px solid rgba(245, 158, 11, 0.3)' }}>
|
|
<div style={{ marginBottom: '12px' }}>
|
|
<label style={styles.label}>Group Name</label>
|
|
<input
|
|
type="text"
|
|
value={newGroupName}
|
|
onChange={e => setNewGroupName(e.target.value)}
|
|
placeholder="e.g., DFW, Austin Area"
|
|
style={styles.input}
|
|
/>
|
|
</div>
|
|
<div style={{ marginBottom: '12px' }}>
|
|
<label style={styles.label}>Select Locations</label>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
|
{savedLocations.map(loc => {
|
|
const isSelected = newGroupLocationIds.includes(loc.id);
|
|
return (
|
|
<button
|
|
key={loc.id}
|
|
onClick={() => setNewGroupLocationIds(prev =>
|
|
isSelected ? prev.filter(id => id !== loc.id) : [...prev, loc.id]
|
|
)}
|
|
style={{
|
|
padding: '8px 14px',
|
|
borderRadius: '8px',
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
cursor: 'pointer',
|
|
border: isSelected ? '1px solid #f59e0b' : '1px solid rgba(255,255,255,0.1)',
|
|
background: isSelected ? 'rgba(245, 158, 11, 0.2)' : '#1a1a24',
|
|
color: isSelected ? '#f59e0b' : '#9ca3af',
|
|
}}
|
|
>
|
|
{loc.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{savedLocations.length === 0 && (
|
|
<p style={{ color: '#6b7280', fontSize: '13px' }}>No saved locations. Add locations above first.</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={handleCreateGroup}
|
|
disabled={!newGroupName.trim() || newGroupLocationIds.length === 0}
|
|
style={{
|
|
...styles.button,
|
|
opacity: (!newGroupName.trim() || newGroupLocationIds.length === 0) ? 0.5 : 1,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
Create Group ({newGroupLocationIds.length} locations)
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Existing Groups */}
|
|
{(rotationState?.groups || []).map((group: any) => {
|
|
const isActive = rotationState?.activeGroupId === group.id;
|
|
const isEditing = editingGroupId === group.id;
|
|
const groupLocations = savedLocations.filter(l => group.locationIds.includes(l.id));
|
|
|
|
return (
|
|
<div
|
|
key={group.id}
|
|
style={{
|
|
...styles.savedLocation,
|
|
flexDirection: 'column' as any,
|
|
alignItems: 'stretch',
|
|
border: isActive ? '1px solid rgba(245, 158, 11, 0.3)' : '1px solid transparent',
|
|
marginBottom: '10px',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
<p style={{ color: '#ffffff', fontWeight: 600, margin: 0, fontSize: '16px' }}>
|
|
{group.name}
|
|
</p>
|
|
{isActive && (
|
|
<span style={{
|
|
...styles.activeLocationBadge,
|
|
background: 'rgba(245, 158, 11, 0.15)',
|
|
color: '#f59e0b',
|
|
}}>
|
|
Active
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
{!isActive ? (
|
|
<button
|
|
onClick={() => updateRotation({ activeGroupId: group.id })}
|
|
style={{
|
|
...styles.setLocationButton,
|
|
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
|
boxShadow: '0 4px 12px rgba(245, 158, 11, 0.3)',
|
|
}}
|
|
>
|
|
Activate
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => updateRotation({ activeGroupId: null, enabled: false })}
|
|
style={styles.buttonSecondary}
|
|
>
|
|
Deactivate
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => {
|
|
if (isEditing) {
|
|
setEditingGroupId(null);
|
|
} else {
|
|
setEditingGroupId(group.id);
|
|
setEditGroupLocationIds([...group.locationIds]);
|
|
}
|
|
}}
|
|
style={styles.buttonSecondary}
|
|
>
|
|
{isEditing ? 'Cancel' : 'Edit'}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteGroup(group.id)}
|
|
style={styles.buttonSecondary}
|
|
title="Delete group"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ width: '16px', height: '16px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Location list / edit mode */}
|
|
{isEditing ? (
|
|
<div style={{ marginTop: '12px' }}>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
|
|
{savedLocations.map(loc => {
|
|
const isSelected = editGroupLocationIds.includes(loc.id);
|
|
return (
|
|
<button
|
|
key={loc.id}
|
|
onClick={() => setEditGroupLocationIds(prev =>
|
|
isSelected ? prev.filter(id => id !== loc.id) : [...prev, loc.id]
|
|
)}
|
|
style={{
|
|
padding: '6px 12px',
|
|
borderRadius: '8px',
|
|
fontSize: '12px',
|
|
fontWeight: 500,
|
|
cursor: 'pointer',
|
|
border: isSelected ? '1px solid #f59e0b' : '1px solid rgba(255,255,255,0.1)',
|
|
background: isSelected ? 'rgba(245, 158, 11, 0.2)' : '#1a1a24',
|
|
color: isSelected ? '#f59e0b' : '#9ca3af',
|
|
}}
|
|
>
|
|
{loc.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<button
|
|
onClick={() => handleSaveEditGroup(group.id)}
|
|
style={{ ...styles.button, width: '100%', fontSize: '13px', padding: '10px' }}
|
|
>
|
|
Save ({editGroupLocationIds.length} locations)
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
|
{groupLocations.map(loc => (
|
|
<span key={loc.id} style={{
|
|
padding: '4px 10px',
|
|
background: '#1a1a24',
|
|
borderRadius: '6px',
|
|
color: '#9ca3af',
|
|
fontSize: '12px',
|
|
}}>
|
|
{loc.name}
|
|
</span>
|
|
))}
|
|
{groupLocations.length === 0 && (
|
|
<span style={{ color: '#6b7280', fontSize: '12px' }}>
|
|
No locations (may need to re-save locations)
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{(rotationState?.groups || []).length === 0 && !showCreateGroup && (
|
|
<p style={{ color: '#6b7280', fontSize: '14px' }}>
|
|
No groups yet. Create one to start rotating.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status Display */}
|
|
{rotationState?.enabled && rotationStatus && (
|
|
<div style={{ ...styles.infoBox, border: '1px solid rgba(245, 158, 11, 0.2)' }}>
|
|
<label style={styles.label}>Rotation Status</label>
|
|
|
|
{rotationStatus.currentLocation && (
|
|
<div style={{ marginBottom: '12px' }}>
|
|
<p style={{ color: '#9ca3af', fontSize: '12px', margin: '0 0 4px 0' }}>Current Location</p>
|
|
<p style={{ color: '#ffffff', fontWeight: 500, margin: 0 }}>
|
|
{rotationStatus.currentLocation.name}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{rotationStatus.nextRotation && (
|
|
<div style={{ marginBottom: '12px' }}>
|
|
<p style={{ color: '#9ca3af', fontSize: '12px', margin: '0 0 4px 0' }}>Next Rotation</p>
|
|
<p style={{ color: '#f59e0b', fontWeight: 500, margin: 0, fontSize: '14px' }}>
|
|
{new Date(rotationStatus.nextRotation).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{rotationStatus.lastResult && (
|
|
<div style={{ marginBottom: '12px' }}>
|
|
<p style={{ color: '#9ca3af', fontSize: '12px', margin: '0 0 4px 0' }}>Last Result</p>
|
|
<p style={{
|
|
color: rotationStatus.lastResult.status === 'success' ? '#22c55e' : '#ef4444',
|
|
fontWeight: 500,
|
|
margin: 0,
|
|
fontSize: '14px',
|
|
}}>
|
|
{rotationStatus.lastResult.status === 'success'
|
|
? `${rotationStatus.lastResult.profilesFound} profiles at ${rotationStatus.lastResult.location}`
|
|
: `Error: ${rotationStatus.lastResult.error}`
|
|
}
|
|
</p>
|
|
{rotationStatus.lastResult.timestamp && (
|
|
<p style={{ color: '#6b7280', fontSize: '11px', margin: '4px 0 0 0' }}>
|
|
{new Date(rotationStatus.lastResult.timestamp).toLocaleString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={handleRotateNow}
|
|
disabled={rotatingNow}
|
|
style={{
|
|
...styles.button,
|
|
width: '100%',
|
|
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
|
opacity: rotatingNow ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{rotatingNow ? 'Rotating...' : 'Rotate Now'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error display */}
|
|
{rotationError && (
|
|
<div style={{
|
|
padding: '12px 16px',
|
|
borderRadius: '10px',
|
|
background: 'rgba(239, 68, 68, 0.15)',
|
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
|
color: '#ef4444',
|
|
fontSize: '14px',
|
|
marginBottom: '16px',
|
|
}}>
|
|
{rotationError}
|
|
</div>
|
|
)}
|
|
|
|
<p style={styles.note}>
|
|
Rotates your location between grouped cities on a schedule. Updates your Feeld location and runs a discovery query to appear active. Works even when the browser is closed (runs on the backend).
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Discovery Settings */}
|
|
<div style={styles.card}>
|
|
<div style={styles.cardContent}>
|
|
<div style={styles.sectionHeader}>
|
|
<div style={styles.iconBox('#c41e3a')}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="#c41e3a" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
|
</svg>
|
|
</div>
|
|
<h2 style={styles.sectionTitle}>Discovery</h2>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gap: '24px' }}>
|
|
<div>
|
|
<label style={styles.label}>Age Range</label>
|
|
<p style={styles.value}>{settings?.ageRange?.[0] || 18} - {settings?.ageRange?.[1] || 99}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={styles.label}>Max Distance</label>
|
|
<p style={styles.value}>{settings?.distanceMax || 100} miles</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={styles.label}>Looking For</label>
|
|
<div>
|
|
{settings?.lookingFor?.map((item: string) => (
|
|
<span key={item} style={styles.badge('default')}>
|
|
{item.replace(/_/g, ' ')}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
|
<label style={{ ...styles.label, marginBottom: 0 }}>Desiring For (Search Filter)</label>
|
|
{!editingDesiringFor && (
|
|
<button
|
|
onClick={handleStartEditDesiringFor}
|
|
style={{
|
|
padding: '6px 14px',
|
|
background: 'linear-gradient(135deg, #c41e3a 0%, #e91e63 100%)',
|
|
border: 'none',
|
|
borderRadius: '8px',
|
|
color: '#fff',
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
Edit
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{editingDesiringFor ? (
|
|
<div style={{ background: '#24242f', borderRadius: '14px', padding: '20px' }}>
|
|
<p style={{ color: '#9ca3af', fontSize: '13px', marginBottom: '16px' }}>
|
|
Select what you're looking for. Profiles matching these desires will appear in discovery.
|
|
</p>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '20px', maxHeight: '300px', overflowY: 'auto' }}>
|
|
{DESIRE_OPTIONS.map((option) => {
|
|
const isSelected = tempDesiringFor.includes(option);
|
|
return (
|
|
<button
|
|
key={option}
|
|
onClick={() => handleToggleDesiringFor(option)}
|
|
style={{
|
|
padding: '8px 14px',
|
|
borderRadius: '8px',
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
cursor: 'pointer',
|
|
border: isSelected ? '1px solid #e91e63' : '1px solid rgba(255,255,255,0.1)',
|
|
background: isSelected ? 'rgba(196, 30, 58, 0.2)' : '#1a1a24',
|
|
color: isSelected ? '#e91e63' : '#9ca3af',
|
|
transition: 'all 0.2s',
|
|
}}
|
|
>
|
|
{option.replace(/_/g, ' ')}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '10px' }}>
|
|
<button
|
|
onClick={handleSaveDesiringFor}
|
|
disabled={savingDesiringFor}
|
|
style={{
|
|
...styles.button,
|
|
flex: 1,
|
|
opacity: savingDesiringFor ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{savingDesiringFor ? 'Saving...' : `Save (${tempDesiringFor.length} selected)`}
|
|
</button>
|
|
<button
|
|
onClick={handleCancelEditDesiringFor}
|
|
style={styles.buttonSecondary}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{settings?.desiringFor && settings.desiringFor.length > 0 ? (
|
|
settings.desiringFor.map((item: string) => (
|
|
<span key={item} style={styles.badge('primary')}>
|
|
{item.replace(/_/g, ' ')}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span style={{ color: '#6b7280', fontSize: '14px' }}>No filters set</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<p style={styles.note}>Age range, max distance, and looking for are read-only. Desiring For can be edited above.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Privacy Settings */}
|
|
<div style={styles.card}>
|
|
<div style={styles.cardContent}>
|
|
<div style={styles.sectionHeader}>
|
|
<div style={styles.iconBox('#a855f7')}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="#a855f7" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
|
</svg>
|
|
</div>
|
|
<h2 style={styles.sectionTitle}>Privacy</h2>
|
|
</div>
|
|
|
|
<div style={styles.toggleContainer}>
|
|
<div>
|
|
<p style={{ color: '#ffffff', fontWeight: 500, marginBottom: '4px' }}>Incognito Mode</p>
|
|
<p style={{ color: '#6b7280', fontSize: '14px' }}>Browse without being seen</p>
|
|
</div>
|
|
<div style={styles.toggle(!!isIncognito)}>
|
|
<div style={styles.toggleKnob(!!isIncognito)} />
|
|
</div>
|
|
</div>
|
|
|
|
<p style={styles.note}>Privacy toggle is read-only in this version</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Teleport */}
|
|
<div style={styles.card}>
|
|
<div style={styles.cardContent}>
|
|
<div style={styles.sectionHeader}>
|
|
<div style={styles.iconBox('#8b5cf6')}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A8.966 8.966 0 013 12c0-1.97.633-3.794 1.706-5.277" />
|
|
</svg>
|
|
</div>
|
|
<h2 style={styles.sectionTitle}>Teleport</h2>
|
|
</div>
|
|
|
|
{/* Teleport Status Badge */}
|
|
<div style={{ marginBottom: '20px' }}>
|
|
<label style={styles.label}>Current Status</label>
|
|
{teleportCity ? (
|
|
<span style={{ ...styles.badge('primary'), fontSize: '14px' }}>
|
|
Teleporting to {teleportCity}
|
|
</span>
|
|
) : (
|
|
<span style={{ ...styles.badge('success'), fontSize: '14px' }}>
|
|
Device Location
|
|
</span>
|
|
)}
|
|
{teleportStatus === 'error' && (
|
|
<p style={{ color: '#ef4444', fontSize: '13px', marginTop: '8px' }}>Failed to update teleport location.</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Popular Locations Grid */}
|
|
<div style={{ marginBottom: '20px' }}>
|
|
<label style={styles.label}>Popular Locations</label>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px' }}>
|
|
{(popularLocationsData?.popularLocations || []).map((loc: any, i: number) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => handleTeleport(loc)}
|
|
disabled={teleportLoading}
|
|
style={{
|
|
padding: '12px 10px',
|
|
background: '#24242f',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
borderRadius: '10px',
|
|
color: '#ffffff',
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
cursor: teleportLoading ? 'wait' : 'pointer',
|
|
opacity: teleportLoading ? 0.6 : 1,
|
|
transition: 'all 0.2s',
|
|
textAlign: 'center' as const,
|
|
}}
|
|
onMouseEnter={(e) => { if (!teleportLoading) { e.currentTarget.style.background = '#2e2e3a'; e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.4)'; } }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.background = '#24242f'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; }}
|
|
>
|
|
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{loc.geocode?.city || 'Unknown'}</div>
|
|
<div style={{ fontSize: '11px', color: '#6b7280' }}>{loc.geocode?.country || ''}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{teleportLoading && (
|
|
<p style={{ color: '#8b5cf6', fontSize: '13px', marginTop: '12px' }}>Teleporting...</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reset to Device Location */}
|
|
<button
|
|
onClick={handleResetTeleport}
|
|
disabled={teleportLoading}
|
|
style={{
|
|
width: '100%',
|
|
padding: '14px 24px',
|
|
background: '#24242f',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
borderRadius: '12px',
|
|
color: '#ffffff',
|
|
fontSize: '15px',
|
|
fontWeight: 600,
|
|
cursor: teleportLoading ? 'wait' : 'pointer',
|
|
opacity: teleportLoading ? 0.6 : 1,
|
|
transition: 'opacity 0.2s',
|
|
}}
|
|
>
|
|
{teleportLoading ? 'Resetting...' : 'Reset to Device Location'}
|
|
</button>
|
|
|
|
<p style={styles.note}>
|
|
Teleport your profile to appear in a different city. Popular locations are provided by Feeld. Reset to return to your device location.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notifications & Preferences */}
|
|
<div style={styles.card}>
|
|
<div style={styles.cardContent}>
|
|
<div style={styles.sectionHeader}>
|
|
<div style={styles.iconBox('#f97316')}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="#f97316" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
|
</svg>
|
|
</div>
|
|
<h2 style={styles.sectionTitle}>Notifications & Preferences</h2>
|
|
</div>
|
|
|
|
{(() => {
|
|
const acct = appSettingsData?.account;
|
|
const appSettings = acct?.appSettings;
|
|
if (!appSettings) return <p style={{ color: '#6b7280' }}>Loading notification settings...</p>;
|
|
|
|
const toggles: Array<{ label: string; field: string; value: boolean }> = [
|
|
{ label: 'New Connection', field: 'receiveNewConnectionPushNotifications', value: !!appSettings.receiveNewConnectionPushNotifications },
|
|
{ label: 'New Ping', field: 'receiveNewPingPushNotifications', value: !!appSettings.receiveNewPingPushNotifications },
|
|
{ label: 'New Message', field: 'receiveNewMessagePushNotifications', value: !!appSettings.receiveNewMessagePushNotifications },
|
|
{ label: 'New Like', field: 'receiveNewLikePushNotifications', value: !!appSettings.receiveNewLikePushNotifications },
|
|
{ label: 'Marketing', field: 'receiveMarketingNotifications', value: !!appSettings.receiveMarketingNotifications },
|
|
{ label: 'News Email', field: 'receiveNewsEmailNotifications', value: !!appSettings.receiveNewsEmailNotifications },
|
|
{ label: 'Promotions Email', field: 'receivePromotionsEmailNotifications', value: !!appSettings.receivePromotionsEmailNotifications },
|
|
{ label: 'News Push', field: 'receiveNewsPushNotifications', value: !!appSettings.receiveNewsPushNotifications },
|
|
{ label: 'Promotions Push', field: 'receivePromotionsPushNotifications', value: !!appSettings.receivePromotionsPushNotifications },
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '24px' }}>
|
|
{toggles.map(({ label, field, value }) => (
|
|
<div
|
|
key={field}
|
|
style={{ ...styles.toggleContainer, cursor: 'pointer' }}
|
|
onClick={() => handleToggleNotification(field, value)}
|
|
>
|
|
<div>
|
|
<p style={{ color: '#ffffff', fontWeight: 500, margin: 0 }}>{label}</p>
|
|
</div>
|
|
<div style={{ ...styles.toggle(value), cursor: 'pointer' }}>
|
|
<div style={styles.toggleKnob(value)} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Distance Units */}
|
|
<div
|
|
style={{ ...styles.toggleContainer, cursor: 'pointer', marginBottom: '24px' }}
|
|
onClick={() => handleToggleNotification('isDistanceInMiles', !!acct?.isDistanceInMiles)}
|
|
>
|
|
<div>
|
|
<p style={{ color: '#ffffff', fontWeight: 500, margin: 0 }}>Distance in Miles</p>
|
|
<p style={{ color: '#6b7280', fontSize: '13px', margin: '4px 0 0 0' }}>
|
|
{acct?.isDistanceInMiles ? 'Using miles' : 'Using kilometers'}
|
|
</p>
|
|
</div>
|
|
<div style={{ ...styles.toggle(!!acct?.isDistanceInMiles), cursor: 'pointer' }}>
|
|
<div style={styles.toggleKnob(!!acct?.isDistanceInMiles)} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sync Account */}
|
|
<button
|
|
onClick={handleSyncAccount}
|
|
disabled={syncingAccount}
|
|
style={{
|
|
...styles.button,
|
|
width: '100%',
|
|
background: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)',
|
|
opacity: syncingAccount ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{syncingAccount ? 'Syncing...' : 'Sync Account'}
|
|
</button>
|
|
{syncResult && (
|
|
<p style={{ color: '#f97316', fontSize: '13px', marginTop: '12px' }}>{syncResult}</p>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
|
|
<p style={styles.note}>
|
|
Toggle notification preferences for your account. Changes are saved immediately. Sync Account refreshes your subscription status from the server.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Account Management */}
|
|
<div style={styles.card}>
|
|
<div style={styles.cardContent}>
|
|
<div style={styles.sectionHeader}>
|
|
<div style={styles.iconBox('#06b6d4')}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</div>
|
|
<h2 style={styles.sectionTitle}>Account</h2>
|
|
</div>
|
|
|
|
{/* Subscription Status */}
|
|
<div style={{ marginBottom: '24px' }}>
|
|
<label style={styles.label}>Subscription</label>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
|
<span style={styles.badge(appSettingsData?.account?.isMajestic ? 'success' : 'default')}>
|
|
{appSettingsData?.account?.isMajestic ? 'Majestic' : 'Free'}
|
|
</span>
|
|
{appSettingsData?.account?.isUplift && (
|
|
<span style={styles.badge('primary')}>Uplift Active</span>
|
|
)}
|
|
<span style={styles.badge('default')}>
|
|
{appSettingsData?.account?.availablePings ?? '?'} Pings Available
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reflect Link Status */}
|
|
<div style={{ marginBottom: '24px' }}>
|
|
<label style={styles.label}>Reflect Link</label>
|
|
<span style={styles.badge(linkedReflectionData?.hasLinkedReflection ? 'success' : 'default')}>
|
|
{linkedReflectionData?.hasLinkedReflection ? 'Linked' : 'Not Linked'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Redeemed Offers */}
|
|
<div style={{ marginBottom: '24px' }}>
|
|
<label style={styles.label}>Redeemed Offers</label>
|
|
{(redeemedOffersData?.account?.redeemedOffers || []).length > 0 ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
{(redeemedOffersData?.account?.redeemedOffers || []).map((offer: any, i: number) => (
|
|
<div key={i} style={styles.infoBox}>
|
|
<p style={{ color: '#ffffff', fontWeight: 500, margin: 0 }}>{offer.offerName}</p>
|
|
{offer.redeemedAt && (
|
|
<p style={{ color: '#6b7280', fontSize: '12px', margin: '4px 0 0 0' }}>
|
|
Redeemed: {new Date(offer.redeemedAt).toLocaleDateString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p style={{ color: '#6b7280', fontSize: '14px' }}>No offers redeemed</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Redeem Offer */}
|
|
<div style={{ marginBottom: '24px' }}>
|
|
<label style={styles.label}>Redeem Offer Code</label>
|
|
<div style={{ display: 'flex', gap: '10px' }}>
|
|
<input
|
|
type="text"
|
|
value={offerCode}
|
|
onChange={(e) => setOfferCode(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleRedeemOffer()}
|
|
placeholder="Enter offer code..."
|
|
style={styles.input}
|
|
/>
|
|
<button
|
|
onClick={handleRedeemOffer}
|
|
disabled={redeemingOffer || !offerCode.trim()}
|
|
style={{
|
|
...styles.button,
|
|
opacity: (redeemingOffer || !offerCode.trim()) ? 0.6 : 1,
|
|
}}
|
|
>
|
|
{redeemingOffer ? 'Redeeming...' : 'Redeem'}
|
|
</button>
|
|
</div>
|
|
{offerResult && (
|
|
<p style={{
|
|
color: offerResult.type === 'success' ? '#22c55e' : '#ef4444',
|
|
fontSize: '13px',
|
|
marginTop: '8px',
|
|
}}>
|
|
{offerResult.text}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pause Account */}
|
|
<div style={{ marginBottom: '16px' }}>
|
|
{!pauseConfirm ? (
|
|
<button
|
|
onClick={() => setPauseConfirm(true)}
|
|
style={{
|
|
width: '100%',
|
|
padding: '14px 24px',
|
|
background: 'rgba(245, 158, 11, 0.15)',
|
|
border: '1px solid rgba(245, 158, 11, 0.3)',
|
|
borderRadius: '12px',
|
|
color: '#f59e0b',
|
|
fontSize: '15px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
transition: 'background 0.2s',
|
|
}}
|
|
>
|
|
Pause Account
|
|
</button>
|
|
) : (
|
|
<div style={{ ...styles.infoBox, border: '1px solid rgba(245, 158, 11, 0.3)' }}>
|
|
<p style={{ color: '#f59e0b', fontWeight: 500, marginBottom: '12px' }}>
|
|
Are you sure you want to pause your account? Your profile will be hidden.
|
|
</p>
|
|
<div style={{ display: 'flex', gap: '10px' }}>
|
|
<button
|
|
onClick={handlePauseAccount}
|
|
disabled={pausingAccount}
|
|
style={{
|
|
...styles.button,
|
|
flex: 1,
|
|
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
|
opacity: pausingAccount ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{pausingAccount ? 'Pausing...' : 'Yes, Pause'}
|
|
</button>
|
|
<button
|
|
onClick={() => setPauseConfirm(false)}
|
|
style={{ ...styles.buttonSecondary, flex: 1 }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Delete Account */}
|
|
<div style={{ marginBottom: '16px' }}>
|
|
{deleteConfirm === 0 && (
|
|
<button
|
|
onClick={() => setDeleteConfirm(1)}
|
|
style={{
|
|
width: '100%',
|
|
padding: '14px 24px',
|
|
background: 'rgba(239, 68, 68, 0.15)',
|
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
|
borderRadius: '12px',
|
|
color: '#ef4444',
|
|
fontSize: '15px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
transition: 'background 0.2s',
|
|
}}
|
|
>
|
|
Delete Account
|
|
</button>
|
|
)}
|
|
{deleteConfirm === 1 && (
|
|
<div style={{ ...styles.infoBox, border: '1px solid rgba(239, 68, 68, 0.3)' }}>
|
|
<p style={{ color: '#ef4444', fontWeight: 500, marginBottom: '12px' }}>
|
|
This will permanently delete your account. This action cannot be undone. Are you sure?
|
|
</p>
|
|
<div style={{ display: 'flex', gap: '10px' }}>
|
|
<button
|
|
onClick={() => setDeleteConfirm(2)}
|
|
style={{
|
|
...styles.button,
|
|
flex: 1,
|
|
background: 'rgba(239, 68, 68, 0.3)',
|
|
}}
|
|
>
|
|
Yes, I'm sure
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteConfirm(0)}
|
|
style={{ ...styles.buttonSecondary, flex: 1 }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{deleteConfirm === 2 && (
|
|
<div style={{ ...styles.infoBox, border: '2px solid #ef4444' }}>
|
|
<p style={{ color: '#ef4444', fontWeight: 700, marginBottom: '12px', fontSize: '16px' }}>
|
|
FINAL WARNING: This is irreversible. Your account, matches, and messages will be permanently deleted.
|
|
</p>
|
|
<div style={{ display: 'flex', gap: '10px' }}>
|
|
<button
|
|
onClick={handleDeleteAccount}
|
|
disabled={deletingAccount}
|
|
style={{
|
|
...styles.button,
|
|
flex: 1,
|
|
background: 'linear-gradient(135deg, #dc2626 0%, #ef4444 100%)',
|
|
opacity: deletingAccount ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{deletingAccount ? 'Deleting...' : 'DELETE PERMANENTLY'}
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteConfirm(0)}
|
|
style={{ ...styles.buttonSecondary, flex: 1 }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Account Action Result */}
|
|
{accountActionResult && (
|
|
<div style={{
|
|
padding: '12px 16px',
|
|
borderRadius: '10px',
|
|
background: accountActionResult.type === 'success' ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.15)',
|
|
border: `1px solid ${accountActionResult.type === 'success' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'}`,
|
|
color: accountActionResult.type === 'success' ? '#22c55e' : '#ef4444',
|
|
fontSize: '14px',
|
|
}}>
|
|
{accountActionResult.text}
|
|
</div>
|
|
)}
|
|
|
|
<p style={styles.note}>
|
|
Pausing hides your profile temporarily. Deleting permanently removes your account, all data, matches, and messages.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Auth Credentials */}
|
|
<div style={styles.card}>
|
|
<div style={styles.cardContent}>
|
|
<div style={styles.sectionHeader}>
|
|
<div style={styles.iconBox('#10b981')}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="#10b981" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
|
</svg>
|
|
</div>
|
|
<h2 style={styles.sectionTitle}>API Credentials</h2>
|
|
</div>
|
|
|
|
{/* Auth Status */}
|
|
<div style={{ ...styles.infoBox, marginBottom: '24px', border: authStatus.isAuthenticated ? '1px solid rgba(34, 197, 94, 0.3)' : '1px solid rgba(239, 68, 68, 0.3)' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
|
<label style={{ ...styles.label, marginBottom: 0 }}>Token Status</label>
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
<button
|
|
onClick={handleRefreshToken}
|
|
disabled={authSaving}
|
|
style={{
|
|
padding: '6px 12px',
|
|
borderRadius: '8px',
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
|
border: 'none',
|
|
color: '#fff',
|
|
cursor: authSaving ? 'wait' : 'pointer',
|
|
opacity: authSaving ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{authSaving ? 'Refreshing...' : 'Refresh Now'}
|
|
</button>
|
|
<span style={{
|
|
padding: '4px 10px',
|
|
borderRadius: '12px',
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
background: authStatus.isAuthenticated ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.15)',
|
|
color: authStatus.isAuthenticated ? '#22c55e' : '#ef4444',
|
|
}}>
|
|
{authStatus.isAuthenticated ? 'Valid' : 'Expired / Invalid'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Current Profile ID */}
|
|
<div style={{ marginBottom: '12px' }}>
|
|
<label style={{ fontSize: '10px', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Profile ID</label>
|
|
<p style={{ color: '#9ca3af', fontSize: '11px', fontFamily: 'monospace', margin: '4px 0 0 0', wordBreak: 'break-all' }}>
|
|
{authManager.getProfileId()}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Refresh Token (managed by backend) */}
|
|
<div style={{ marginBottom: '12px' }}>
|
|
<label style={{ fontSize: '10px', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Refresh Token</label>
|
|
<p style={{ color: '#9ca3af', fontSize: '10px', fontFamily: 'monospace', margin: '4px 0 0 0', wordBreak: 'break-all', maxHeight: '60px', overflow: 'auto' }}>
|
|
Managed by backend server
|
|
</p>
|
|
</div>
|
|
|
|
{/* Access Token */}
|
|
{authStatus.accessToken && (
|
|
<div style={{ marginBottom: '12px' }}>
|
|
<label style={{ fontSize: '10px', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Access Token</label>
|
|
<p style={{ color: '#9ca3af', fontSize: '10px', fontFamily: 'monospace', margin: '4px 0 0 0', wordBreak: 'break-all', maxHeight: '80px', overflow: 'auto', background: '#1a1a24', padding: '8px', borderRadius: '6px' }}>
|
|
{authStatus.accessToken}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Expiry */}
|
|
{authStatus.expiresIn && (
|
|
<p style={{ color: '#22c55e', fontSize: '13px', margin: 0 }}>
|
|
Expires in: {Math.floor(authStatus.expiresIn / 60)}m {authStatus.expiresIn % 60}s
|
|
</p>
|
|
)}
|
|
{authStatus.lastError && (
|
|
<p style={{ color: '#ef4444', fontSize: '13px', margin: 0, marginTop: '8px' }}>
|
|
Error: {authStatus.lastError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Profile ID */}
|
|
<div style={{ marginBottom: '20px' }}>
|
|
<label style={styles.label}>Profile ID</label>
|
|
<input
|
|
type="text"
|
|
value={profileIdInput}
|
|
onChange={(e) => setProfileIdInput(e.target.value)}
|
|
placeholder="profile#xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
style={{ ...styles.input, width: '100%', fontFamily: 'monospace', fontSize: '13px' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Refresh Token */}
|
|
<div style={{ marginBottom: '20px' }}>
|
|
<label style={styles.label}>Refresh Token</label>
|
|
<textarea
|
|
value={refreshTokenInput}
|
|
onChange={(e) => setRefreshTokenInput(e.target.value)}
|
|
placeholder="AMf-vBx..."
|
|
rows={3}
|
|
style={{
|
|
...styles.input,
|
|
width: '100%',
|
|
fontFamily: 'monospace',
|
|
fontSize: '12px',
|
|
resize: 'vertical' as const,
|
|
}}
|
|
/>
|
|
<p style={{ color: '#6b7280', fontSize: '12px', marginTop: '8px' }}>
|
|
Get from Proxyman: Find a request to core.api.fldcore.com and copy the refresh token from the token refresh request body.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div style={{ display: 'flex', gap: '10px', marginBottom: '16px' }}>
|
|
<button
|
|
style={{ ...styles.button, opacity: authSaving ? 0.7 : 1, flex: 1 }}
|
|
onClick={handleSaveCredentials}
|
|
disabled={authSaving}
|
|
>
|
|
{authSaving ? 'Saving...' : 'Save & Refresh Token'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Status Message */}
|
|
{authMessage && (
|
|
<div style={{
|
|
padding: '12px 16px',
|
|
borderRadius: '10px',
|
|
background: authMessage.type === 'success' ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.15)',
|
|
border: `1px solid ${authMessage.type === 'success' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'}`,
|
|
color: authMessage.type === 'success' ? '#22c55e' : '#ef4444',
|
|
fontSize: '14px',
|
|
}}>
|
|
{authMessage.text}
|
|
</div>
|
|
)}
|
|
|
|
<p style={styles.note}>
|
|
These credentials are stored in localStorage and used for all API requests. Update them when your session expires.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Logout */}
|
|
<div style={styles.card}>
|
|
<div style={styles.cardContent}>
|
|
<div style={styles.sectionHeader}>
|
|
<div style={styles.iconBox('#ef4444')}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5" style={{ width: '24px', height: '24px' }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
|
</svg>
|
|
</div>
|
|
<h2 style={styles.sectionTitle}>Session</h2>
|
|
</div>
|
|
|
|
<button
|
|
onClick={logout}
|
|
style={{
|
|
width: '100%',
|
|
padding: '16px 24px',
|
|
background: 'rgba(239, 68, 68, 0.15)',
|
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
|
borderRadius: '12px',
|
|
color: '#ef4444',
|
|
fontSize: '16px',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
transition: 'background 0.2s',
|
|
}}
|
|
>
|
|
Sign Out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|