diff --git a/.claude/settings.local.json b/.claude/settings.local.json index afc3586..7194915 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -69,7 +69,23 @@ "Bash(ssh:*)", "Bash(sort:*)", "Bash(for f in /Users/treyt/Desktop/code/Feeld/ping_calls/profile_update_logs/request_*)", - "Bash(find:*)" + "Bash(find:*)", + "Bash(scp -o StrictHostKeyChecking=no root@10.3.3.11:/tmp/kaitlin/p1.jpg /tmp/kaitlin_p1.jpg && scp -o StrictHostKeyChecking=no root@10.3.3.11:/tmp/kaitlin/p2.jpg /tmp/kaitlin_p2.jpg && scp -o StrictHostKeyChecking=no root@10.3.3.11:/tmp/kaitlin/p3.jpg /tmp/kaitlin_p3.jpg && scp -o StrictHostKeyChecking=no root@10.3.3.11:/tmp/kaitlin/p4.jpg /tmp/kaitlin_p4.jpg && scp -o StrictHostKeyChecking=no root@10.3.3.11:/tmp/kaitlin/p5.jpg /tmp/kaitlin_p5.jpg && scp -o StrictHostKeyChecking=no root@10.3.3.11:/tmp/kaitlin/p6.jpg /tmp/kaitlin_p6.jpg)", + "mcp__playwright__browser_navigate", + "Bash(npx playwright:*)", + "Bash(mkdir -p /tmp/feeld_photos)", + "Read(//tmp/**)", + "Bash(wait)", + "Bash(xxd /tmp/proxyman_extract/request_93)", + "Bash(git remote:*)", + "Bash(git push:*)", + "mcp__playwright__browser_take_screenshot", + "mcp__playwright__browser_snapshot", + "mcp__playwright__browser_fill_form", + "mcp__playwright__browser_click", + "mcp__playwright__browser_wait_for", + "mcp__playwright__browser_evaluate", + "Bash(git add:*)" ] } } diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a80c72d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "github-webhook": { + "command": "bun", + "args": [ + "/Users/m4mini/Desktop/code/github-webhook-channel/webhook.ts" + ] + } + } +} diff --git a/api_calls/.DS_Store b/api_calls/.DS_Store new file mode 100644 index 0000000..be2aff2 Binary files /dev/null and b/api_calls/.DS_Store differ diff --git a/chat.stream-io-api.com_01-24-2026-11-11-43.proxymanlogv2 b/api_calls/chat.stream-io-api.com_01-24-2026-11-11-43 2.proxymanlogv2 similarity index 100% rename from chat.stream-io-api.com_01-24-2026-11-11-43.proxymanlogv2 rename to api_calls/chat.stream-io-api.com_01-24-2026-11-11-43 2.proxymanlogv2 diff --git a/api_calls/chat.stream-io-api.com_01-24-2026-11-11-43.proxymanlogv2 b/api_calls/chat.stream-io-api.com_01-24-2026-11-11-43.proxymanlogv2 new file mode 100644 index 0000000..8ac3028 Binary files /dev/null and b/api_calls/chat.stream-io-api.com_01-24-2026-11-11-43.proxymanlogv2 differ diff --git a/core.api.fldcore.com_01-24-2026-05-31-58.proxymanlogv2 b/api_calls/core.api.fldcore.com_01-24-2026-05-31-58 2.proxymanlogv2 similarity index 100% rename from core.api.fldcore.com_01-24-2026-05-31-58.proxymanlogv2 rename to api_calls/core.api.fldcore.com_01-24-2026-05-31-58 2.proxymanlogv2 diff --git a/api_calls/core.api.fldcore.com_01-24-2026-05-31-58.proxymanlogv2 b/api_calls/core.api.fldcore.com_01-24-2026-05-31-58.proxymanlogv2 new file mode 100644 index 0000000..4a59f93 Binary files /dev/null and b/api_calls/core.api.fldcore.com_01-24-2026-05-31-58.proxymanlogv2 differ diff --git a/core.api.fldcore.com_01-24-2026-05-50-44.proxymanlogv2 b/api_calls/core.api.fldcore.com_01-24-2026-05-50-44 2.proxymanlogv2 similarity index 100% rename from core.api.fldcore.com_01-24-2026-05-50-44.proxymanlogv2 rename to api_calls/core.api.fldcore.com_01-24-2026-05-50-44 2.proxymanlogv2 diff --git a/api_calls/core.api.fldcore.com_01-24-2026-05-50-44.proxymanlogv2 b/api_calls/core.api.fldcore.com_01-24-2026-05-50-44.proxymanlogv2 new file mode 100644 index 0000000..995a77d Binary files /dev/null and b/api_calls/core.api.fldcore.com_01-24-2026-05-50-44.proxymanlogv2 differ diff --git a/core.api.fldcore.com_01-24-2026-06-57-56.proxymanlogv2 b/api_calls/core.api.fldcore.com_01-24-2026-06-57-56 2.proxymanlogv2 similarity index 100% rename from core.api.fldcore.com_01-24-2026-06-57-56.proxymanlogv2 rename to api_calls/core.api.fldcore.com_01-24-2026-06-57-56 2.proxymanlogv2 diff --git a/api_calls/core.api.fldcore.com_01-24-2026-06-57-56.proxymanlogv2 b/api_calls/core.api.fldcore.com_01-24-2026-06-57-56.proxymanlogv2 new file mode 100644 index 0000000..0cfa511 Binary files /dev/null and b/api_calls/core.api.fldcore.com_01-24-2026-06-57-56.proxymanlogv2 differ diff --git a/core.api.fldcore.com_01-24-2026-10-36-00.proxymanlogv2 b/api_calls/core.api.fldcore.com_01-24-2026-10-36-00 2.proxymanlogv2 similarity index 100% rename from core.api.fldcore.com_01-24-2026-10-36-00.proxymanlogv2 rename to api_calls/core.api.fldcore.com_01-24-2026-10-36-00 2.proxymanlogv2 diff --git a/api_calls/core.api.fldcore.com_01-24-2026-10-36-00.proxymanlogv2 b/api_calls/core.api.fldcore.com_01-24-2026-10-36-00.proxymanlogv2 new file mode 100644 index 0000000..5b72b5b Binary files /dev/null and b/api_calls/core.api.fldcore.com_01-24-2026-10-36-00.proxymanlogv2 differ diff --git a/core.api.fldcore.com_01-24-2026-11-10-49.proxymanlogv2 b/api_calls/core.api.fldcore.com_01-24-2026-11-10-49 2.proxymanlogv2 similarity index 100% rename from core.api.fldcore.com_01-24-2026-11-10-49.proxymanlogv2 rename to api_calls/core.api.fldcore.com_01-24-2026-11-10-49 2.proxymanlogv2 diff --git a/api_calls/core.api.fldcore.com_01-24-2026-11-10-49.proxymanlogv2 b/api_calls/core.api.fldcore.com_01-24-2026-11-10-49.proxymanlogv2 new file mode 100644 index 0000000..87a5b7f Binary files /dev/null and b/api_calls/core.api.fldcore.com_01-24-2026-11-10-49.proxymanlogv2 differ diff --git a/api_calls/core.api.fldcore.com_03-19-2026-14-14-16.proxymanlogv2 b/api_calls/core.api.fldcore.com_03-19-2026-14-14-16.proxymanlogv2 new file mode 100644 index 0000000..d9f25e3 Binary files /dev/null and b/api_calls/core.api.fldcore.com_03-19-2026-14-14-16.proxymanlogv2 differ diff --git a/api_calls/core.api.fldcore.com_03-28-2026-11-26-42.proxymanlogv2 b/api_calls/core.api.fldcore.com_03-28-2026-11-26-42.proxymanlogv2 new file mode 100644 index 0000000..80a548e Binary files /dev/null and b/api_calls/core.api.fldcore.com_03-28-2026-11-26-42.proxymanlogv2 differ diff --git a/api_calls/core.api.fldcore.com_03-28-2026-20-12-57.proxymanlogv2 b/api_calls/core.api.fldcore.com_03-28-2026-20-12-57.proxymanlogv2 new file mode 100644 index 0000000..8cfda37 Binary files /dev/null and b/api_calls/core.api.fldcore.com_03-28-2026-20-12-57.proxymanlogv2 differ diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-17-13.proxymanlogv2 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-13.proxymanlogv2 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-17-13.proxymanlogv2 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-13.proxymanlogv2 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-17-13/request_138 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-13/request_138 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-17-13/request_138 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-13/request_138 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-17-40.proxymanlogv2 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-40.proxymanlogv2 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-17-40.proxymanlogv2 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-40.proxymanlogv2 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-17-40/request_136 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-40/request_136 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-17-40/request_136 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-40/request_136 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-17-52.proxymanlogv2 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-52.proxymanlogv2 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-17-52.proxymanlogv2 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-52.proxymanlogv2 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-17-52/request_134 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-52/request_134 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-17-52/request_134 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-17-52/request_134 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-18-04.proxymanlogv2 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-04.proxymanlogv2 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-18-04.proxymanlogv2 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-04.proxymanlogv2 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-18-04/request_132 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-04/request_132 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-18-04/request_132 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-04/request_132 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-18-17.proxymanlogv2 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-17.proxymanlogv2 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-18-17.proxymanlogv2 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-17.proxymanlogv2 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-18-17/request_128 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-17/request_128 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-18-17/request_128 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-17/request_128 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-18-29.proxymanlogv2 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-29.proxymanlogv2 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-18-29.proxymanlogv2 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-29.proxymanlogv2 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-18-29/request_127 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-29/request_127 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-18-29/request_127 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-18-29/request_127 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54.proxymanlogv2 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54.proxymanlogv2 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54.proxymanlogv2 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54.proxymanlogv2 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_178 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_178 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_178 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_178 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_179 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_179 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_179 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_179 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_180 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_180 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_180 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_180 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_183 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_183 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_183 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_183 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_184 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_184 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_184 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_184 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_185 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_185 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_185 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_185 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_186 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_186 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_186 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_186 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_189 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_189 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_189 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_189 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_190 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_190 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_190 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_190 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_191 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_191 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_191 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_191 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_192 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_192 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_192 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_192 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_193 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_193 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_193 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_193 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_194 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_194 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_194 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_194 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_195 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_195 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_195 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_195 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_197 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_197 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_197 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_197 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_198 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_198 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_198 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_198 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_199 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_199 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_199 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_199 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_200 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_200 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_200 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_200 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_201 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_201 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_201 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_201 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_202 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_202 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_202 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_202 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_205 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_205 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_205 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_205 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_206 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_206 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_206 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_206 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_209 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_209 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_209 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_209 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_210 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_210 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_210 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_210 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_212 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_212 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_212 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_212 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_213 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_213 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_213 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_213 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_217 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_217 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_217 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_217 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_218 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_218 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_218 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_218 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_219 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_219 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_219 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_219 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_220 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_220 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_220 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_220 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_221 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_221 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_221 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_221 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_222 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_222 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_222 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_222 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_223 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_223 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_223 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_223 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_224 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_224 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_224 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_224 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_226 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_226 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_226 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_226 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_227 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_227 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_227 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-09-53-54/request_227 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-11-31-11.proxymanlogv2 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-11-31-11.proxymanlogv2 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-11-31-11.proxymanlogv2 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-11-31-11.proxymanlogv2 diff --git a/ping_calls/core.api.fldcore.com_01-28-2026-15-14-05.proxymanlogv2 b/api_calls/ping_calls/core.api.fldcore.com_01-28-2026-15-14-05.proxymanlogv2 similarity index 100% rename from ping_calls/core.api.fldcore.com_01-28-2026-15-14-05.proxymanlogv2 rename to api_calls/ping_calls/core.api.fldcore.com_01-28-2026-15-14-05.proxymanlogv2 diff --git a/ping_calls/profile_update_logs/request_464 b/api_calls/ping_calls/profile_update_logs/request_464 similarity index 100% rename from ping_calls/profile_update_logs/request_464 rename to api_calls/ping_calls/profile_update_logs/request_464 diff --git a/ping_calls/profile_update_logs/request_465 b/api_calls/ping_calls/profile_update_logs/request_465 similarity index 100% rename from ping_calls/profile_update_logs/request_465 rename to api_calls/ping_calls/profile_update_logs/request_465 diff --git a/ping_calls/profile_update_logs/request_467 b/api_calls/ping_calls/profile_update_logs/request_467 similarity index 100% rename from ping_calls/profile_update_logs/request_467 rename to api_calls/ping_calls/profile_update_logs/request_467 diff --git a/ping_calls/profile_update_logs/request_470 b/api_calls/ping_calls/profile_update_logs/request_470 similarity index 100% rename from ping_calls/profile_update_logs/request_470 rename to api_calls/ping_calls/profile_update_logs/request_470 diff --git a/ping_calls/profile_update_logs/request_471 b/api_calls/ping_calls/profile_update_logs/request_471 similarity index 100% rename from ping_calls/profile_update_logs/request_471 rename to api_calls/ping_calls/profile_update_logs/request_471 diff --git a/ping_calls/profile_update_logs/request_473 b/api_calls/ping_calls/profile_update_logs/request_473 similarity index 100% rename from ping_calls/profile_update_logs/request_473 rename to api_calls/ping_calls/profile_update_logs/request_473 diff --git a/proxyman_chat/request_434 b/api_calls/proxyman_chat/request_434 similarity index 100% rename from proxyman_chat/request_434 rename to api_calls/proxyman_chat/request_434 diff --git a/proxyman_chat/request_435 b/api_calls/proxyman_chat/request_435 similarity index 100% rename from proxyman_chat/request_435 rename to api_calls/proxyman_chat/request_435 diff --git a/proxyman_chat/request_436 b/api_calls/proxyman_chat/request_436 similarity index 100% rename from proxyman_chat/request_436 rename to api_calls/proxyman_chat/request_436 diff --git a/proxyman_chat/request_437 b/api_calls/proxyman_chat/request_437 similarity index 100% rename from proxyman_chat/request_437 rename to api_calls/proxyman_chat/request_437 diff --git a/proxyman_chat/request_438 b/api_calls/proxyman_chat/request_438 similarity index 100% rename from proxyman_chat/request_438 rename to api_calls/proxyman_chat/request_438 diff --git a/proxyman_chat/request_439 b/api_calls/proxyman_chat/request_439 similarity index 100% rename from proxyman_chat/request_439 rename to api_calls/proxyman_chat/request_439 diff --git a/proxyman_chat/request_440 b/api_calls/proxyman_chat/request_440 similarity index 100% rename from proxyman_chat/request_440 rename to api_calls/proxyman_chat/request_440 diff --git a/proxyman_chat/request_443 b/api_calls/proxyman_chat/request_443 similarity index 100% rename from proxyman_chat/request_443 rename to api_calls/proxyman_chat/request_443 diff --git a/proxyman_chat/request_444 b/api_calls/proxyman_chat/request_444 similarity index 100% rename from proxyman_chat/request_444 rename to api_calls/proxyman_chat/request_444 diff --git a/proxyman_chat/request_445 b/api_calls/proxyman_chat/request_445 similarity index 100% rename from proxyman_chat/request_445 rename to api_calls/proxyman_chat/request_445 diff --git a/proxyman_chat/request_446 b/api_calls/proxyman_chat/request_446 similarity index 100% rename from proxyman_chat/request_446 rename to api_calls/proxyman_chat/request_446 diff --git a/proxyman_chat/request_447 b/api_calls/proxyman_chat/request_447 similarity index 100% rename from proxyman_chat/request_447 rename to api_calls/proxyman_chat/request_447 diff --git a/proxyman_chat/request_448 b/api_calls/proxyman_chat/request_448 similarity index 100% rename from proxyman_chat/request_448 rename to api_calls/proxyman_chat/request_448 diff --git a/proxyman_chat/request_449 b/api_calls/proxyman_chat/request_449 similarity index 100% rename from proxyman_chat/request_449 rename to api_calls/proxyman_chat/request_449 diff --git a/proxyman_chat/request_451 b/api_calls/proxyman_chat/request_451 similarity index 100% rename from proxyman_chat/request_451 rename to api_calls/proxyman_chat/request_451 diff --git a/proxyman_chat/request_452 b/api_calls/proxyman_chat/request_452 similarity index 100% rename from proxyman_chat/request_452 rename to api_calls/proxyman_chat/request_452 diff --git a/proxyman_chat/request_453 b/api_calls/proxyman_chat/request_453 similarity index 100% rename from proxyman_chat/request_453 rename to api_calls/proxyman_chat/request_453 diff --git a/proxyman_chat/request_454 b/api_calls/proxyman_chat/request_454 similarity index 100% rename from proxyman_chat/request_454 rename to api_calls/proxyman_chat/request_454 diff --git a/proxyman_chat/request_455 b/api_calls/proxyman_chat/request_455 similarity index 100% rename from proxyman_chat/request_455 rename to api_calls/proxyman_chat/request_455 diff --git a/proxyman_chat/request_457 b/api_calls/proxyman_chat/request_457 similarity index 100% rename from proxyman_chat/request_457 rename to api_calls/proxyman_chat/request_457 diff --git a/proxyman_chat/request_459 b/api_calls/proxyman_chat/request_459 similarity index 100% rename from proxyman_chat/request_459 rename to api_calls/proxyman_chat/request_459 diff --git a/proxyman_chat/request_460 b/api_calls/proxyman_chat/request_460 similarity index 100% rename from proxyman_chat/request_460 rename to api_calls/proxyman_chat/request_460 diff --git a/proxyman_chat/request_461 b/api_calls/proxyman_chat/request_461 similarity index 100% rename from proxyman_chat/request_461 rename to api_calls/proxyman_chat/request_461 diff --git a/proxyman_chat/request_462 b/api_calls/proxyman_chat/request_462 similarity index 100% rename from proxyman_chat/request_462 rename to api_calls/proxyman_chat/request_462 diff --git a/proxyman_chat/request_463 b/api_calls/proxyman_chat/request_463 similarity index 100% rename from proxyman_chat/request_463 rename to api_calls/proxyman_chat/request_463 diff --git a/proxyman_chat/request_464 b/api_calls/proxyman_chat/request_464 similarity index 100% rename from proxyman_chat/request_464 rename to api_calls/proxyman_chat/request_464 diff --git a/proxyman_chat/request_465 b/api_calls/proxyman_chat/request_465 similarity index 100% rename from proxyman_chat/request_465 rename to api_calls/proxyman_chat/request_465 diff --git a/proxyman_chat/request_468 b/api_calls/proxyman_chat/request_468 similarity index 100% rename from proxyman_chat/request_468 rename to api_calls/proxyman_chat/request_468 diff --git a/proxyman_chat/request_469 b/api_calls/proxyman_chat/request_469 similarity index 100% rename from proxyman_chat/request_469 rename to api_calls/proxyman_chat/request_469 diff --git a/proxyman_chat/request_470 b/api_calls/proxyman_chat/request_470 similarity index 100% rename from proxyman_chat/request_470 rename to api_calls/proxyman_chat/request_470 diff --git a/proxyman_extracted/request_175 b/api_calls/proxyman_extracted/request_175 similarity index 100% rename from proxyman_extracted/request_175 rename to api_calls/proxyman_extracted/request_175 diff --git a/proxyman_extracted/request_176 b/api_calls/proxyman_extracted/request_176 similarity index 100% rename from proxyman_extracted/request_176 rename to api_calls/proxyman_extracted/request_176 diff --git a/proxyman_extracted/request_177 b/api_calls/proxyman_extracted/request_177 similarity index 100% rename from proxyman_extracted/request_177 rename to api_calls/proxyman_extracted/request_177 diff --git a/proxyman_extracted/request_178 b/api_calls/proxyman_extracted/request_178 similarity index 100% rename from proxyman_extracted/request_178 rename to api_calls/proxyman_extracted/request_178 diff --git a/proxyman_extracted/request_179 b/api_calls/proxyman_extracted/request_179 similarity index 100% rename from proxyman_extracted/request_179 rename to api_calls/proxyman_extracted/request_179 diff --git a/proxyman_extracted/request_180 b/api_calls/proxyman_extracted/request_180 similarity index 100% rename from proxyman_extracted/request_180 rename to api_calls/proxyman_extracted/request_180 diff --git a/proxyman_extracted/request_181 b/api_calls/proxyman_extracted/request_181 similarity index 100% rename from proxyman_extracted/request_181 rename to api_calls/proxyman_extracted/request_181 diff --git a/proxyman_extracted/request_185 b/api_calls/proxyman_extracted/request_185 similarity index 100% rename from proxyman_extracted/request_185 rename to api_calls/proxyman_extracted/request_185 diff --git a/proxyman_extracted/request_186 b/api_calls/proxyman_extracted/request_186 similarity index 100% rename from proxyman_extracted/request_186 rename to api_calls/proxyman_extracted/request_186 diff --git a/proxyman_extracted/request_187 b/api_calls/proxyman_extracted/request_187 similarity index 100% rename from proxyman_extracted/request_187 rename to api_calls/proxyman_extracted/request_187 diff --git a/proxyman_extracted/request_188 b/api_calls/proxyman_extracted/request_188 similarity index 100% rename from proxyman_extracted/request_188 rename to api_calls/proxyman_extracted/request_188 diff --git a/proxyman_extracted/request_189 b/api_calls/proxyman_extracted/request_189 similarity index 100% rename from proxyman_extracted/request_189 rename to api_calls/proxyman_extracted/request_189 diff --git a/proxyman_extracted/request_190 b/api_calls/proxyman_extracted/request_190 similarity index 100% rename from proxyman_extracted/request_190 rename to api_calls/proxyman_extracted/request_190 diff --git a/proxyman_extracted/request_191 b/api_calls/proxyman_extracted/request_191 similarity index 100% rename from proxyman_extracted/request_191 rename to api_calls/proxyman_extracted/request_191 diff --git a/proxyman_extracted/request_192 b/api_calls/proxyman_extracted/request_192 similarity index 100% rename from proxyman_extracted/request_192 rename to api_calls/proxyman_extracted/request_192 diff --git a/proxyman_extracted/request_193 b/api_calls/proxyman_extracted/request_193 similarity index 100% rename from proxyman_extracted/request_193 rename to api_calls/proxyman_extracted/request_193 diff --git a/proxyman_extracted/request_194 b/api_calls/proxyman_extracted/request_194 similarity index 100% rename from proxyman_extracted/request_194 rename to api_calls/proxyman_extracted/request_194 diff --git a/proxyman_extracted/request_195 b/api_calls/proxyman_extracted/request_195 similarity index 100% rename from proxyman_extracted/request_195 rename to api_calls/proxyman_extracted/request_195 diff --git a/proxyman_extracted/request_196 b/api_calls/proxyman_extracted/request_196 similarity index 100% rename from proxyman_extracted/request_196 rename to api_calls/proxyman_extracted/request_196 diff --git a/proxyman_extracted/request_197 b/api_calls/proxyman_extracted/request_197 similarity index 100% rename from proxyman_extracted/request_197 rename to api_calls/proxyman_extracted/request_197 diff --git a/proxyman_extracted/request_198 b/api_calls/proxyman_extracted/request_198 similarity index 100% rename from proxyman_extracted/request_198 rename to api_calls/proxyman_extracted/request_198 diff --git a/proxyman_extracted/request_201 b/api_calls/proxyman_extracted/request_201 similarity index 100% rename from proxyman_extracted/request_201 rename to api_calls/proxyman_extracted/request_201 diff --git a/proxyman_extracted/request_202 b/api_calls/proxyman_extracted/request_202 similarity index 100% rename from proxyman_extracted/request_202 rename to api_calls/proxyman_extracted/request_202 diff --git a/proxyman_extracted/request_203 b/api_calls/proxyman_extracted/request_203 similarity index 100% rename from proxyman_extracted/request_203 rename to api_calls/proxyman_extracted/request_203 diff --git a/proxyman_extracted/request_204 b/api_calls/proxyman_extracted/request_204 similarity index 100% rename from proxyman_extracted/request_204 rename to api_calls/proxyman_extracted/request_204 diff --git a/proxyman_extracted/request_205 b/api_calls/proxyman_extracted/request_205 similarity index 100% rename from proxyman_extracted/request_205 rename to api_calls/proxyman_extracted/request_205 diff --git a/proxyman_extracted/request_208 b/api_calls/proxyman_extracted/request_208 similarity index 100% rename from proxyman_extracted/request_208 rename to api_calls/proxyman_extracted/request_208 diff --git a/proxyman_extracted/request_209 b/api_calls/proxyman_extracted/request_209 similarity index 100% rename from proxyman_extracted/request_209 rename to api_calls/proxyman_extracted/request_209 diff --git a/securetoken.googleapis.com_01-24-2026-05-31-21.proxymanlogv2 b/api_calls/securetoken.googleapis.com_01-24-2026-05-31-21 2.proxymanlogv2 similarity index 100% rename from securetoken.googleapis.com_01-24-2026-05-31-21.proxymanlogv2 rename to api_calls/securetoken.googleapis.com_01-24-2026-05-31-21 2.proxymanlogv2 diff --git a/api_calls/securetoken.googleapis.com_01-24-2026-05-31-21.proxymanlogv2 b/api_calls/securetoken.googleapis.com_01-24-2026-05-31-21.proxymanlogv2 new file mode 100644 index 0000000..49c5343 Binary files /dev/null and b/api_calls/securetoken.googleapis.com_01-24-2026-05-31-21.proxymanlogv2 differ diff --git a/api_calls/securetoken.googleapis.com_03-19-2026-14-13-36.proxymanlogv2 b/api_calls/securetoken.googleapis.com_03-19-2026-14-13-36.proxymanlogv2 new file mode 100644 index 0000000..b96d6d5 Binary files /dev/null and b/api_calls/securetoken.googleapis.com_03-19-2026-14-13-36.proxymanlogv2 differ diff --git a/api_calls/securetoken.googleapis.com_03-28-2026-11-27-36.proxymanlogv2 b/api_calls/securetoken.googleapis.com_03-28-2026-11-27-36.proxymanlogv2 new file mode 100644 index 0000000..874724c Binary files /dev/null and b/api_calls/securetoken.googleapis.com_03-28-2026-11-27-36.proxymanlogv2 differ diff --git a/api_calls/securetoken.googleapis.com_03-28-2026-20-12-37.proxymanlogv2 b/api_calls/securetoken.googleapis.com_03-28-2026-20-12-37.proxymanlogv2 new file mode 100644 index 0000000..14cc0df Binary files /dev/null and b/api_calls/securetoken.googleapis.com_03-28-2026-20-12-37.proxymanlogv2 differ diff --git a/api_calls/securetoken.googleapis.com_04-06-2026-09-47-45.proxymanlogv2 b/api_calls/securetoken.googleapis.com_04-06-2026-09-47-45.proxymanlogv2 new file mode 100644 index 0000000..15e6748 Binary files /dev/null and b/api_calls/securetoken.googleapis.com_04-06-2026-09-47-45.proxymanlogv2 differ diff --git a/okcupid/OkCupid-v111.2.0-(Getmodsapk.com).xapk b/okcupid/OkCupid-v111.2.0-(Getmodsapk.com).xapk new file mode 100644 index 0000000..089cc0e Binary files /dev/null and b/okcupid/OkCupid-v111.2.0-(Getmodsapk.com).xapk differ diff --git a/okcupid/e2p-okapi.api.okcupid.com_03-28-2026-23-20-17.proxymanlogv2 b/okcupid/e2p-okapi.api.okcupid.com_03-28-2026-23-20-17.proxymanlogv2 new file mode 100644 index 0000000..60a2aa4 Binary files /dev/null and b/okcupid/e2p-okapi.api.okcupid.com_03-28-2026-23-20-17.proxymanlogv2 differ diff --git a/okcupid/e2p-okapi.api.okcupid.com_03-28-2026-23-23-13.proxymanlogv2 b/okcupid/e2p-okapi.api.okcupid.com_03-28-2026-23-23-13.proxymanlogv2 new file mode 100644 index 0000000..1c557f3 Binary files /dev/null and b/okcupid/e2p-okapi.api.okcupid.com_03-28-2026-23-23-13.proxymanlogv2 differ diff --git a/okcupid/e2p-okapi.api.okcupid.com_03-29-2026-00-12-07.proxymanlogv2 b/okcupid/e2p-okapi.api.okcupid.com_03-29-2026-00-12-07.proxymanlogv2 new file mode 100644 index 0000000..5608f25 Binary files /dev/null and b/okcupid/e2p-okapi.api.okcupid.com_03-29-2026-00-12-07.proxymanlogv2 differ diff --git a/web/index.html b/web/index.html index af88f03..3748f2d 100644 --- a/web/index.html +++ b/web/index.html @@ -3,8 +3,12 @@ - - web + + + + + + Feeld
diff --git a/web/nginx.conf b/web/nginx.conf index 954aabb..3d98046 100755 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -39,6 +39,15 @@ http { proxy_set_header Host $host; } + # OKCupid API proxy (goes through backend to bypass Cloudflare) + location /api/okcupid/ { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + # Auth endpoints location /api/auth/ { proxy_pass http://backend; @@ -102,6 +111,24 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # Emulate app open endpoint + location /api/emulate-open { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Match scoring endpoints + location /api/matches { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + # Everything else (including /api/graphql, /api/firebase, etc.) # goes to Vite which handles its own proxying location / { diff --git a/web/server/index.js b/web/server/index.js index 73b1fea..0b942fa 100755 --- a/web/server/index.js +++ b/web/server/index.js @@ -4,6 +4,7 @@ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; +import { DEFAULT_WEIGHTS, scoreProfile, safeStr as scoreSafeStr } from './matchScoring.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Always use ../data (relative to server/) so Docker and local dev use the same directory @@ -395,6 +396,19 @@ app.get('/api/discovered-profiles', (req, res) => { } }); +// GET /api/discovered-profiles/lookup/:id - Get a single cached profile +app.get('/api/discovered-profiles/lookup/:id', (req, res) => { + const filePath = path.join(DATA_DIR, 'discoveredProfiles.json'); + if (!fs.existsSync(filePath)) return res.json({ profile: null }); + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const target = (data.profiles || []).find(p => p.id === req.params.id); + res.json({ profile: target || null }); + } catch (e) { + res.json({ profile: null }); + } +}); + // POST /api/discovered-profiles/batch - Batch upsert discovered profiles app.post('/api/discovered-profiles/batch', (req, res) => { const { profiles: incoming } = req.body; @@ -421,9 +435,12 @@ app.post('/api/discovered-profiles/batch', (req, res) => { if (!profile.id) continue; const existing = existingMap.get(profile.id); if (existing) { - // Update but preserve original discoveredAt + // Last-seen wins for discoveredLocation; fall back to prior value if + // this batch didn't carry one. existingMap.set(profile.id, { + ...existing, ...profile, + discoveredLocation: profile.discoveredLocation ?? existing.discoveredLocation ?? null, discoveredAt: existing.discoveredAt, updatedAt: new Date().toISOString(), }); @@ -431,6 +448,7 @@ app.post('/api/discovered-profiles/batch', (req, res) => { } else { existingMap.set(profile.id, { ...profile, + discoveredLocation: profile.discoveredLocation ?? null, discoveredAt: profile.discoveredAt || new Date().toISOString(), }); added++; @@ -479,6 +497,288 @@ app.delete('/api/discovered-profiles/:profileId', (req, res) => { } }); +// PUT /api/discovered-profiles/update-photos - Update photos for a cached profile +app.put('/api/discovered-profiles/update-photos', (req, res) => { + const { profileId, photos } = req.body; + if (!profileId || !Array.isArray(photos)) { + return res.status(400).json({ success: false, error: 'profileId and photos array required' }); + } + const filePath = path.join(DATA_DIR, 'discoveredProfiles.json'); + if (fs.existsSync(filePath)) { + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const idx = (data.profiles || []).findIndex(p => p.id === profileId); + if (idx >= 0) { + data.profiles[idx].photos = photos; + data.profiles[idx].photosRefreshedAt = new Date().toISOString(); + data.updatedAt = new Date().toISOString(); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + return res.json({ success: true, updated: true }); + } + return res.json({ success: true, updated: false, reason: 'profile not found' }); + } catch (e) { + console.error('Failed to update photos:', e); + return res.status(500).json({ success: false, error: e.message }); + } + } + res.json({ success: true, updated: false, reason: 'no cache file' }); +}); + +// ============================================================ +// Smart Matches Endpoints +// ============================================================ + +const MATCH_WEIGHTS_FILE = path.join(DATA_DIR, 'matchWeights.json'); + +function readMatchWeights() { + if (fs.existsSync(MATCH_WEIGHTS_FILE)) { + try { + return { ...DEFAULT_WEIGHTS, ...JSON.parse(fs.readFileSync(MATCH_WEIGHTS_FILE, 'utf8')) }; + } catch (e) { + console.error('Failed to read matchWeights.json:', e); + } + } + return { ...DEFAULT_WEIGHTS }; +} + +function readJsonFile(filename, fallback) { + const filePath = path.join(DATA_DIR, filename); + if (fs.existsSync(filePath)) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (e) { + console.error(`Failed to read ${filename}:`, e); + } + } + return fallback; +} + +// GET /api/matches — Score, filter, and rank discovered profiles +app.get('/api/matches', (req, res) => { + try { + const { + minAge, maxAge, maxDistance, gender, sexuality, desires, + verifiedOnly, search, theyLikedOnly, sort, + limit = '50', offset = '0', + } = req.query; + + const limitNum = Math.min(parseInt(limit) || 50, 200); + const offsetNum = parseInt(offset) || 0; + + // 1. Read discovered profiles + const discovered = readJsonFile('discoveredProfiles.json', { profiles: [] }); + let profiles = discovered.profiles || []; + + // 2. Build exclusion sets + const disliked = readJsonFile('dislikedProfiles.json', { profiles: [] }); + const dislikedIds = new Set((disliked.profiles || []).map(p => p.id)); + + const sentPings = readJsonFile('sentPings.json', { pings: [] }); + const sentPingIds = new Set((sentPings.pings || []).map(p => p.targetProfileId)); + + // Read user.json to get liked profiles + const userFiles = fs.readdirSync(DATA_DIR).filter(f => f.endsWith('.json') && !['discoveredProfiles', 'dislikedProfiles', 'sentPings', 'whoLikedYou', 'auth', 'auth-tokens', 'locationRotation', 'savedLocations', 'matchWeights'].some(n => f.startsWith(n))); + let likedIds = new Set(); + for (const file of userFiles) { + try { + const data = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), 'utf8')); + if (data.likedProfiles) { + data.likedProfiles.forEach(p => likedIds.add(p.id)); + } + } catch (e) { /* skip */ } + } + + // 3. Filter out excluded profiles + profiles = profiles.filter(p => !dislikedIds.has(p.id) && !sentPingIds.has(p.id) && !likedIds.has(p.id)); + + // 4. Apply query param filters + if (minAge) { + const min = parseInt(minAge); + profiles = profiles.filter(p => typeof p.age === 'number' && p.age >= min); + } + if (maxAge) { + const max = parseInt(maxAge); + profiles = profiles.filter(p => typeof p.age === 'number' && p.age <= max); + } + if (maxDistance) { + const maxDist = parseInt(maxDistance); + profiles = profiles.filter(p => { + const mi = p.distance?.mi; + return typeof mi === 'number' && mi <= maxDist; + }); + } + if (gender) { + const genders = gender.split(',').map(g => g.trim().toUpperCase()); + profiles = profiles.filter(p => { + const pg = (typeof p.gender === 'string' ? p.gender : '').toUpperCase(); + return genders.includes(pg); + }); + } + if (sexuality) { + const sexualities = sexuality.split(',').map(s => s.trim().toUpperCase()); + profiles = profiles.filter(p => { + const ps = (typeof p.sexuality === 'string' ? p.sexuality : '').toUpperCase(); + return sexualities.includes(ps); + }); + } + if (desires) { + const desireList = desires.split(',').map(d => d.trim().toUpperCase()); + profiles = profiles.filter(p => { + const pd = Array.isArray(p.desires) ? p.desires.map(d => (typeof d === 'string' ? d : '').toUpperCase()) : []; + return desireList.some(d => pd.includes(d)); + }); + } + if (verifiedOnly === 'true') { + profiles = profiles.filter(p => { + const vs = typeof p.verificationStatus === 'string' ? p.verificationStatus : ''; + return vs.toUpperCase() === 'VERIFIED'; + }); + } + if (theyLikedOnly === 'true') { + profiles = profiles.filter(p => p.interactionStatus?.theirs === 'LIKED'); + } + + // 5. Text search on name + bio + if (search) { + const searchLower = search.toLowerCase(); + profiles = profiles.filter(p => { + const name = (typeof p.imaginaryName === 'string' ? p.imaginaryName : '').toLowerCase(); + const bio = (typeof p.bio === 'string' ? p.bio : '').toLowerCase(); + return name.includes(searchLower) || bio.includes(searchLower); + }); + } + + // 6. Score each profile + const weights = readMatchWeights(); + const scored = profiles.map(p => { + const { total, breakdown } = scoreProfile(p, weights); + return { ...p, _score: total, _scoreBreakdown: breakdown }; + }); + + // 7. Sort + if (sort === 'distance') { + scored.sort((a, b) => (a.distance?.mi ?? 9999) - (b.distance?.mi ?? 9999)); + } else if (sort === 'recent') { + scored.sort((a, b) => { + const da = a.discoveredAt ? new Date(a.discoveredAt).getTime() : 0; + const db = b.discoveredAt ? new Date(b.discoveredAt).getTime() : 0; + return db - da; + }); + } else { + // Default: score descending + scored.sort((a, b) => b._score - a._score); + } + + // 8. Paginate + const total = scored.length; + const paginated = scored.slice(offsetNum, offsetNum + limitNum); + + res.json({ + matches: paginated, + total, + filters: { minAge, maxAge, maxDistance, gender, sexuality, desires, verifiedOnly, search, theyLikedOnly, sort }, + updatedAt: discovered.updatedAt, + }); + } catch (e) { + console.error('Failed to compute matches:', e); + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/matches/weights — Return current scoring weights +app.get('/api/matches/weights', (req, res) => { + res.json({ weights: readMatchWeights(), updatedAt: new Date().toISOString() }); +}); + +// PUT /api/matches/weights — Update scoring weights +app.put('/api/matches/weights', (req, res) => { + const { weights } = req.body; + if (!weights || typeof weights !== 'object') { + return res.status(400).json({ success: false, error: 'weights object required' }); + } + const merged = { ...DEFAULT_WEIGHTS, ...weights }; + fs.writeFileSync(MATCH_WEIGHTS_FILE, JSON.stringify(merged, null, 2)); + res.json({ success: true, weights: merged }); +}); + +// GET /api/matches/summary — Discord-friendly text summary +app.get('/api/matches/summary', (req, res) => { + try { + const { limit = '5', ...filterParams } = req.query; + + // Reuse the matches logic by building the same pipeline + const discovered = readJsonFile('discoveredProfiles.json', { profiles: [] }); + let profiles = discovered.profiles || []; + + // Exclusions + const disliked = readJsonFile('dislikedProfiles.json', { profiles: [] }); + const dislikedIds = new Set((disliked.profiles || []).map(p => p.id)); + const sentPings = readJsonFile('sentPings.json', { pings: [] }); + const sentPingIds = new Set((sentPings.pings || []).map(p => p.targetProfileId)); + profiles = profiles.filter(p => !dislikedIds.has(p.id) && !sentPingIds.has(p.id)); + + // Apply filters + if (filterParams.theyLikedOnly === 'true') { + profiles = profiles.filter(p => p.interactionStatus?.theirs === 'LIKED'); + } + if (filterParams.verifiedOnly === 'true') { + profiles = profiles.filter(p => (typeof p.verificationStatus === 'string' ? p.verificationStatus : '').toUpperCase() === 'VERIFIED'); + } + if (filterParams.maxAge) { + profiles = profiles.filter(p => typeof p.age === 'number' && p.age <= parseInt(filterParams.maxAge)); + } + if (filterParams.minAge) { + profiles = profiles.filter(p => typeof p.age === 'number' && p.age >= parseInt(filterParams.minAge)); + } + if (filterParams.maxDistance) { + profiles = profiles.filter(p => typeof p.distance?.mi === 'number' && p.distance.mi <= parseInt(filterParams.maxDistance)); + } + + // Score & sort + const weights = readMatchWeights(); + const scored = profiles.map(p => ({ + ...p, + _score: scoreProfile(p, weights).total, + })); + scored.sort((a, b) => b._score - a._score); + + const topN = scored.slice(0, parseInt(limit) || 5); + const total = scored.length; + + // Build summary text + const lines = topN.map((p, i) => { + const name = typeof p.imaginaryName === 'string' ? p.imaginaryName : 'Unknown'; + const age = p.age || '?'; + const dist = p.distance?.mi != null ? `${Math.round(p.distance.mi)}mi` : '?mi'; + const verified = (typeof p.verificationStatus === 'string' && p.verificationStatus.toUpperCase() === 'VERIFIED') ? 'Verified' : ''; + const liked = p.interactionStatus?.theirs === 'LIKED' ? 'they liked you' : ''; + const tags = [verified, liked, dist].filter(Boolean).join(', '); + return `${i + 1}. ${name}, ${age} (Score: ${p._score}) — ${tags}`; + }); + + const summary = topN.length > 0 + ? `Top ${topN.length} Matches:\n${lines.join('\n')}` + : 'No matches found with current filters.'; + + res.json({ + summary, + matches: topN.map(p => ({ + id: p.id, + imaginaryName: typeof p.imaginaryName === 'string' ? p.imaginaryName : '', + age: p.age, + score: p._score, + distance: p.distance, + verified: (typeof p.verificationStatus === 'string' && p.verificationStatus.toUpperCase() === 'VERIFIED'), + theyLikedYou: p.interactionStatus?.theirs === 'LIKED', + })), + total, + }); + } catch (e) { + console.error('Failed to compute matches summary:', e); + res.status(500).json({ error: e.message }); + } +}); + // Health check app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); @@ -522,6 +822,357 @@ app.post('/api/auth/logout', (req, res) => { res.json({ success: true }); }); +// GET /api/auth/token — Single source of truth: backend manages the refresh token, +// frontend just gets a ready-to-use access token. No more race conditions. +app.get('/api/auth/token', async (req, res) => { + try { + // Ensure feeldAPI has credentials loaded + if (!feeldAPI.refreshToken) { + feeldAPI.loadCredentials(); + } + if (!feeldAPI.refreshToken) { + return res.status(503).json({ error: 'No refresh token available. Seed from mobile app first.' }); + } + const accessToken = await feeldAPI.getToken(); + res.json({ + accessToken, + profileId: feeldAPI.profileId, + analyticsId: feeldAPI.analyticsId, + expiresAt: feeldAPI.expiresAt, + }); + } catch (e) { + console.error('[Auth] Token request failed:', e.message); + res.status(500).json({ error: e.message }); + } +}); + +// POST /api/auth/seed — Seed a refresh token (from mobile app Proxyman capture, etc.) +app.post('/api/auth/seed', (req, res) => { + const { refreshToken, profileId, analyticsId } = req.body; + if (!refreshToken) return res.status(400).json({ error: 'refreshToken required' }); + feeldAPI.saveCredentials( + profileId || feeldAPI.profileId, + refreshToken, + analyticsId || feeldAPI.analyticsId + ); + // Clear cached access token so next request gets a fresh one + feeldAPI.accessToken = null; + feeldAPI.expiresAt = 0; + res.json({ success: true }); +}); + +// ============================================================ +// Emulate App Open — replicate the mobile app's launch sequence +// ============================================================ + +app.post('/api/emulate-open', async (req, res) => { + const { latitude, longitude, locationName } = req.body; + if (latitude == null || longitude == null) { + return res.status(400).json({ error: 'latitude and longitude required' }); + } + + if (!feeldAPI.profileId) { + feeldAPI.loadCredentials(); + if (!feeldAPI.profileId) { + return res.status(503).json({ error: 'No Feeld credentials. Seed from browser first.' }); + } + } + + const steps = []; + + try { + // Step 1: Set device location (mimics GPS update on app open) + await feeldAPI.updateLocation(latitude, longitude); + steps.push({ step: 'DeviceLocationUpdate', status: 'ok', location: locationName || `${latitude},${longitude}` }); + + // Step 2: Fetch search settings + let filters = { + ageRange: [22, 59], + maxDistance: 100, + lookingFor: ['WOMAN', 'MAN_WOMAN_COUPLE', 'WOMAN_WOMAN_COUPLE'], + recentlyOnline: false, + desiringFor: [], + }; + + try { + const settings = await feeldAPI.getSearchSettings(); + const profile = settings?.profile; + if (profile) { + filters = { + ageRange: profile.ageRange || filters.ageRange, + maxDistance: profile.distanceMax || filters.maxDistance, + lookingFor: profile.lookingFor || filters.lookingFor, + recentlyOnline: profile.recentlyOnline || false, + desiringFor: profile.desiringFor || [], + }; + } + steps.push({ step: 'SearchSettings', status: 'ok', filters }); + } catch (e) { + steps.push({ step: 'SearchSettings', status: 'fallback', error: e.message }); + } + + // Step 3: Discover profiles at this location + const discovery = await feeldAPI.discoverProfiles(filters); + const profiles = discovery?.discovery?.nodes || []; + steps.push({ step: 'DiscoverProfiles', status: 'ok', count: profiles.length, hasNextBatch: discovery?.discovery?.hasNextBatch }); + + // Step 4: Cache discovered profiles and detect who liked us + let newProfiles = 0; + let likedMeFound = 0; + if (profiles.length > 0) { + const discoveredFile = path.join(DATA_DIR, 'discoveredProfiles.json'); + let existing = { profiles: [], updatedAt: null }; + try { existing = JSON.parse(fs.readFileSync(discoveredFile, 'utf8')); } catch {} + const existingMap = new Map(existing.profiles.map(p => [p.id, p])); + + for (const p of profiles) { + const safeStr = v => (typeof v === 'string' ? v : ''); + const cached = { + id: p.id, + imaginaryName: safeStr(p.imaginaryName), + age: p.age, + gender: safeStr(p.gender), + sexuality: safeStr(p.sexuality), + bio: safeStr(p.bio), + desires: Array.isArray(p.desires) ? p.desires.filter(d => typeof d === 'string') : [], + connectionGoals: Array.isArray(p.connectionGoals) ? p.connectionGoals.filter(g => typeof g === 'string') : [], + verificationStatus: safeStr(p.verificationStatus), + interactionStatus: p.interactionStatus, + discoveredLocation: locationName || `${latitude},${longitude}`, + discoveredAt: existingMap.has(p.id) ? existingMap.get(p.id).discoveredAt : new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + if (!existingMap.has(p.id)) newProfiles++; + existingMap.set(p.id, cached); + + // Detect who liked us + if (p.interactionStatus?.theirs === 'LIKED') { + likedMeFound++; + // Save to whoLikedYou cache + const whoFile = path.join(DATA_DIR, 'whoLikedYou.json'); + let whoData = { profiles: [], updatedAt: null }; + try { whoData = JSON.parse(fs.readFileSync(whoFile, 'utf8')); } catch {} + if (!whoData.profiles.some(w => w.id === p.id)) { + whoData.profiles.push({ + id: p.id, + imaginaryName: safeStr(p.imaginaryName), + age: p.age, + gender: safeStr(p.gender), + sexuality: safeStr(p.sexuality), + photos: p.photos, + discoveredAt: new Date().toISOString(), + }); + whoData.updatedAt = new Date().toISOString(); + fs.writeFileSync(whoFile, JSON.stringify(whoData, null, 2)); + } + } + } + + existing.profiles = Array.from(existingMap.values()); + existing.updatedAt = new Date().toISOString(); + fs.writeFileSync(discoveredFile, JSON.stringify(existing, null, 2)); + console.log(`Discovered profiles batch: +${newProfiles} new, ${profiles.length} updated, ${existing.profiles.length} total`); + } + + res.json({ + success: true, + location: locationName || `${latitude},${longitude}`, + steps, + summary: { + profilesFound: profiles.length, + newProfiles, + likedMeFound, + }, + }); + } catch (e) { + console.error('[EmulateOpen] Error:', e.message); + res.status(500).json({ error: e.message, steps }); + } +}); + +// ============================================================ +// OKCupid API Proxy — server-side fetch to bypass Cloudflare +// ============================================================ + +const OKC_TOKEN_FILE = path.join(DATA_DIR, 'okc-token.json'); +const OKC_CREDS_FILE = path.join(DATA_DIR, 'okc-credentials.json'); + +const OKC_HEADERS = { + 'Content-Type': 'application/json', + 'User-Agent': 'OkCupid/111.1.0 iOS/26.2.1', + 'x-okcupid-locale': 'en', + 'x-okcupid-platform': 'ios', + 'x-okcupid-auth-v': '1', + 'x-okcupid-version': '111.1.0', + 'x-okcupid-device-id': '40022B89-7089-4969-85CC-94843116EEE9', + 'apollographql-client-name': 'com.okcupid.app-apollo-ios', + 'apollographql-client-version': '111.1.0-1625', + 'Accept': 'application/json', +}; + +// OKC token is valid for 45 min. We login ONCE and refresh proactively before expiry. +// NEVER login reactively in response to a failed request. +let _okcLoginInProgress = null; + +async function okcLogin() { + // Deduplicate: if a login is already in progress, wait for it + if (_okcLoginInProgress) return _okcLoginInProgress; + _okcLoginInProgress = _doOkcLogin(); + const result = await _okcLoginInProgress; + _okcLoginInProgress = null; + return result; +} + +async function _doOkcLogin() { + if (!fs.existsSync(OKC_CREDS_FILE)) return false; + try { + const creds = JSON.parse(fs.readFileSync(OKC_CREDS_FILE, 'utf8')); + if (!creds.email || !creds.password) return false; + + // Step 1: Anonymous token + const anonResp = await fetch('https://e2p-okapi.api.okcupid.com/graphql/AnonAuthToken', { + method: 'POST', + headers: OKC_HEADERS, + body: JSON.stringify({ + operationName: 'AnonAuthToken', + query: 'mutation AnonAuthToken($input: AuthAnonymousInput!) { authAnonymous(input: $input) { token } }', + variables: { input: { deviceId: '40022B89-7089-4969-85CC-94843116EEE9', siteCode: 36 } }, + extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' } }, + }), + }); + const anonData = await anonResp.json(); + let anonToken = anonData?.data?.authAnonymous?.token; + if (!anonToken) { + console.error('[OKC] Anon auth failed:', JSON.stringify(anonData).substring(0, 200)); + return false; + } + anonToken = anonToken.replace(/^Bearer\s+/i, ''); + + // Step 2: Login with anon token + const loginResp = await fetch('https://e2p-okapi.api.okcupid.com/graphql/AuthLogin', { + method: 'POST', + headers: { ...OKC_HEADERS, 'Authorization': 'Bearer ' + anonToken }, + body: JSON.stringify({ + operationName: 'AuthLogin', + query: 'mutation AuthLogin($input: AuthEmailLoginInput!) { authEmailLogin(input: $input) { token encryptedUserId status } }', + variables: { input: { email: creds.email, password: creds.password } }, + extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' } }, + }), + }); + const loginData = await loginResp.json(); + const newToken = loginData?.data?.authEmailLogin?.token; + if (newToken) { + const clean = newToken.replace(/^Bearer\s+/i, ''); + fs.writeFileSync(OKC_TOKEN_FILE, JSON.stringify({ token: clean, updatedAt: new Date().toISOString() }, null, 2)); + console.log('[OKC] Token refreshed, valid for 45 min'); + return true; + } + console.error('[OKC] Login failed (status ' + loginData?.data?.authEmailLogin?.status + ')'); + return false; + } catch (e) { + console.error('[OKC] Login error:', e.message); + return false; + } +} + +function getOkcToken() { + if (fs.existsSync(OKC_TOKEN_FILE)) { + try { + const token = JSON.parse(fs.readFileSync(OKC_TOKEN_FILE, 'utf8')).token; + return token ? token.replace(/^Bearer\s+/i, '') : null; + } catch (e) {} + } + return null; +} + +function getOkcTokenExpiry() { + const token = getOkcToken(); + if (!token) return 0; + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); + return payload.exp * 1000; + } catch (e) { return 0; } +} + +// Proactive refresh: check every 5 min, login if < 10 min remaining +setInterval(async () => { + const expiry = getOkcTokenExpiry(); + if (!expiry) return; + const remaining = expiry - Date.now(); + if (remaining < 10 * 60 * 1000 && remaining > -5 * 60 * 1000) { + // Between 10 min before expiry and 5 min after — refresh + console.log('[OKC] Proactive refresh: token expires in', Math.round(remaining / 60000), 'min'); + await okcLogin(); + } +}, 5 * 60 * 1000); + +// Login on startup if token is expired or missing +(async () => { + const expiry = getOkcTokenExpiry(); + if (!expiry || Date.now() > expiry) { + console.log('[OKC] Token expired or missing on startup, logging in...'); + await okcLogin(); + } else { + console.log('[OKC] Token valid, expires in', Math.round((expiry - Date.now()) / 60000), 'min'); + } +})(); + +// POST /api/okcupid/graphql/:operation — Proxy GraphQL requests to OKCupid +// NO reactive login — just forward the request with current token +app.post('/api/okcupid/graphql/:operation', async (req, res) => { + const token = getOkcToken(); + if (!token) { + return res.status(401).json({ error: 'No OKCupid token. Login not configured.' }); + } + + const operation = req.params.operation; + // Log vote operations for debugging + if (operation === 'UserVote') { + console.log('[OKC] UserVote request:', JSON.stringify(req.body?.variables || {}).substring(0, 500)); + } + try { + const response = await fetch(`https://e2p-okapi.api.okcupid.com/graphql/${operation}`, { + method: 'POST', + headers: { + ...OKC_HEADERS, + 'Authorization': `Bearer ${token}`, + 'x-match-useragent': 'OkCupid/111.1.0 iOS/26.2.1', + 'X-APOLLO-OPERATION-TYPE': req.body?.query?.trim().startsWith('mutation') ? 'mutation' : 'query', + 'X-APOLLO-OPERATION-NAME': operation, + 'Accept-Language': 'en-US,en;q=0.9', + 'Connection': 'keep-alive', + }, + body: JSON.stringify({ + ...req.body, + extensions: { clientLibrary: { name: 'apollo-ios', version: '1.23.0' }, ...req.body.extensions }, + }), + }); + + const data = await response.text(); + if (operation === 'UserVote') { + console.log('[OKC] UserVote response:', data.substring(0, 300)); + } + res.status(response.status).type('application/json').send(data); + } catch (e) { + console.error('[OKC Proxy] Error:', e.message); + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/okcupid/token — Get stored OKC token +app.get('/api/okcupid/token', (req, res) => { + const token = getOkcToken(); + res.json({ token: token ? token.substring(0, 20) + '...' : null, hasToken: !!token }); +}); + +// PUT /api/okcupid/token — Save OKC token +app.put('/api/okcupid/token', (req, res) => { + const { token } = req.body; + if (!token) return res.status(400).json({ error: 'token required' }); + fs.writeFileSync(OKC_TOKEN_FILE, JSON.stringify({ token, updatedAt: new Date().toISOString() }, null, 2)); + res.json({ success: true }); +}); + // ============================================================ // FeeldAPI Client — makes direct GraphQL calls to Feeld backend // ============================================================ @@ -529,8 +1180,8 @@ app.post('/api/auth/logout', (req, res) => { const FIREBASE_API_KEY = 'AIzaSyD9o9mzulN50-hqOwF6ww9pxUNUxwVOCXA'; const FIREBASE_REFRESH_URL = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`; const GRAPHQL_ENDPOINT = 'https://core.api.fldcore.com/graphql'; -const APP_VERSION = '8.8.3'; -const OS_VERSION = '18.6.2'; +const APP_VERSION = '8.11.0'; +const OS_VERSION = '26.2.1'; const AUTH_TOKENS_FILE = path.join(DATA_DIR, 'auth-tokens.json'); const ROTATION_STATE_FILE = path.join(DATA_DIR, 'locationRotation.json'); const SAVED_LOCATIONS_FILE = path.join(DATA_DIR, 'savedLocations.json'); @@ -613,7 +1264,7 @@ class FeeldAPIClient { return this.accessToken; } - async graphql(operationName, query, variables = {}) { + async graphql(operationName, query, variables = {}, _retried = false) { const token = await this.getToken(); const transactionId = crypto.randomUUID(); @@ -640,6 +1291,21 @@ class FeeldAPIClient { } const result = await response.json(); + + // Auto-retry on UNAUTHENTICATED: force-refresh token and replay + if (result.errors && !_retried) { + const isAuthError = result.errors.some(e => e.extensions?.code === 'UNAUTHENTICATED'); + if (isAuthError) { + console.log(`[FeeldAPI] Auth error on ${operationName}, force-refreshing token...`); + this.accessToken = null; + this.expiresAt = 0; + // Re-read credentials from disk in case another process updated them + this.loadCredentials(); + await this.refreshAccessToken(); + return this.graphql(operationName, query, variables, true); + } + } + if (result.errors) { throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`); } @@ -876,6 +1542,7 @@ async function performRotation() { const existingMap = new Map(discoveredData.profiles.map(p => [p.id, p])); for (const p of profiles) { const sanitized = sanitizeProfile(p); + sanitized.discoveredLocation = loc.name; const existing = existingMap.get(p.id); if (existing) { existingMap.set(p.id, { ...sanitized, discoveredAt: existing.discoveredAt, updatedAt: new Date().toISOString() }); diff --git a/web/server/matchScoring.js b/web/server/matchScoring.js new file mode 100644 index 0000000..becfdd4 --- /dev/null +++ b/web/server/matchScoring.js @@ -0,0 +1,142 @@ +// Match scoring engine for discovered profiles + +const DEFAULT_WEIGHTS = { + verification: 15, + photoBase: 2, // per photo (max 6) + photoVerified: 3, // per verified photo + bioLong: 15, // >200 chars + bioMedium: 10, // >100 chars + bioShort: 5, // >30 chars + desiresMany: 8, // >=5 desires + desiresSome: 5, // >=3 desires + connectionGoals: 5, // has any + distanceClose: 15, // <=15mi + distanceMedium: 10, // <=30mi + distanceFar: 5, // <=50mi + ageSweetSpot: 15, // 24-40 (preferred) + ageOk: 5, // 21-45 + ageOutOfRange: -10, // outside 21-45 penalty + theyLikedYou: 30, // interactionStatus.theirs === 'LIKED' + connectionDesires: 5, // >=2 of CONNECTION/COMMUNICATION/FWB/INTIMACY/RELATIONSHIP +}; + +const CONNECTION_DESIRE_KEYWORDS = [ + 'CONNECTION', 'COMMUNICATION', 'FWB', 'INTIMACY', 'RELATIONSHIP', + 'connection', 'communication', 'fwb', 'intimacy', 'relationship', + 'Friends with benefits', 'Long-term relationship', 'Short-term relationship', +]; + +function safeStr(v) { + return typeof v === 'string' ? v : ''; +} + +function scoreProfile(profile, weights = DEFAULT_WEIGHTS) { + const breakdown = {}; + let total = 0; + + // Verification + const verStatus = safeStr(profile.verificationStatus); + if (verStatus === 'VERIFIED' || verStatus === 'verified') { + breakdown.verification = weights.verification; + total += weights.verification; + } + + // Photos + const photos = Array.isArray(profile.photos) ? profile.photos : []; + const photoCount = Math.min(photos.length, 6); + if (photoCount > 0) { + const photoScore = photoCount * weights.photoBase; + breakdown.photos = photoScore; + total += photoScore; + } + + // Verified photos (pictureType === 'VERIFIED' or similar) + const verifiedPhotos = photos.filter(p => + p.pictureType === 'VERIFIED' || p.pictureType === 'verified' + ).length; + if (verifiedPhotos > 0) { + const vpScore = verifiedPhotos * weights.photoVerified; + breakdown.verifiedPhotos = vpScore; + total += vpScore; + } + + // Bio quality + const bio = safeStr(profile.bio); + if (bio.length > 200) { + breakdown.bio = weights.bioLong; + total += weights.bioLong; + } else if (bio.length > 100) { + breakdown.bio = weights.bioMedium; + total += weights.bioMedium; + } else if (bio.length > 30) { + breakdown.bio = weights.bioShort; + total += weights.bioShort; + } + + // Desires + const desires = Array.isArray(profile.desires) ? profile.desires : []; + if (desires.length >= 5) { + breakdown.desires = weights.desiresMany; + total += weights.desiresMany; + } else if (desires.length >= 3) { + breakdown.desires = weights.desiresSome; + total += weights.desiresSome; + } + + // Connection goals + const goals = Array.isArray(profile.connectionGoals) ? profile.connectionGoals : []; + if (goals.length > 0) { + breakdown.connectionGoals = weights.connectionGoals; + total += weights.connectionGoals; + } + + // Distance + const distMi = profile.distance?.mi; + if (typeof distMi === 'number') { + if (distMi <= 15) { + breakdown.distance = weights.distanceClose; + total += weights.distanceClose; + } else if (distMi <= 30) { + breakdown.distance = weights.distanceMedium; + total += weights.distanceMedium; + } else if (distMi <= 50) { + breakdown.distance = weights.distanceFar; + total += weights.distanceFar; + } + } + + // Age preference: 24-40 sweet spot, 21-45 ok, outside penalized + const age = profile.age; + if (typeof age === 'number') { + if (age >= 24 && age <= 40) { + breakdown.age = weights.ageSweetSpot; + total += weights.ageSweetSpot; + } else if (age >= 21 && age <= 45) { + breakdown.age = weights.ageOk; + total += weights.ageOk; + } else { + breakdown.age = weights.ageOutOfRange; + total += weights.ageOutOfRange; + } + } + + // They liked you + if (profile.interactionStatus?.theirs === 'LIKED') { + breakdown.theyLikedYou = weights.theyLikedYou; + total += weights.theyLikedYou; + } + + // Connection desires (check desires array for relationship-oriented ones) + const desireStrings = desires.map(d => typeof d === 'string' ? d : ''); + const matchingDesires = desireStrings.filter(d => + CONNECTION_DESIRE_KEYWORDS.some(kw => d.toUpperCase().includes(kw.toUpperCase())) + ); + if (matchingDesires.length >= 2) { + breakdown.connectionDesires = weights.connectionDesires; + total += weights.connectionDesires; + } + + return { total, breakdown }; +} + +export { DEFAULT_WEIGHTS, scoreProfile, safeStr }; diff --git a/web/src/App.tsx b/web/src/App.tsx index 4a81973..e6e4439 100755 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,11 +13,20 @@ import { ChatPage } from './pages/Chat'; import { ProfilePage } from './pages/Profile'; import { SettingsPage } from './pages/Settings'; import { SentPingsPage } from './pages/SentPings'; +import { MatchesPage } from './pages/Matches'; import { ApiExplorerPage } from './pages/ApiExplorer'; +import { OkCupidPage } from './pages/OkCupid'; import { useEffect, useState, useRef } from 'react'; import { initialSync } from './api/dataSync'; import { authManager } from './api/auth'; +// One-time credential reset (v2) — remove this block after it runs once +if (!localStorage.getItem('_cred_reset_v2')) { + localStorage.removeItem('feeld_refresh_token'); + localStorage.removeItem('feeld_auth_token'); + localStorage.setItem('_cred_reset_v2', '1'); +} + // Prevent browser tab discarding by keeping minimal activity function usePreventTabDiscard() { const intervalRef = useRef(null); @@ -148,6 +157,8 @@ function AuthenticatedApp() { } /> } /> } /> + } /> + } /> } /> diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 399ff00..19cb3ca 100755 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -1,20 +1,10 @@ -import { API_CONFIG, getCredentials } from '../config/constants'; - -interface TokenResponse { - access_token: string; - expires_in: string; - token_type: string; - refresh_token: string; - id_token: string; - user_id: string; - project_id: string; -} +import { getCredentials } from '../config/constants'; export interface AuthStatus { isAuthenticated: boolean; expiresAt: number | null; - expiresIn: number | null; // seconds until expiry - accessToken: string | null; // full token + expiresIn: number | null; + accessToken: string | null; lastError: string | null; } @@ -25,18 +15,15 @@ class AuthManager { private listeners: Set<() => void> = new Set(); private initPromise: Promise | null = null; private isReady: boolean = false; + private profileId: string | null = null; + private analyticsId: string | null = null; - // Ensure token is ready before any queries async ensureReady(): Promise { - if (this.isReady && this.accessToken) { - return true; - } + if (this.isReady && this.accessToken) return true; if (!this.initPromise) { this.initPromise = this.refresh() - .then(() => { - this.isReady = true; - }) + .then(() => { this.isReady = true; }) .catch((err) => { console.error('Initial token fetch failed:', err); this.isReady = false; @@ -56,51 +43,22 @@ class AuthManager { } private async refresh(): Promise { - const creds = getCredentials(); - const url = `${API_CONFIG.FIREBASE_TOKEN_URL}?key=${API_CONFIG.FIREBASE_API_KEY}`; - console.log('Refreshing token at:', url); - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - grantType: 'refresh_token', - refreshToken: creds.REFRESH_TOKEN, - }), - }); + // Ask the backend for a fresh access token — it manages the refresh token + const resp = await fetch('/api/auth/token'); - if (!response.ok) { - const error = await response.text(); - console.error('Token refresh failed:', response.status, error); - this.lastError = `HTTP ${response.status}: ${error}`; - this.notifyListeners(); - throw new Error(`Failed to refresh token: ${error}`); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${resp.status}`); } - const data: TokenResponse = await response.json(); - this.accessToken = data.access_token; - this.expiresAt = Date.now() + parseInt(data.expires_in) * 1000; + const data = await resp.json(); + this.accessToken = data.accessToken; + this.expiresAt = data.expiresAt || (Date.now() + 3500000); // ~58 min fallback + this.profileId = data.profileId || null; + this.analyticsId = data.analyticsId || null; this.lastError = null; - console.log('Token refreshed, expires in:', data.expires_in, 'seconds'); - - // Seed the backend with updated refresh token for rotation cron - try { - const creds = getCredentials(); - fetch('/api/location-rotation/seed-token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - refreshToken: creds.REFRESH_TOKEN, - profileId: creds.PROFILE_ID, - analyticsId: creds.EVENT_ANALYTICS_ID, - }), - }).catch(() => {}); // Best-effort, don't block auth flow - } catch (e) {} - this.notifyListeners(); } catch (err) { this.lastError = err instanceof Error ? err.message : 'Unknown error'; @@ -109,15 +67,19 @@ class AuthManager { } } - // Force a token refresh (useful after updating credentials) async forceRefresh(): Promise { this.accessToken = null; this.expiresAt = 0; + this.initPromise = null; await this.refresh(); } getProfileId(): string { - return getCredentials().PROFILE_ID; + return this.profileId || getCredentials().PROFILE_ID; + } + + getAnalyticsId(): string { + return this.analyticsId || getCredentials().EVENT_ANALYTICS_ID; } isAuthenticated(): boolean { @@ -127,7 +89,6 @@ class AuthManager { getStatus(): AuthStatus { const now = Date.now(); const expiresIn = this.expiresAt > 0 ? Math.floor((this.expiresAt - now) / 1000) : null; - return { isAuthenticated: this.isAuthenticated(), expiresAt: this.expiresAt > 0 ? this.expiresAt : null, @@ -137,7 +98,6 @@ class AuthManager { }; } - // Subscribe to auth status changes subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9fe67d9..3aa2fd3 100755 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,14 +1,13 @@ -import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink } from '@apollo/client/core'; +import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink, Observable } from '@apollo/client/core'; +import { onError } from '@apollo/client/link/error'; import { setContext } from '@apollo/client/link/context'; -import { API_CONFIG, REQUEST_HEADERS, TEST_CREDENTIALS } from '../config/constants'; +import { API_CONFIG, REQUEST_HEADERS } from '../config/constants'; import { authManager } from './auth'; -// UUID generator that works in non-secure contexts (HTTP) function generateUUID(): string { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } - // Fallback for non-secure contexts return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; @@ -20,47 +19,93 @@ const httpLink = createHttpLink({ uri: API_CONFIG.GRAPHQL_ENDPOINT, }); -// Auth link that adds exact headers from Proxyman capture const authLink = setContext(async (operation, { headers }) => { - console.log('GraphQL operation:', operation.operationName); - let token: string; try { token = await authManager.getToken(); - console.log('Got auth token, length:', token?.length); } catch (err) { console.error('Failed to get auth token:', err); throw err; } - // Exact headers from Proxyman capture - const newHeaders = { - ...headers, - // From Proxyman: Host header (handled by browser) - 'Accept': REQUEST_HEADERS['Accept'], - 'Accept-Language': REQUEST_HEADERS['Accept-Language'], - 'Accept-Encoding': REQUEST_HEADERS['Accept-Encoding'], - 'Content-Type': REQUEST_HEADERS['Content-Type'], - 'Connection': REQUEST_HEADERS['Connection'], - 'User-Agent': REQUEST_HEADERS['User-Agent'], - // Auth headers - 'Authorization': `Bearer ${token}`, - 'x-profile-id': TEST_CREDENTIALS.PROFILE_ID, - // App identification headers - exact from Proxyman - 'x-device-os': REQUEST_HEADERS['x-device-os'], - 'x-app-version': REQUEST_HEADERS['x-app-version'], - 'x-os-version': REQUEST_HEADERS['x-os-version'], - // Transaction headers - 'x-transaction-id': generateUUID(), - 'x-event-analytics-id': TEST_CREDENTIALS.EVENT_ANALYTICS_ID, + return { + headers: { + ...headers, + 'Accept': REQUEST_HEADERS['Accept'], + 'Accept-Language': REQUEST_HEADERS['Accept-Language'], + 'Accept-Encoding': REQUEST_HEADERS['Accept-Encoding'], + 'Content-Type': REQUEST_HEADERS['Content-Type'], + 'Connection': REQUEST_HEADERS['Connection'], + 'User-Agent': REQUEST_HEADERS['User-Agent'], + 'Authorization': `Bearer ${token}`, + 'x-profile-id': authManager.getProfileId(), + 'x-device-os': REQUEST_HEADERS['x-device-os'], + 'x-app-version': REQUEST_HEADERS['x-app-version'], + 'x-os-version': REQUEST_HEADERS['x-os-version'], + 'x-transaction-id': generateUUID(), + 'x-event-analytics-id': authManager.getAnalyticsId(), + }, }; +}); - console.log('Request headers:', Object.keys(newHeaders)); - return { headers: newHeaders }; +// Auto-retry on UNAUTHENTICATED errors: force-refresh token and replay the request +let isRefreshing = false; +let pendingRetries: Array<() => void> = []; + +const errorLink = onError(({ graphQLErrors, operation, forward }) => { + const isAuthError = graphQLErrors?.some( + (e) => e.extensions?.code === 'UNAUTHENTICATED' + ); + + if (!isAuthError) return; + + if (isRefreshing) { + // Another refresh is in progress — queue this request to retry after + return new Observable((observer) => { + pendingRetries.push(() => { + const subscriber = forward(operation).subscribe(observer); + return () => subscriber.unsubscribe(); + }); + }); + } + + isRefreshing = true; + + return new Observable((observer) => { + authManager + .forceRefresh() + .then(async () => { + // Update this operation's headers with the new token + const token = await authManager.getToken(); + const oldHeaders = operation.getContext().headers; + operation.setContext({ + headers: { + ...oldHeaders, + 'Authorization': `Bearer ${token}`, + 'x-transaction-id': generateUUID(), + }, + }); + + isRefreshing = false; + + // Retry all queued requests + pendingRetries.forEach((cb) => cb()); + pendingRetries = []; + + // Retry this request + const subscriber = forward(operation).subscribe(observer); + return () => subscriber.unsubscribe(); + }) + .catch((err) => { + isRefreshing = false; + pendingRetries = []; + observer.error(err); + }); + }); }); export const apolloClient = new ApolloClient({ - link: ApolloLink.from([authLink, httpLink]), + link: ApolloLink.from([errorLink, authLink, httpLink]), cache: new InMemoryCache({ typePolicies: { Profile: { diff --git a/web/src/api/okcupid.ts b/web/src/api/okcupid.ts new file mode 100644 index 0000000..c01b84c --- /dev/null +++ b/web/src/api/okcupid.ts @@ -0,0 +1,251 @@ +// OKCupid API integration +// Uses JWT bearer token auth, proxied through Vite to bypass Cloudflare + +const DEFAULT_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJjb3JlYXBpIiwiYXVkIjoiY29yZWFwaSIsInBsYXRmb3JtSWQiOjExMSwic2Vzc2lvbklkIjoiZjYwYjY3NTAtZTRkMC00ZmViLTg2MzEtNDk3OTVkMWViN2Y5Iiwic2l0ZUNvZGUiOjM2LCJTZXJ2ZXJJZCI6NzksInZlciI6MTIsImlzc1NyYyI6MTAsImVudiI6MSwic2NvcGUiOlsyXSwiYXV0aF90aW1lIjoxNzU3MzM0NTk4LCJpYXQiOjE3NzQ3NTc3NzgsImV4cCI6MTc3NDc2MDQ3OCwic3ViIjoidXZNbGtiYXF6N0Q3VFNqSG91YlE1ZzIiLCJ1cmxDb2RlIjoiMTg2IiwicmVnVXJsQ29kZSI6IjE4NiJ9.X1RwfV7A8aDq6gVXr9IVLIFTQwtlyiQRogWKJwB1Wqc'; + +export function getOkcToken(): string { + return localStorage.getItem('okc_token') || DEFAULT_TOKEN; +} + +// Try to load token from backend on first use +let _tokenLoaded = false; +async function ensureToken(): Promise { + const local = localStorage.getItem('okc_token'); + if (local) return local; + if (!_tokenLoaded) { + _tokenLoaded = true; + try { + const resp = await fetch('/api/okcupid/token'); + const data = await resp.json(); + if (data.token) { + // Backend only returns a preview — we need the full token saved there + // Actually check if backend has full token by trying a query + } + } catch (e) {} + } + return DEFAULT_TOKEN; +} + +export function setOkcToken(token: string): void { + localStorage.setItem('okc_token', token); + // Also save to backend so it persists across sessions + fetch('/api/okcupid/token', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }).catch(() => {}); +} + +// ─── Token refresh ────────────────────────────────────────────────────────── + +async function refreshOkcToken(): Promise { + const currentToken = getOkcToken(); + try { + const res = await fetch('/api/okcupid/graphql/authTokenRefresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-okc-token': currentToken }, + body: JSON.stringify({ + operationName: 'authTokenRefresh', + query: 'mutation authTokenRefresh($token: String!) { authTokenRefresh(token: $token) { token loginDisabled } }', + variables: { token: currentToken }, + }), + }); + if (!res.ok) return null; + const json = await res.json(); + const newToken = json?.data?.authTokenRefresh?.token; + if (newToken) { + setOkcToken(newToken); + return newToken; + } + } catch (e) {} + return null; +} + +// ─── Core query helper ─────────────────────────────────────────────────────── + +export async function okcQuery( + operationName: string, + query: string, + variables: Record = {}, +): Promise { + const token = getOkcToken(); + const res = await fetch(`/api/okcupid/graphql/${operationName}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-okc-token': token, + }, + body: JSON.stringify({ operationName, query, variables }), + }); + + if (res.status === 401) { + throw new Error('OKCupid token expired. Paste a new JWT in the Profile tab.'); + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`OKC API ${res.status}: ${text.slice(0, 200)}`); + } + + const json = await res.json(); + if (json.errors?.length) { + const errCode = json.errors[0]?.extensions?.code; + // Auto-refresh on expired token + if (errCode === 'TOKEN_EXPIRED' || errCode === 'UNAUTHENTICATED') { + const newToken = await refreshOkcToken(); + if (newToken) { + // Retry with new token + const retry = await fetch(`/api/okcupid/graphql/${operationName}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-okc-token': newToken }, + body: JSON.stringify({ operationName, query, variables }), + }); + if (retry.ok) { + const retryJson = await retry.json(); + if (!retryJson.errors?.length) return retryJson.data; + } + } + throw new Error('OKCupid token expired. Paste a new JWT in the Profile tab.'); + } + throw new Error(`OKC GraphQL: ${json.errors[0].message}`); + } + return json.data; +} + +// ─── Query strings (full, from captured operations) ────────────────────────── + +const QUERIES = { + MainSessionQuery: `query MainSessionQuery($experimentNames: [String]!) { session { __typename ...SessionDataFragment } me { __typename ...SessionUserFragment ...ApolloNotificationCounts ...CrossSellDataFragmentV2 } }\nfragment ApolloNotificationCounts on User { __typename notificationCounts { __typename messages likesIncoming likesMutual likesAndViews intros } }\nfragment CrossSellDataFragmentV2 on User { __typename id photos { __typename id original caption width height } xMatchFields { __typename astrologicalSign bio birthday bodyType drinking education firstName genderPresentation genderReductive hasKids heightInCm highestEducation isPayer jobCompany jobTitle languagesSpoken lookingFor marijuana pets preferences { __typename age { __typename max min } bodyType distanceInKilometers drinking ethnicity hasKids heightInCm { __typename max min } highestEducation marijuana pets politics relationshipStatus religion sexualRole smoking wantsKids } registrationDate relationshipIntent relationshipStatus relationshipType sensitiveFields { __typename ethnicity location { __typename latitude longitude } politics preferences { __typename genderSeeking } religion sexualRole } smoking wantsKids work } }\nfragment GateKeeperChecksFragment on Session { __typename gatekeeperChecks { __typename APP_FORCE_UPDATE ONBOARDING_MANDATORY_REDIRECT TERMS_MANDATORY_REDIRECT SMS_MANDATORY_REDIRECT BLOCK_PERSONALIZED_MARKETING HAS_PHONE SMS_KILL_SWITCH NEEDS_DETAILS_REBOARDING IDENTITY_TAGS_QUALIFIES JEWISH_RELIGIOUS_IDENTITY_ATTRIBUTES_QUALIFIES INCOGNITO_TERMED_MANDATORY_REDIRECT } }\nfragment SessionDataFragment on Session { __typename guestId ipCountry isInEU isAppsConsentKillswitchEnabled additionalPolicies traceSampleRate ...GateKeeperChecksFragment experiments(names: $experimentNames) { __typename group } }\nfragment SessionUserFragment on User { __typename id displayname age emailAddress binaryGenderLetter orientations relationshipType unitPreference userLocation { __typename publicName } primaryImage { __typename square800 } boostTokenCount superlikeTokenCount rewindTokenCount learnMyTypeLikeCount isIncognito hasMetPhotoRequirements hasSeenSwipingTutorial: hasSeenUserGuide(feature: USER_SWIPING_TUTORIAL) joinDate ...SubscriptionInfo globalPreferences { __typename gender { __typename values } } selfieVerifiedStatus(shouldReturnStatus: true) isPurchaseBanned }\nfragment SubscriptionFeature on FeatureSubscription { __typename timeOfActualLoss wasEverActive isActive }\nfragment SubscriptionInfo on User { __typename unlimitedLikesSubscriptionFeature: featureSubscription(feature: UNLIMITED_LIKES) { __typename ...SubscriptionFeature } seeWhoLikesYouSubscriptionFeature: featureSubscription( feature: SEE_WHO_LIKES_YOU ) { __typename ...SubscriptionFeature } ALIST_BASIC: hasPremium(name: ALIST_BASIC) ALIST_PREMIUM: hasPremium(name: ALIST_PREMIUM) ALIST_PREMIUM_PLUS: hasPremium(name: ALIST_PREMIUM_PLUS) INCOGNITO: hasPremium(name: INCOGNITO_BUNDLE) }`, + + PublicProfile: `query PublicProfile($userId: ID!, $userIdString: String!, $conversationThreadLimit: Int) { me { __typename id ...SelfProfile match(id: $userIdString) { __typename ...ApolloBaseUser user { __typename id ...ProfileDetailsAndPreferences ...ProfileEssays ...PhotosFragment } } } }\nfragment ApolloBaseUser on Match { __typename user { __typename id displayname age userLocation { __typename publicName } primaryImage { __typename square800 } isOnline } matchPercent senderVote senderMessageTime senderBlocked targetVote targetLikes targetViewedMe likeTime targetLikeViaSpotlight targetLikeViaSuperBoost targetMessageTime firstMessage { __typename id text time threadId attachments { __typename ...AttachmentFragment } } }\nfragment ApolloEssay on Essay { __typename id title groupTitle groupId isActive isPassion processedContent rawContent placeholder picture { __typename id square800 } }\nfragment AttachmentFragment on Attachment { __typename ... on ProfileCommentPhoto { __typename photo { __typename id original } } ... on ProfileCommentEssay { __typename essayTitle essayText } }\nfragment PhotosFragment on User { __typename photos { __typename id original square225 square800 caption width height crop { __typename upperLeftX upperLeftY lowerRightX lowerRightY } } }\nfragment ProfileDetailsAndPreferences on User { __typename badges { __typename name } bodyType children relationshipStatus relationshipType drinking pets weed ethnicity smoking politics height astrologicalSign diet knownLanguages genders orientations pronounCategory customPronouns identityTags occupation { __typename title employer status } education { __typename level school { __typename name } } religion { __typename value modifier } shabbatRoutine kosherHabits religiousBackground globalPreferences { __typename relationshipType { __typename values } connectionType { __typename values } gender { __typename values } } selfieVerifiedStatus(shouldReturnStatus: true) }\nfragment ProfileEssays on User { __typename essaysWithUniqueIds { __typename ...ApolloEssay } }\nfragment SelfProfile on User { __typename isIncognito unitPreference conversationThread(targetId: $userId, limit: $conversationThreadLimit) { __typename canMessage messages { __typename senderId text attachments { __typename ...AttachmentFragment } } } match(id: $userIdString) { __typename senderIsVisibleThroughIncognito } }`, + + NotificationBatch: `query NotificationBatch { me { __typename notificationCounts { __typename likesMutual messages } matches: conversationsAndMatches(filter: MATCHES, limit: 1) { __typename data { __typename ... on MutualMatch { __typename match { __typename user { __typename ...BasicUserInfo } } } } } messages: conversationsAndMatches(filter: REPLIES, limit: 1) { __typename data { __typename ... on Conversation { __typename correspondent { __typename user { __typename ...BasicUserInfo } } } } } } }\nfragment BasicUserInfo on User { __typename id displayname primaryImage { __typename square225 } }`, + + LikesIncomingPage: `query LikesIncomingPage($nextPageKey: String, $limit: Int, $sort: LikesListSort, $includeViews: Boolean! = false) { me { __typename likesIncomingWithPreviews( after: $nextPageKey limit: $limit sort: $sort includeViews: $includeViews ) { __typename data { __typename ...ApolloBaseUser ...ApolloPreviewUser ...UserMatchHighlightsFragment ...PreviewMatchHighlightsFragment } pageInfo { __typename ...ApolloPaging } } promosForPage(page: LIKES_INCOMING) { __typename ...ApolloPromo } } }\nfragment ApolloBaseUser on Match { __typename user { __typename id displayname age userLocation { __typename publicName } primaryImage { __typename square800 } isOnline } matchPercent senderVote senderMessageTime senderBlocked targetVote targetLikes targetViewedMe likeTime targetLikeViaSpotlight targetLikeViaSuperBoost targetMessageTime firstMessage { __typename id text time threadId attachments { __typename ...AttachmentFragment } } }\nfragment ApolloPaging on PageInfo { __typename before after hasMore total }\nfragment ApolloPreviewUser on MatchPreview { __typename primaryImage { __typename square800 } primaryImageBlurred { __typename square800 } hasFirstMessage targetSuperlikes targetViewedMe matchHighlights { __typename ...MatchHighlightsFragment } }\nfragment ApolloPromo on Promo { __typename id name type upsellType featureType }\nfragment AttachmentFragment on Attachment { __typename ... on ProfileCommentPhoto { __typename photo { __typename id original } } ... on ProfileCommentEssay { __typename essayTitle essayText } }\nfragment MatchHighlightsFragment on MatchHighlights { __typename age matchScore isOnline isVerified hasIntroMessage dynamicHighlight { __typename ... on RelationshipIntentHighlight { __typename sharedIntents } ... on LocationHighlight { __typename summary } } }\nfragment PreviewMatchHighlightsFragment on MatchPreview { __typename matchHighlights { __typename ...MatchHighlightsFragment } }\nfragment UserMatchHighlightsFragment on Match { __typename matchHighlights { __typename ...MatchHighlightsFragment } }`, + + MessagesAndMatches: `query MessagesAndMatches($nextPageKey: String, $filter: ConversationsAndMatchesFilter!) { me { __typename conversationsAndMatches(filter: $filter, after: $nextPageKey) { __typename data { __typename ... on Conversation { __typename ...ApolloConversationRow } ... on MutualMatch { __typename status isUnread match { __typename ...ConversationCorrespondent } } } pageInfo { __typename ...ApolloPaging } } } }\nfragment ApolloConversationRow on Conversation { __typename correspondent { __typename ...ConversationCorrespondent } attachmentPreviews { __typename ... on GifAttachmentPreview { __typename id } ... on ReactionUpdate { __typename reaction originalMessage updateType } } snippet { __typename text sender { __typename id } } time isUnread threadid status }\nfragment ApolloPaging on PageInfo { __typename before after hasMore total }\nfragment ConversationCorrespondent on Match { __typename likeTime senderVote targetVote targetLikeViaSpotlight targetLikeViaSuperBoost user { __typename id displayname primaryImage { __typename square225 } isOnline } }`, + + conversationThread: `query conversationThread($targetId: ID!, $limit: Int, $before: String) { me { __typename conversationThread(targetId: $targetId, limit: $limit, before: $before) { __typename id status canMessage pageInfo { __typename ...ApolloPaging } correspondent { __typename senderVote targetVote targetLikeViaSpotlight targetLikeViaSuperBoost matchPercent user { __typename id displayname isOnline primaryImage { __typename square225 } } } messages { __typename id senderId threadId text time attachments { __typename ...AttachmentFragment } readTime } isReadReceiptActivated } } }\nfragment ApolloPaging on PageInfo { __typename before after hasMore total }\nfragment AttachmentFragment on Attachment { __typename ... on ProfileCommentPhoto { __typename photo { __typename id original } } ... on ProfileCommentEssay { __typename essayTitle essayText } }`, + + ConversationSend: `mutation ConversationSend($input: ConversationMessageSendInput!) { conversationMessageSend(input: $input) { __typename success nway messageId threadId adTrigger } }`, + + UserVote: `mutation UserVote($input: UserVoteInput!) { userVote(input: $input) { __typename likesRemaining success } }`, + + StacksMenu: `query StacksMenu { me { __typename id stacks { __typename ...StackFragment } hasSeenSwipingTutorial: hasSeenUserGuide(feature: USER_SWIPING_TUTORIAL) } }\nfragment Highlight on ProfileHighlight { __typename ... on PhotoHighlight { __typename id url caption } }\nfragment StackFragment on Stack { __typename id status emptyStateStatus badge data { __typename ... on StackMatch { __typename stream targetLikesSender hasSuperlikeRecommendation profileHighlights { __typename ...Highlight } match { __typename user { __typename id } } } ... on FirstPartyAd { __typename id } ... on ThirdPartyAd { __typename ad } ... on PromotedQuestionPrompt { __typename promotedQuestionId } } }`, + + Stack: `query Stack($stackId: StackTypes!, $excludedUserIds: [String]!, $usersRemaining: Int!) { me { __typename stack( id: $stackId excludedUserIds: $excludedUserIds usersRemaining: $usersRemaining shouldReturnStatusForSelfieVerification: true ) { __typename ...StackWithStackMatchesFragment } } }\nfragment ApolloBaseUser on Match { __typename user { __typename id displayname age userLocation { __typename publicName } primaryImage { __typename square800 } isOnline } matchPercent senderVote senderMessageTime senderBlocked targetVote targetLikes targetViewedMe likeTime targetLikeViaSpotlight targetLikeViaSuperBoost targetMessageTime firstMessage { __typename id text time threadId attachments { __typename ...AttachmentFragment } } }\nfragment ApolloEssay on Essay { __typename id title groupTitle groupId isActive isPassion processedContent rawContent placeholder picture { __typename id square800 } }\nfragment AttachmentFragment on Attachment { __typename ... on ProfileCommentPhoto { __typename photo { __typename id original } } ... on ProfileCommentEssay { __typename essayTitle essayText } }\nfragment Highlight on ProfileHighlight { __typename ... on PhotoHighlight { __typename id url caption } }\nfragment PhotosFragment on User { __typename photos { __typename id original square225 square800 caption width height crop { __typename upperLeftX upperLeftY lowerRightX lowerRightY } } }\nfragment StackMatchFragment on Match { __typename ...ApolloBaseUser user { __typename selfieVerifiedStatus(shouldReturnStatus: true) essaysWithUniqueIds { __typename ...ApolloEssay } ...PhotosFragment badges { __typename name } } }\nfragment StackWithStackMatchesFragment on Stack { __typename id status emptyStateStatus badge data { __typename ... on StackMatch { __typename stream targetLikesSender hasSuperlikeRecommendation profileHighlights { __typename ...Highlight } match { __typename user { __typename id } ...StackMatchFragment } } ... on FirstPartyAd { __typename id } ... on ThirdPartyAd { __typename ad } ... on PromotedQuestionPrompt { __typename promotedQuestionId } } }`, + + StackMatches: `query StackMatches($userIds: [String!]!) { me { __typename matches(ids: $userIds) { __typename ...StackMatchFragment } } }\nfragment ApolloBaseUser on Match { __typename user { __typename id displayname age userLocation { __typename publicName } primaryImage { __typename square800 } isOnline } matchPercent senderVote senderMessageTime senderBlocked targetVote targetLikes targetViewedMe likeTime targetLikeViaSpotlight targetLikeViaSuperBoost targetMessageTime firstMessage { __typename id text time threadId attachments { __typename ...AttachmentFragment } } }\nfragment ApolloEssay on Essay { __typename id title groupTitle groupId isActive isPassion processedContent rawContent placeholder picture { __typename id square800 } }\nfragment AttachmentFragment on Attachment { __typename ... on ProfileCommentPhoto { __typename photo { __typename id original } } ... on ProfileCommentEssay { __typename essayTitle essayText } }\nfragment PhotosFragment on User { __typename photos { __typename id original square225 square800 caption width height crop { __typename upperLeftX upperLeftY lowerRightX lowerRightY } } }\nfragment StackMatchFragment on Match { __typename ...ApolloBaseUser user { __typename selfieVerifiedStatus(shouldReturnStatus: true) essaysWithUniqueIds { __typename ...ApolloEssay } ...PhotosFragment badges { __typename name } } }`, + + LikesCapInfo: `query LikesCapInfo { me { __typename likesCap { __typename ...LikesCapFragment } } }\nfragment LikesCapFragment on LikesCap { __typename likesCapTotal likesRemaining viewCount resetTime }`, + + UserViewedUsers: `mutation UserViewedUsers($input: UserViewedUsersInput!) { userViewedUsers(input: $input) { __typename success } }`, +}; + +// ─── Exported API functions ────────────────────────────────────────────────── + +export async function getMainSession() { + return okcQuery('MainSessionQuery', QUERIES.MainSessionQuery, { + experimentNames: [ + 'cbmPivotPhase2', + 'iOS.cbmpivot.2485', + 'iOS.LearnMyType.2497', + 'okQuizzyAd', + 'iOS.IAPV3', + 'testExperiment', + ], + }); +} + +export async function getProfile(userId: string) { + return okcQuery('PublicProfile', QUERIES.PublicProfile, { + userId, + userIdString: userId, + conversationThreadLimit: 2, + }); +} + +export async function getNotifications() { + return okcQuery('NotificationBatch', QUERIES.NotificationBatch); +} + +export async function getLikesIncoming(limit = 10, nextPageKey: string | null = null) { + return okcQuery('LikesIncomingPage', QUERIES.LikesIncomingPage, { + includeViews: true, + limit, + nextPageKey, + sort: 'LIKES_VIEWS_GLOBAL', + }); +} + +export async function getRealLikesCount() { + const data = await okcQuery('LikesIncomingPage', QUERIES.LikesIncomingPage, { + includeViews: false, + limit: 1, + nextPageKey: null, + sort: 'LIKES_VIEWS_GLOBAL', + }); + return data?.me?.likesIncomingWithPreviews?.pageInfo?.total || 0; +} + +export async function getMessages(filter: string = 'ALL', nextPageKey: string | null = null) { + return okcQuery('MessagesAndMatches', QUERIES.MessagesAndMatches, { + filter, + nextPageKey, + }); +} + +export async function getConversation(targetId: string, limit = 30) { + return okcQuery('conversationThread', QUERIES.conversationThread, { + targetId, + limit, + before: null, + }); +} + +export async function sendMessage(targetId: string, text: string) { + return okcQuery('ConversationSend', QUERIES.ConversationSend, { + input: { targetId, text }, + }); +} + +export async function vote(targetId: string, voteType: 'LIKE' | 'PASS', userMetadata?: string) { + return okcQuery('UserVote', QUERIES.UserVote, { + input: { + votes: [ + { + targetId, + vote: voteType, + voteSource: 'DOUBLETAKE', + ...(userMetadata ? { userMetadata } : {}), + }, + ], + }, + }); +} + +export async function getStacks() { + return okcQuery('StacksMenu', QUERIES.StacksMenu); +} + +export async function getStack(stackId: string, excludedUserIds: string[] = []) { + return okcQuery('Stack', QUERIES.Stack, { + stackId, + excludedUserIds, + usersRemaining: 0, + }); +} + +export async function getStackMatches(userIds: string[]) { + return okcQuery('StackMatches', QUERIES.StackMatches, { userIds }); +} + +export async function getLikesCapInfo() { + return okcQuery('LikesCapInfo', QUERIES.LikesCapInfo); +} + +export async function markUsersViewed(targetIds: string[]) { + return okcQuery('UserViewedUsers', QUERIES.UserViewedUsers, { + input: { targetIds }, + }); +} diff --git a/web/src/api/operations/experimental.ts b/web/src/api/operations/experimental.ts index 86835a6..3af72db 100755 --- a/web/src/api/operations/experimental.ts +++ b/web/src/api/operations/experimental.ts @@ -1,7 +1,6 @@ import { gql } from '@apollo/client/core'; -// Experimental queries to discover hidden API endpoints -// Based on patterns: whoLikesMe, whoPingsMe -> try whoILiked, whoIPinged, myLikes, etc. +// Real endpoints discovered in v8.11.0 - replaces old wrong guesses export const LIKES_PROFILE_FRAGMENT = gql` fragment LikesProfileFragment on Profile { @@ -55,198 +54,25 @@ export const LIKES_PROFILE_FRAGMENT = gql` } `; -// Attempt 1: whoILiked (mirror of whoLikesMe) -export const WHO_I_LIKED_QUERY = gql` +// pastLikes - profiles you've liked (new in v8.11.0) +export const PAST_LIKES_QUERY = gql` ${LIKES_PROFILE_FRAGMENT} - query WhoILiked($limit: Int, $cursor: String, $sortBy: SortBy!) { - interactions: whoILiked( - input: {sortBy: $sortBy} - limit: $limit - cursor: $cursor - ) { + query pastLikes($cursor: String, $input: PastLikesQueryInput!, $limit: Int) { + pastLikes(cursor: $cursor, input: $input, limit: $limit) { nodes { - ...LikesProfileFragment - __typename - } - pageInfo { - total - hasNextPage - nextPageCursor - __typename - } - __typename - } - } -`; - -// Attempt 2: myLikes -export const MY_LIKES_QUERY = gql` - ${LIKES_PROFILE_FRAGMENT} - query MyLikes($limit: Int, $cursor: String, $sortBy: SortBy!) { - interactions: myLikes( - input: {sortBy: $sortBy} - limit: $limit - cursor: $cursor - ) { - nodes { - ...LikesProfileFragment - __typename - } - pageInfo { - total - hasNextPage - nextPageCursor - __typename - } - __typename - } - } -`; - -// Attempt 3: sentLikes -export const SENT_LIKES_QUERY = gql` - ${LIKES_PROFILE_FRAGMENT} - query SentLikes($limit: Int, $cursor: String, $sortBy: SortBy!) { - interactions: sentLikes( - input: {sortBy: $sortBy} - limit: $limit - cursor: $cursor - ) { - nodes { - ...LikesProfileFragment - __typename - } - pageInfo { - total - hasNextPage - nextPageCursor - __typename - } - __typename - } - } -`; - -// Attempt 4: likedProfiles -export const LIKED_PROFILES_QUERY = gql` - ${LIKES_PROFILE_FRAGMENT} - query LikedProfiles($limit: Int, $cursor: String, $sortBy: SortBy!) { - interactions: likedProfiles( - input: {sortBy: $sortBy} - limit: $limit - cursor: $cursor - ) { - nodes { - ...LikesProfileFragment - __typename - } - pageInfo { - total - hasNextPage - nextPageCursor - __typename - } - __typename - } - } -`; - -// Attempt 5: profilesILiked -export const PROFILES_I_LIKED_QUERY = gql` - ${LIKES_PROFILE_FRAGMENT} - query ProfilesILiked($limit: Int, $cursor: String, $sortBy: SortBy!) { - interactions: profilesILiked( - input: {sortBy: $sortBy} - limit: $limit - cursor: $cursor - ) { - nodes { - ...LikesProfileFragment - __typename - } - pageInfo { - total - hasNextPage - nextPageCursor - __typename - } - __typename - } - } -`; - -// Attempt 6: outgoingLikes (opposite of incoming likes) -export const OUTGOING_LIKES_QUERY = gql` - ${LIKES_PROFILE_FRAGMENT} - query OutgoingLikes($limit: Int, $cursor: String, $sortBy: SortBy!) { - interactions: outgoingLikes( - input: {sortBy: $sortBy} - limit: $limit - cursor: $cursor - ) { - nodes { - ...LikesProfileFragment - __typename - } - pageInfo { - total - hasNextPage - nextPageCursor - __typename - } - __typename - } - } -`; - -// Attempt 7: Try interactions query with direction parameter -export const INTERACTIONS_OUTGOING_QUERY = gql` - ${LIKES_PROFILE_FRAGMENT} - query InteractionsOutgoing($limit: Int, $cursor: String, $sortBy: SortBy!, $direction: String) { - interactions( - input: {sortBy: $sortBy, direction: $direction} - limit: $limit - cursor: $cursor - ) { - nodes { - ...LikesProfileFragment - __typename - } - pageInfo { - total - hasNextPage - nextPageCursor - __typename - } - __typename - } - } -`; - -// Attempt 8: Try whoLikesMe with a filter for mine=LIKED -export const FILTERED_WHO_I_LIKED_MUTATION = gql` - ${LIKES_PROFILE_FRAGMENT} - mutation FilteredWhoILiked($input: FilteredInteractionInput!, $cursor: String) { - filteredWhoILiked(input: $input, cursor: $cursor) { - filters { - ageRange - desires - lookingFor - sexualities - __typename - } - profiles { - nodes { + isPing + interactionSentAt + profile { ...LikesProfileFragment __typename } - pageInfo { - total - unfilteredTotal - hasNextPage - nextPageCursor - __typename - } + __typename + } + pageInfo { + hasNextPage + nextPageCursor + total + unfilteredTotal __typename } __typename @@ -254,47 +80,27 @@ export const FILTERED_WHO_I_LIKED_MUTATION = gql` } `; -// Direct profile lookup query - to test if profile IDs from WhoLikesMe return real data -export const DIRECT_PROFILE_LOOKUP_QUERY = gql` - query DirectProfileLookup($profileId: String!) { - profile(id: $profileId) { - id - age - gender - sexuality - imaginaryName - bio - desires - connectionGoals - verificationStatus - distance { - km - mi - __typename - } - photos { - id - pictureUrl - pictureUrls { - small - medium - large +// pastDislikes - profiles you've passed (new in v8.11.0) +export const PAST_DISLIKES_QUERY = gql` + ${LIKES_PROFILE_FRAGMENT} + query pastDislikes($cursor: String, $input: PastDislikesQueryInput!, $limit: Int) { + pastDislikes(cursor: $cursor, input: $input, limit: $limit) { + nodes { + interactionSentAt + profile { + ...LikesProfileFragment __typename } - publicId + __typename + } + pageInfo { + hasNextPage + nextPageCursor + total + unfilteredTotal __typename } __typename } } `; - -// List of all experimental queries to try -export const EXPERIMENTAL_QUERIES = [ - { name: 'whoILiked', query: WHO_I_LIKED_QUERY }, - { name: 'myLikes', query: MY_LIKES_QUERY }, - { name: 'sentLikes', query: SENT_LIKES_QUERY }, - { name: 'likedProfiles', query: LIKED_PROFILES_QUERY }, - { name: 'profilesILiked', query: PROFILES_I_LIKED_QUERY }, - { name: 'outgoingLikes', query: OUTGOING_LIKES_QUERY }, -]; diff --git a/web/src/api/operations/mutations.ts b/web/src/api/operations/mutations.ts index 94e5833..e18f5d0 100755 --- a/web/src/api/operations/mutations.ts +++ b/web/src/api/operations/mutations.ts @@ -294,3 +294,417 @@ export const PROFILE_DISLIKE_MUTATION = gql` profileDislike(input: { targetProfileId: $targetProfileId }) } `; + +// === New mutations discovered via API probing + APK analysis (v8.11.0) === + +// Location mutations +export const PROFILE_LOCATION_UPDATE_MUTATION = gql` + mutation ProfileLocationUpdate($input: ProfileLocationInput!) { + profileLocationUpdate(input: $input) { + location { + ... on DeviceLocation { + device { + latitude + longitude + geocode { + city + country + __typename + } + __typename + } + __typename + } + ... on TeleportLocation { + teleport { + latitude + longitude + geocode { + city + country + __typename + } + __typename + } + __typename + } + ... on VirtualLocation { + core + __typename + } + __typename + } + __typename + } + } +`; + +// Interaction mutations +export const PROFILE_BLOCK_MUTATION = gql` + mutation ProfileBlock($input: ProfileBlockInteractionInput!) { + profileBlock(input: $input) + } +`; + +export const PROFILE_REPORT_MUTATION = gql` + mutation ProfileReport($input: ProfileReportInteractionInput!) { + profileReport(input: $input) + } +`; + +export const PROFILE_ACCEPT_PING_MUTATION = gql` + mutation ProfileAcceptPing($targetProfileId: String!) { + profileAcceptPing(input: { targetProfileId: $targetProfileId }) { + status + chat { + id + name + type + streamChatId + status + members { + id + status + imaginaryName + streamUserId + age + gender + sexuality + photos { + id + pictureUrl + pictureUrls { + small + medium + large + __typename + } + __typename + } + __typename + } + __typename + } + __typename + } + } +`; + +export const PROFILE_REJECT_PING_MUTATION = gql` + mutation ProfileRejectPing($targetProfileId: String!) { + profileRejectPing(input: { targetProfileId: $targetProfileId }) + } +`; + +export const UNDO_PROFILE_DISLIKE_MUTATION = gql` + mutation UndoProfileDislike { + undoProfileDislike + } +`; + +// Account mutations +export const SYNC_ACCOUNT_MUTATION = gql` + mutation SyncAccount { + syncAccount { + id + status + isMajestic + isUplift + availablePings + __typename + } + } +`; + +export const ACCOUNT_REDEEM_OFFER_MUTATION = gql` + mutation RedeemAccountOffer($input: RedeemAccountOfferInput!) { + accountRedeemOffer(input: $input) { + redeemedOffers { + offerName + redeemedAt + __typename + } + __typename + } + } +`; + +export const ACCOUNT_REDEEM_OFFER_WITH_REFLECTION_MUTATION = gql` + mutation AccountRedeemOfferWithReflection($input: RedeemAccountOfferInput!) { + accountRedeemOfferWithReflection(input: $input) { + availablePings + __typename + } + } +`; + +// Chat mutations +export const DISCONNECT_FROM_CHAT_MUTATION = gql` + mutation DisconnectFromChat($input: ChatDisconnectInput!) { + disconnectFromChat(input: $input) { + chatId + __typename + } + } +`; + +export const CHAT_ACTIVATE_MUTATION = gql` + mutation ChatActivate($input: ChatActivateInput!) { + chatActivate(input: $input) { + streamChatId + __typename + } + } +`; + +export const CHAT_CREATE_MUTATION = gql` + mutation ChatCreate($input: ChatCreateInput!) { + chatCreate(input: $input) { + id + name + type + status + streamChannelId + __typename + } + } +`; + +export const CHATS_MARK_READ_MUTATION = gql` + mutation ChatsMarkRead($input: ChatsMarkReadInput!) { + chatsMarkRead(input: $input) + } +`; + +export const GROUP_CHAT_ADD_MEMBERS_MUTATION = gql` + mutation GroupChatAddMembers($sourceProfileId: String!, $chatId: String!, $targetProfileIds: [String!]!) { + groupChatAddMembers( + input: { chatId: $chatId, targetProfileIds: $targetProfileIds } + ) { + id + streamChatId + name + type + status + members { + id + imaginaryName + __typename + } + __typename + } + } +`; + +// Profile link mutations +export const PROFILE_LINK_CREATE_MUTATION = gql` + mutation ProfileLinkCreate($input: ProfileLinkCreateInput!) { + profileLinkCreate(input: $input) { + linkId + url + linkType + __typename + } + } +`; + +export const PROFILE_LINK_DELETE_MUTATION = gql` + mutation ProfileLinkDelete($linkId: String!) { + profileLinkDelete(linkId: $linkId) { + linkId + __typename + } + } +`; + +// Picture mutations +export const PICTURE_CREATE_MUTATION = gql` + mutation PictureCreate($input: PictureCreateInput!) { + pictureCreate(input: $input) { + id + publicId + pictureUrl + pictureUrls { + small + medium + large + __typename + } + pictureOrder + pictureType + __typename + } + } +`; + +export const PICTURE_UPDATE_MUTATION = gql` + mutation PictureUpdate($input: PictureUpdateInput!) { + pictureUpdate(input: $input) { + id + publicId + pictureType + __typename + } + } +`; + +export const PICTURE_DELETE_MUTATION = gql` + mutation PictureDelete($input: PictureDeleteInput!) { + pictureDelete(input: $input) { + id + __typename + } + } +`; + +// Upload mutations +export const CLOUDINARY_GENERATE_UPLOAD_CREDENTIALS_MUTATION = gql` + mutation CloudinaryGenerateUploadCredentials($resourceType: ResourceType) { + cloudinaryGenerateUploadCredentials(resourceType: $resourceType) { + publicId + signature + timestamp + __typename + } + } +`; + +export const CREATE_UPLOAD_URL_MUTATION = gql` + mutation CreateUploadUrl($input: CreateUploadUrlInput!) { + createUploadUrl(input: $input) { + mediaUploadUrl + __typename + } + } +`; + +// Settings mutation +export const APP_SETTINGS_UPDATE_MUTATION = gql` + mutation AppSettingsUpdate( + $isDistanceInMiles: Boolean + $receiveMarketingNotifications: Boolean + $language: String + $receiveNewsEmailNotifications: Boolean + $receivePromotionsEmailNotifications: Boolean + $receiveNewsPushNotifications: Boolean + $receivePromotionsPushNotifications: Boolean + $receiveNewConnectionPushNotifications: Boolean + $receiveNewPingPushNotifications: Boolean + $receiveNewMessagePushNotifications: Boolean + $receiveNewLikePushNotifications: Boolean + ) { + accountUpdate( + input: { + isDistanceInMiles: $isDistanceInMiles + receiveMarketingNotifications: $receiveMarketingNotifications + language: $language + receiveNewsEmailNotifications: $receiveNewsEmailNotifications + receivePromotionsEmailNotifications: $receivePromotionsEmailNotifications + receiveNewsPushNotifications: $receiveNewsPushNotifications + receivePromotionsPushNotifications: $receivePromotionsPushNotifications + receiveNewConnectionPushNotifications: $receiveNewConnectionPushNotifications + receiveNewPingPushNotifications: $receiveNewPingPushNotifications + receiveNewMessagePushNotifications: $receiveNewMessagePushNotifications + receiveNewLikePushNotifications: $receiveNewLikePushNotifications + } + ) { + id + appSettings { + receiveMarketingNotifications + receiveNewsEmailNotifications + receivePromotionsEmailNotifications + receiveNewsPushNotifications + receivePromotionsPushNotifications + receiveNewConnectionPushNotifications + receiveNewPingPushNotifications + receiveNewMessagePushNotifications + receiveNewLikePushNotifications + __typename + } + __typename + } + } +`; + +// Filtered interactions +export const FILTERED_WHO_PINGS_ME_MUTATION = gql` + mutation FilteredWhoPingsMe($input: FilteredPingInteractionInput!, $cursor: String) { + filteredWhoPingsMe(input: $input, cursor: $cursor) { + profiles { + nodes { + id + age + gender + sexuality + imaginaryName + bio + desires + connectionGoals + interests + verificationStatus + isMajestic + distance { + km + mi + __typename + } + interactionStatus { + message + mine + theirs + __typename + } + photos { + id + pictureUrl + pictureUrls { + small + medium + large + __typename + } + __typename + } + __typename + } + pageInfo { + total + unfilteredTotal + hasNextPage + nextPageCursor + __typename + } + __typename + } + __typename + } + } +`; + +// Challenge mutation +export const START_CHALLENGE_MUTATION = gql` + mutation StartChallenge($input: StartChallengeInput!) { + startChallenge(input: $input) + } +`; + +// Account management +export const ACCOUNT_DEACTIVATE_MUTATION = gql` + mutation AccountDeactivate { + accountDeactivate { + id + status + __typename + } + } +`; + +export const ACCOUNT_TERMINATE_MUTATION = gql` + mutation AccountTerminate { + accountTerminate { + id + status + __typename + } + } +`; diff --git a/web/src/api/operations/queries.ts b/web/src/api/operations/queries.ts index 798e2e8..9f2d40b 100755 --- a/web/src/api/operations/queries.ts +++ b/web/src/api/operations/queries.ts @@ -577,3 +577,197 @@ export const ACCOUNT_STATUS_QUERY = gql` } } `; + +// === New queries discovered via API probing + APK analysis (v8.11.0) === + +export const POPULAR_LOCATIONS_QUERY = gql` + query PopularLocationsQuery { + popularLocations { + latitude + longitude + geocode { + city + country + __typename + } + __typename + } + } +`; + +export const GET_PROFILE_CONNECTIONS_QUERY = gql` + ${PICTURE_FRAGMENT} + query ProfileConnections($limit: Int = 25, $cursor: String) { + connections: getProfileConnections(limit: $limit, cursor: $cursor) { + nodes { + imaginaryName + isIncognito + sexuality + verificationStatus + isMajestic + age + gender + photos { + ...GetPictureUrlFragment + __typename + } + __typename + } + pageInfo { + total + hasNextPage + nextPageCursor + __typename + } + __typename + } + } +`; + +export const REDEEMED_OFFERS_QUERY = gql` + query RedeemedOffers { + account { + redeemedOffers { + offerName + redeemedAt + __typename + } + __typename + } + } +`; + +export const APP_SETTINGS_QUERY = gql` + query AppSettings { + account { + id + email + analyticsId + status + createdAt + isFinishedOnboarding + isMajestic + upliftExpirationTimestamp + isUplift + isDistanceInMiles + language + ageVerificationStatus + verificationNumber + challenges + appSettings { + receiveMarketingNotifications + receiveNewsEmailNotifications + receivePromotionsEmailNotifications + receiveNewsPushNotifications + receivePromotionsPushNotifications + receiveNewConnectionPushNotifications + receiveNewPingPushNotifications + receiveNewMessagePushNotifications + receiveNewLikePushNotifications + __typename + } + location { + device { + country + __typename + } + __typename + } + profiles { + id + status + imaginaryName + __typename + } + __typename + } + } +`; + +export const PROFILE_BY_STREAM_USER_ID_QUERY = gql` + ${PICTURE_FRAGMENT} + query ProfileByStreamUserId($streamUserId: String!) { + profile: profileByStreamUserId(streamUserId: $streamUserId) { + id + age + imaginaryName + gender + sexuality + bio + desires + connectionGoals + interests + verificationStatus + isMajestic + photos { + ...GetPictureUrlFragment + __typename + } + __typename + } + } +`; + +export const PROFILE_MATCHES_QUERY = gql` + ${PICTURE_FRAGMENT} + query ProfileMatches($profileId: String!, $limit: Int, $cursor: String) { + profile(id: $profileId) { + matches(limit: $limit, cursor: $cursor) { + nodes { + profile { + id + imaginaryName + age + gender + sexuality + bio + desires + connectionGoals + interests + verificationStatus + isMajestic + distance { + km + mi + __typename + } + photos { + ...GetPictureUrlFragment + __typename + } + interactionStatus { + mine + theirs + message + __typename + } + __typename + } + chat { + id + streamChatId + name + type + status + __typename + } + __typename + } + pageInfo { + total + hasNextPage + nextPageCursor + __typename + } + __typename + } + __typename + } + } +`; + +export const HAS_LINKED_REFLECTION_QUERY = gql` + query HasLinkedReflection { + hasLinkedReflection + } +`; diff --git a/web/src/components/chat/ChatListItem.tsx b/web/src/components/chat/ChatListItem.tsx index 626c199..a9526bc 100755 --- a/web/src/components/chat/ChatListItem.tsx +++ b/web/src/components/chat/ChatListItem.tsx @@ -14,6 +14,7 @@ interface ChatListItemProps { age?: number; isMajestic?: boolean; } | null; + discoveredLocation?: string | null; }; onClick?: () => void; } @@ -21,6 +22,7 @@ interface ChatListItemProps { export function ChatListItem({ chat, onClick }: ChatListItemProps) { const avatar = chat.avatarSet?.[0]; const message = chat.latestMessage; + const locationText = typeof chat.discoveredLocation === 'string' ? chat.discoveredLocation : ''; // Parse the message if it's a string (from API) const messageData = typeof message === 'string' ? JSON.parse(message) : message; @@ -114,6 +116,16 @@ export function ChatListItem({ chat, onClick }: ChatListItemProps) { `}> {messageText}

+ + {locationText && ( +
+ + + + + {locationText} +
+ )} {/* Chevron indicator */} diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index 783f89b..d67cdc8 100755 --- a/web/src/components/layout/Layout.tsx +++ b/web/src/components/layout/Layout.tsx @@ -3,27 +3,29 @@ import { Navigation } from './Navigation'; export function Layout() { return ( -
+
- {/* Main content area - centered with nav offset */} -
-
+
+
diff --git a/web/src/components/layout/Navigation.tsx b/web/src/components/layout/Navigation.tsx index f25ab0b..bdb73fe 100755 --- a/web/src/components/layout/Navigation.tsx +++ b/web/src/components/layout/Navigation.tsx @@ -6,7 +6,7 @@ const navItems = [ to: '/discover', label: 'Discover', icon: ( - + ), @@ -15,26 +15,42 @@ const navItems = [ to: '/likes', label: 'Likes', icon: ( - + ), }, + { + to: '/messages', + label: 'Chat', + icon: ( + + + + ), + }, { to: '/sent-pings', label: 'Pings', icon: ( - - + + ), }, { - to: '/messages', - label: 'Messages', + to: '/okcupid', + label: 'OKC', icon: ( - - + OK + ), + }, + { + to: '/matches', + label: 'Matches', + icon: ( + + ), }, @@ -42,7 +58,7 @@ const navItems = [ to: '/profile', label: 'Profile', icon: ( - + ), @@ -51,7 +67,7 @@ const navItems = [ to: '/settings', label: 'Settings', icon: ( - + @@ -59,126 +75,206 @@ const navItems = [ }, ]; +const s = { + // Desktop side rail + desktop: { + position: 'fixed' as const, + left: 0, + top: 0, + bottom: 0, + width: '80px', + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + paddingTop: '24px', + paddingBottom: '24px', + zIndex: 50, + background: 'var(--color-void)', + borderRight: '1px solid rgba(255,255,255,0.04)', + }, + desktopLogo: { + width: '40px', + height: '40px', + borderRadius: '12px', + background: 'var(--gradient-desire)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginBottom: '32px', + boxShadow: '0 4px 16px rgba(124,58,237,0.3)', + }, + desktopLogoText: { + color: '#fff', + fontFamily: "var(--font-display)", + fontWeight: 700, + fontSize: '18px', + }, + desktopNavList: { + display: 'flex', + flexDirection: 'column' as const, + gap: '4px', + flex: 1, + }, + desktopLink: (active: boolean) => ({ + position: 'relative' as const, + width: '48px', + height: '48px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '14px', + color: active ? '#fff' : 'rgba(255,255,255,0.38)', + background: active ? 'rgba(124,58,237,0.12)' : 'transparent', + border: active ? '1px solid rgba(124,58,237,0.20)' : '1px solid transparent', + transition: 'all 250ms cubic-bezier(0.22,1,0.36,1)', + cursor: 'pointer', + textDecoration: 'none', + }), + desktopTooltip: { + position: 'absolute' as const, + left: '100%', + marginLeft: '12px', + padding: '6px 12px', + borderRadius: '8px', + background: 'var(--color-surface-elevated)', + border: '1px solid rgba(255,255,255,0.06)', + fontSize: '13px', + fontWeight: 500, + color: 'rgba(255,255,255,0.87)', + whiteSpace: 'nowrap' as const, + pointerEvents: 'none' as const, + opacity: 0, + transform: 'translateX(4px)', + transition: 'all 200ms cubic-bezier(0.22,1,0.36,1)', + }, + desktopActiveDot: { + position: 'absolute' as const, + right: '-4px', + width: '6px', + height: '6px', + borderRadius: '50%', + background: 'var(--color-desire)', + boxShadow: '0 0 8px rgba(124,58,237,0.5)', + }, + + // Mobile bottom bar + mobile: { + position: 'fixed' as const, + bottom: 0, + left: 0, + right: 0, + zIndex: 50, + background: 'rgba(18,18,18,0.82)', + backdropFilter: 'blur(24px) saturate(180%)', + WebkitBackdropFilter: 'blur(24px) saturate(180%)', + borderTop: '1px solid rgba(255,255,255,0.06)', + paddingBottom: 'env(safe-area-inset-bottom, 0px)', + }, + mobileInner: { + display: 'flex', + justifyContent: 'space-around', + alignItems: 'center', + height: '56px', + padding: '0 4px', + }, + mobileLink: (active: boolean) => ({ + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + gap: '2px', + width: '64px', + height: '48px', + borderRadius: '12px', + color: active ? 'var(--color-desire)' : 'rgba(255,255,255,0.38)', + background: active ? 'rgba(124,58,237,0.10)' : 'transparent', + transition: 'all 200ms cubic-bezier(0.22,1,0.36,1)', + textDecoration: 'none', + WebkitTapHighlightColor: 'transparent', + userSelect: 'none' as const, + WebkitUserSelect: 'none' as const, + }), + mobileLabel: (active: boolean) => ({ + fontSize: '10px', + fontWeight: active ? 600 : 500, + fontFamily: 'var(--font-body)', + lineHeight: 1, + letterSpacing: '0.02em', + }), + mobileIconWrap: (active: boolean) => ({ + transition: 'transform 200ms cubic-bezier(0.22,1,0.36,1)', + transform: active ? 'scale(1.1)' : 'scale(1)', + display: 'flex', + }), +}; + export function Navigation() { const location = useLocation(); - const [mounted, setMounted] = useState(false); + const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + const [hovered, setHovered] = useState(null); useEffect(() => { - setMounted(true); + const onResize = () => setIsMobile(window.innerWidth < 768); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); }, []); + const isActive = (to: string) => + location.pathname === to || (to === '/discover' && location.pathname === '/'); + + if (isMobile) { + return ( +
+ {navItems.map((item) => { + const active = isActive(item.to); + return ( + + {item.icon} + {item.label} + + ); + })} +
+ + ); + } + return ( - <> - {/* Desktop Navigation - Side rail */} -
+ ); } diff --git a/web/src/components/profile/ProfileCard.tsx b/web/src/components/profile/ProfileCard.tsx index efd358a..5b29562 100755 --- a/web/src/components/profile/ProfileCard.tsx +++ b/web/src/components/profile/ProfileCard.tsx @@ -15,7 +15,7 @@ interface ProfileCardProps { connectionGoals?: string[]; interests?: string[]; distance?: { km: number; mi: number } | null; - location?: string | null; + discoveredLocation?: string | null; interactionStatus?: { mine?: string | null; theirs?: string | null; @@ -38,439 +38,314 @@ interface ProfileCardProps { showDislike?: boolean; } -const styles = { - card: { - position: 'relative' as const, - borderRadius: '16px', - overflow: 'hidden', - cursor: 'pointer', - background: 'rgba(255,255,255,0.03)', - }, - photoContainer: { - aspectRatio: '3/4', - position: 'relative' as const, - overflow: 'hidden', - }, - photo: { - width: '100%', - height: '100%', - objectFit: 'cover' as const, - transition: 'transform 0.5s ease', - }, - photoFallback: { - width: '100%', - height: '100%', - background: 'linear-gradient(135deg, #1a1a1f 0%, #0a0a0c 100%)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - gradient: { - position: 'absolute' as const, - inset: 0, - background: 'linear-gradient(180deg, transparent 0%, transparent 40%, rgba(0,0,0,0.8) 70%, rgba(0,0,0,0.95) 100%)', - }, - - // Status badges - statusBadge: (variant: 'liked' | 'passed') => ({ - position: 'absolute' as const, - top: '12px', - left: '12px', - zIndex: 10, - display: 'flex', - alignItems: 'center', - gap: '6px', - padding: '6px 12px', - borderRadius: '20px', - fontSize: '12px', - fontWeight: 600, - fontFamily: "'Satoshi', sans-serif", - color: '#ffffff', - background: variant === 'liked' ? 'rgba(34,197,94,0.9)' : 'rgba(100,116,139,0.9)', - backdropFilter: 'blur(8px)', - boxShadow: variant === 'liked' ? '0 4px 12px rgba(34,197,94,0.3)' : 'none', - }), - - // Majestic/Verified badges - badgeContainer: { - position: 'absolute' as const, - top: '12px', - right: '12px', - zIndex: 10, - display: 'flex', - gap: '6px', - }, - majesticBadge: { - width: '28px', - height: '28px', - borderRadius: '50%', - background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - boxShadow: '0 4px 12px rgba(245, 158, 11, 0.4)', - }, - verifiedBadge: { - width: '28px', - height: '28px', - borderRadius: '50%', - background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - boxShadow: '0 4px 12px rgba(59, 130, 246, 0.4)', - }, - - // Info overlay - infoOverlay: { - position: 'absolute' as const, - bottom: 0, - left: 0, - right: 0, - padding: '20px', - }, - nameRow: { - display: 'flex', - alignItems: 'baseline', - gap: '8px', - marginBottom: '4px', - }, - name: { - fontFamily: "'Clash Display', sans-serif", - fontSize: '20px', - fontWeight: 600, - color: '#ffffff', - margin: 0, - letterSpacing: '-0.01em', - }, - age: { - fontFamily: "'Satoshi', sans-serif", - fontSize: '16px', - fontWeight: 500, - color: 'rgba(255,255,255,0.8)', - }, - detailsRow: { - display: 'flex', - alignItems: 'center', - gap: '8px', - fontSize: '13px', - fontFamily: "'Satoshi', sans-serif", - color: 'rgba(255,255,255,0.7)', - marginBottom: '12px', - }, - detailDot: { - width: '3px', - height: '3px', - borderRadius: '50%', - background: 'rgba(255,255,255,0.4)', - }, - tagsContainer: { - display: 'flex', - flexWrap: 'wrap' as const, - gap: '6px', - }, - tag: { - padding: '5px 10px', - borderRadius: '12px', - fontSize: '11px', - fontWeight: 600, - fontFamily: "'Satoshi', sans-serif", - background: 'rgba(190,49,68,0.3)', - color: '#f4a5b0', - textTransform: 'uppercase' as const, - letterSpacing: '0.02em', - }, - tagMore: { - padding: '5px 10px', - borderRadius: '12px', - fontSize: '11px', - fontWeight: 600, - fontFamily: "'Satoshi', sans-serif", - background: 'rgba(255,255,255,0.1)', - color: 'rgba(255,255,255,0.6)', - }, - - // Refresh button overlay - refreshOverlay: { - position: 'absolute' as const, - inset: 0, - display: 'flex', - flexDirection: 'column' as const, - alignItems: 'center', - justifyContent: 'center', - gap: '12px', - background: 'linear-gradient(135deg, rgba(26,26,31,0.95) 0%, rgba(10,10,12,0.95) 100%)', - zIndex: 15, - }, - refreshButton: (isRefreshing: boolean) => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - gap: '8px', - padding: '12px 20px', - borderRadius: '12px', - border: 'none', - background: isRefreshing - ? 'rgba(255,255,255,0.1)' - : 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', - color: '#ffffff', - fontSize: '13px', - fontWeight: 600, - fontFamily: "'Satoshi', sans-serif", - cursor: isRefreshing ? 'not-allowed' : 'pointer', - opacity: isRefreshing ? 0.7 : 1, - transition: 'all 0.2s ease', - boxShadow: isRefreshing ? 'none' : '0 4px 12px rgba(59, 130, 246, 0.3)', - }), - refreshText: { - fontSize: '12px', - color: 'rgba(255,255,255,0.5)', - fontFamily: "'Satoshi', sans-serif", - textAlign: 'center' as const, - maxWidth: '160px', - }, - - // Quick action buttons - quickActionsContainer: { - position: 'absolute' as const, - bottom: '16px', - right: '16px', - zIndex: 20, - display: 'flex', - gap: '8px', - }, - dislikeButton: (isDisliking: boolean) => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: '40px', - height: '40px', - borderRadius: '50%', - border: 'none', - background: isDisliking - ? 'rgba(100,116,139,0.6)' - : 'rgba(100,116,139,0.8)', - color: '#ffffff', - cursor: isDisliking ? 'not-allowed' : 'pointer', - opacity: isDisliking ? 0.7 : 1, - transition: 'all 0.2s ease', - backdropFilter: 'blur(8px)', - boxShadow: '0 4px 12px rgba(0,0,0,0.3)', - }), -}; - -// Safely convert any value to a renderable string (handles GraphQL objects with __typename) const safeText = (v: any): string => { if (v == null) return ''; if (typeof v === 'string') return v; if (typeof v === 'number' || typeof v === 'boolean') return String(v); - // Object - likely a GraphQL type with __typename return ''; }; -export function ProfileCard({ profile, onClick, onRefresh, isRefreshing, onDislike, isDisliking, showDislike }: ProfileCardProps) { +export function ProfileCard({ profile, onClick, index = 0, onRefresh, isRefreshing, onDislike, isDisliking, showDislike }: ProfileCardProps) { const [imageError, setImageError] = useState(false); + const [copied, setCopied] = useState(false); + const primaryPhotoUrl = profile.photos?.[0]?.pictureUrls?.medium || profile.photos?.[0]?.pictureUrls?.large; - const theyDislikedMe = profile.interactionStatus?.theirs === 'DISLIKED'; const theyLikedMe = profile.interactionStatus?.theirs === 'LIKED'; + const theyDislikedMe = profile.interactionStatus?.theirs === 'DISLIKED'; + const locationText = typeof profile.discoveredLocation === 'string' ? profile.discoveredLocation : ''; const rawGoals = profile.connectionGoals || profile.desires || []; - // Ensure goals are strings (GraphQL may return objects) const goals = Array.isArray(rawGoals) ? rawGoals.filter((g): g is string => typeof g === 'string') : []; - // Reset error state when photo URL changes (new photos loaded after refresh) - useEffect(() => { - setImageError(false); - }, [primaryPhotoUrl]); + useEffect(() => { setImageError(false); }, [primaryPhotoUrl]); + + const handleCopyId = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(profile.id.replace(/^profile#/, '')).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; const handleRefresh = (e: React.MouseEvent) => { - e.stopPropagation(); // Don't trigger card onClick - if (onRefresh && !isRefreshing) { - onRefresh(profile.id); - } + e.stopPropagation(); + if (onRefresh && !isRefreshing) onRefresh(profile.id); }; const handleDislike = async (e: React.MouseEvent) => { - e.stopPropagation(); // Don't trigger card onClick - if (onDislike && !isDisliking) { - await onDislike(profile); - } + e.stopPropagation(); + if (onDislike && !isDisliking) await onDislike(profile); }; - // Key for ProxiedImage - changes when URL changes - const photoKey = primaryPhotoUrl || 'no-photo'; - return ( -
-
+
+ {/* Photo */} +
setImageError(true)} onLoad={() => setImageError(false)} fallback={ -
- - +
+ +
} /> - {/* Refresh overlay when image fails to load (only if there was a URL to try) */} + {/* Refresh overlay */} {imageError && onRefresh && primaryPhotoUrl && ( -
+
- - Image expired. Tap to fetch fresh data. + + Image expired
)} {/* Gradient overlay */} -
+
- {/* Likes you badge */} + {/* Status badges — top left */} {theyLikedMe && ( -
- +
+ Likes you
)} - {/* Not interested badge */} {theyDislikedMe && ( -
- +
+ - Not interested + Passed
)} - {/* Majestic/Verified badges */} - {(profile.isMajestic || profile.verificationStatus) && ( -
- {profile.isMajestic && ( -
- - - -
+ {/* Top right badges */} +
+ {profile.isMajestic && ( +
+ + + +
+ )} + {profile.verificationStatus && ( +
+ + + +
+ )} +
- )} + +
- {/* Info overlay */} -
- {/* Name and age */} -
-

{profile.imaginaryName}

- {profile.age} + {/* Info overlay — glassmorphism */} +
+ {/* Name + Age */} +
+

+ {profile.imaginaryName} +

+ + {profile.age} +
- {/* Details row - gender/sexuality */} -
+ {/* Gender / Sexuality */} +
{safeText(profile.gender)} -
+ {safeText(profile.sexuality)} + {profile.distance && typeof profile.distance === 'object' && 'mi' in profile.distance && ( + <> + + {Math.round(profile.distance.mi)} mi + + )}
- {/* Location row - distance and location */} - {(profile.distance && typeof profile.distance === 'object' && 'mi' in profile.distance || typeof profile.location === 'string') && ( -
- {profile.distance && typeof profile.distance === 'object' && 'mi' in profile.distance && ( - {Math.round(profile.distance.mi)} mi - )} - {profile.distance && typeof profile.distance === 'object' && 'mi' in profile.distance && typeof profile.location === 'string' && ( -
- )} - {typeof profile.location === 'string' && ( - - - - - {profile.location} - - )} + {/* Location */} + {locationText && ( +
+ + + + + + {locationText} +
)} - {/* Connection goals / Desires */} + {/* Tags */} {goals.length > 0 && ( -
+
{goals.slice(0, 2).map((goal) => ( - + {goal.replace(/_/g, ' ')} ))} {goals.length > 2 && ( - +{goals.length - 2} + + +{goals.length - 2} + )}
)}
- {/* Quick action buttons */} + {/* Dislike button */} {showDislike && onDislike && ( -
+
+ {/* Close button */}
+ {/* Block/Report Overlay */} + {showBlockReport && ( +
{ if (!blockReportLoading) { setShowBlockReport(false); setBlockReportMode(null); setBlockReportError(null); } }}> +
e.stopPropagation()}> + {!blockReportMode ? ( + <> +

Options

+ +
+ + + + ) : blockReportMode === 'block' ? ( + <> +

Block Reason

+ {['NOT_INTERESTED', 'SOMETHING_ELSE'].map((category) => ( + + ))} + {blockReportError &&

{blockReportError}

} + + + ) : ( + <> +

Report Reason

+ {['INAPPROPRIATE', 'UNDERAGE', 'OFFENSIVE', 'OTHER'].map((category) => ( + + ))} + {blockReportError && ( +

{blockReportError}

+ )} + + + )} +
+
+ )} + {/* Ping Modal */} {showPingModal && ( -
- {/* Outer ring */} -
- {/* Spinning gradient arc */} -
- {/* Inner glow */} -
+
+
+
+
); @@ -49,9 +29,17 @@ export function Loading({ size = 'md', className = '' }: LoadingProps) { export function LoadingPage() { return ( -
+
-

+

Loading...

@@ -60,25 +48,36 @@ export function LoadingPage() { export function LoadingCard() { return ( -
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
); } -export function LoadingCards({ count = 8 }: { count?: number }) { +export function LoadingCards({ count = 6 }: { count?: number }) { return ( -
+
{Array.from({ length: count }).map((_, i) => ( - +
+ +
))}
); diff --git a/web/src/config/constants.ts b/web/src/config/constants.ts index 3567230..e20b13a 100755 --- a/web/src/config/constants.ts +++ b/web/src/config/constants.ts @@ -1,6 +1,6 @@ // ⚠️ When updating, also update vite.config.ts proxy headers (search for APP_VERSION) -export const APP_VERSION = '8.8.3'; -export const OS_VERSION = '18.6.2'; +export const APP_VERSION = '8.11.0'; +export const OS_VERSION = '26.2.1'; export const API_CONFIG = { // Use Vite proxy to bypass CORS @@ -27,8 +27,8 @@ export const REQUEST_HEADERS = { // Default credentials - can be overridden via localStorage const DEFAULT_CREDENTIALS = { PROFILE_ID: 'profile#c5f40eca-b972-41b8-ba74-24db33a8f244', - REFRESH_TOKEN: 'AMf-vBzSZvWXvunGC0c3Sn5Sf4vxwSdb2z4VwT6rLC7LhGo_I4Wd759NCtKBBmIM994RPlEd3eHFT7v0CwkTFpu7uTT80uFa7EDR5DgYK-DiCm4S8nvcuoPMgPK1pa2xRYafqXnUmMEaZNIpWozvQrqmGZHa62pFySbX6EXxXd-S9vewsbWbSXUkZtZUMtOx3GR5nP1jP-z_bTGFktrJUNjV0tfeGa6C3Jc5MOg4dvjPGJ6YAMKGGMkKoDauNK1lHFKSvtnfMRFC6KrJYar1bsCljre-9aaYwq2gJ9YSZ53T6JIkV2_74c0', - EVENT_ANALYTICS_ID: '53923e7d-39bb-42ac-99c6-99cdf6008d11', + REFRESH_TOKEN: 'AMf-vBwqZWAS46Y76TtMlbyE7eSaPA952sVuMyxRwIUKbuqwEmHxn6Mk4Zy0vbOVKPjAckKjT42LkBIzFEUV9PdlhDpQehTmZ45VbqYLOPj7Vh4cQCYDsZP47APdLThHYoWGT4verYMEz7I7o0_6TC_V0B9kgvL6xdtcumeTY_UAG-5LZxUNYQ1EdAXwv4tCT7CpGIKsdoMpyyNwWNBKRV1LIP7c2H2OfZVgGF107XM3mfzc97kKOqT7JSZPg5tOuuFXFykTiiS_JjFMgvWzs2tk-V394sa1Jz3DrgWkXYhphUsa7S-ls9A', + EVENT_ANALYTICS_ID: 'e207fccf-b258-428f-bb6a-02e14616915c', }; // Auth credentials manager with localStorage persistence diff --git a/web/src/context/StreamChatContext.tsx b/web/src/context/StreamChatContext.tsx index 0628966..a6b52f7 100755 --- a/web/src/context/StreamChatContext.tsx +++ b/web/src/context/StreamChatContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useState, useEffect, ReactNode, useCallback import { StreamChat } from 'stream-chat'; import { useQuery } from '@apollo/client/react'; import { STREAM_CREDENTIALS_QUERY } from '../api/operations/queries'; -import { TEST_CREDENTIALS } from '../config/constants'; +import { authManager } from '../api/auth'; // Stream Chat API Key for Feeld - this is a public key // Found from app traffic analysis (chat.stream-io-api.com requests) @@ -59,7 +59,7 @@ export function StreamChatProvider({ children }: { children: ReactNode }) { const [userName, setUserName] = useState(null); const { data, loading, error: queryError } = useQuery(STREAM_CREDENTIALS_QUERY, { - variables: { profileId: TEST_CREDENTIALS.PROFILE_ID }, + variables: { profileId: authManager.getProfileId() }, }); // Extract stable values to avoid reconnecting on every Apollo cache update diff --git a/web/src/hooks/useMatches.ts b/web/src/hooks/useMatches.ts new file mode 100644 index 0000000..4e22372 --- /dev/null +++ b/web/src/hooks/useMatches.ts @@ -0,0 +1,178 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +export type MatchProfile = { + id: string; + imaginaryName?: string; + age?: number; + gender?: string; + sexuality?: string; + bio?: string; + desires?: string[]; + connectionGoals?: string[]; + interests?: string[]; + isMajestic?: boolean; + verificationStatus?: string; + distance?: { km: number; mi: number }; + photos?: Array<{ + id: string; + pictureUrls?: { small?: string; medium?: string; large?: string }; + pictureType?: string; + }>; + interactionStatus?: { + mine?: string; + theirs?: string; + message?: string; + }; + discoveredAt?: string; + _score: number; + _scoreBreakdown: Record; +}; + +export type MatchFilters = { + search?: string; + minAge?: number; + maxAge?: number; + maxDistance?: number; + gender?: string; + verifiedOnly?: boolean; + theyLikedOnly?: boolean; + sort?: 'score' | 'distance' | 'recent'; +}; + +export type MatchWeights = { + [key: string]: number; +}; + +const FILTERS_KEY = 'feeld_match_filters'; + +function loadSavedFilters(): MatchFilters { + try { + const saved = localStorage.getItem(FILTERS_KEY); + return saved ? JSON.parse(saved) : {}; + } catch { + return {}; + } +} + +export function useMatches() { + const [matches, setMatches] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filters, setFiltersState] = useState(loadSavedFilters); + const [weights, setWeights] = useState(null); + const [offset, setOffset] = useState(0); + const searchTimerRef = useRef(null); + + const setFilters = useCallback((newFilters: MatchFilters | ((prev: MatchFilters) => MatchFilters)) => { + setFiltersState(prev => { + const updated = typeof newFilters === 'function' ? newFilters(prev) : newFilters; + localStorage.setItem(FILTERS_KEY, JSON.stringify(updated)); + return updated; + }); + setOffset(0); + setMatches([]); + }, []); + + const fetchMatches = useCallback(async (currentOffset = 0, append = false) => { + setLoading(true); + try { + const params = new URLSearchParams(); + if (filters.search) params.set('search', filters.search); + if (filters.minAge) params.set('minAge', String(filters.minAge)); + if (filters.maxAge) params.set('maxAge', String(filters.maxAge)); + if (filters.maxDistance) params.set('maxDistance', String(filters.maxDistance)); + if (filters.gender) params.set('gender', filters.gender); + if (filters.verifiedOnly) params.set('verifiedOnly', 'true'); + if (filters.theyLikedOnly) params.set('theyLikedOnly', 'true'); + if (filters.sort && filters.sort !== 'score') params.set('sort', filters.sort); + params.set('limit', '50'); + params.set('offset', String(currentOffset)); + + const res = await fetch(`/api/matches?${params.toString()}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + + if (append) { + setMatches(prev => [...prev, ...data.matches]); + } else { + setMatches(data.matches); + } + setTotal(data.total); + } catch (e) { + console.error('Failed to fetch matches:', e); + } finally { + setLoading(false); + } + }, [filters]); + + const loadMore = useCallback(() => { + const newOffset = offset + 50; + setOffset(newOffset); + fetchMatches(newOffset, true); + }, [offset, fetchMatches]); + + const removeMatch = useCallback((profileId: string) => { + setMatches(prev => prev.filter(m => m.id !== profileId)); + setTotal(prev => Math.max(0, prev - 1)); + }, []); + + // Fetch weights + const fetchWeights = useCallback(async () => { + try { + const res = await fetch('/api/matches/weights'); + if (!res.ok) return; + const data = await res.json(); + setWeights(data.weights); + } catch (e) { + console.error('Failed to fetch weights:', e); + } + }, []); + + const updateWeights = useCallback(async (newWeights: MatchWeights) => { + try { + const res = await fetch('/api/matches/weights', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ weights: newWeights }), + }); + if (!res.ok) return; + const data = await res.json(); + setWeights(data.weights); + // Re-fetch matches with new weights + setOffset(0); + setMatches([]); + } catch (e) { + console.error('Failed to update weights:', e); + } + }, []); + + // Debounced fetch on filter change + useEffect(() => { + if (searchTimerRef.current) clearTimeout(searchTimerRef.current); + searchTimerRef.current = window.setTimeout(() => { + fetchMatches(0, false); + }, 300); + return () => { + if (searchTimerRef.current) clearTimeout(searchTimerRef.current); + }; + }, [fetchMatches]); + + // Fetch weights on mount + useEffect(() => { + fetchWeights(); + }, [fetchWeights]); + + return { + matches, + total, + loading, + filters, + setFilters, + fetchMatches: () => { setOffset(0); setMatches([]); fetchMatches(0, false); }, + loadMore, + removeMatch, + weights, + updateWeights, + hasMore: matches.length < total, + }; +} diff --git a/web/src/hooks/useSentPings.ts b/web/src/hooks/useSentPings.ts index bc179be..72a23eb 100755 --- a/web/src/hooks/useSentPings.ts +++ b/web/src/hooks/useSentPings.ts @@ -68,7 +68,7 @@ export function useSentPings() { const result = await apolloClient.query({ query: PROFILE_QUERY, variables: { profileId: ping.targetProfileId }, - fetchPolicy: 'cache-first', + fetchPolicy: 'network-first', }); const profile = result.data?.profile; diff --git a/web/src/index.css b/web/src/index.css index ef7cc2d..f557eda 100755 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,91 +1,102 @@ -/* Import distinctive fonts - must be before tailwindcss */ +/* Feeld — Mobile-First Design System 2026 */ + +/* Fonts: Clash Display (display) + Satoshi (body) */ @import url('https://api.fontshare.com/v2/css?f[]=clash-display@700,600,500&f[]=satoshi@400,500,700&display=swap'); @import "tailwindcss"; +/* ─── Design Tokens ─── */ :root { - /* Navigation width for layout */ + /* Navigation */ --nav-width: 0px; + --nav-height: 56px; + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-top: env(safe-area-inset-top, 0px); - /* Core palette - Midnight Velvet */ - --color-void: #000000; - --color-midnight: #0a0a0f; - --color-deep: #12121a; - --color-surface: #1a1a24; - --color-surface-elevated: #24242f; - --color-surface-hover: #2e2e3a; + /* Surfaces — warm charcoal, not pure black */ + --color-void: #121212; + --color-midnight: #161618; + --color-deep: #1a1a1e; + --color-surface: #1E1E22; + --color-surface-elevated: #262628; + --color-surface-hover: #2e2e32; - /* Accent colors */ - --color-desire: #c41e3a; - --color-desire-glow: #e91e63; - --color-desire-soft: rgba(196, 30, 58, 0.15); + /* Accent — saturated purple + rose for dating context */ + --color-desire: #7c3aed; + --color-desire-glow: #a855f7; + --color-desire-soft: rgba(124, 58, 237, 0.15); + --color-rose: #be3144; + --color-rose-glow: #e91e63; --color-rose-gold: #b76e79; - --color-champagne: #f7e7ce; - /* Status colors */ + /* Status */ --color-liked: #22c55e; - --color-liked-soft: rgba(34, 197, 94, 0.15); + --color-liked-soft: rgba(34, 197, 94, 0.12); --color-passed: #ef4444; - --color-passed-soft: rgba(239, 68, 68, 0.15); + --color-passed-soft: rgba(239, 68, 68, 0.12); + --color-warning: #f59e0b; - /* Text hierarchy */ - --color-text: #ffffff; - --color-text-primary: #ffffff; - --color-text-secondary: #9ca3af; - --color-text-muted: #6b7280; + /* Text — WCAG-compliant opacity levels */ + --color-text: rgba(255, 255, 255, 0.87); + --color-text-primary: rgba(255, 255, 255, 0.87); + --color-text-secondary: rgba(255, 255, 255, 0.60); + --color-text-muted: rgba(255, 255, 255, 0.38); /* Gradients */ - --gradient-desire: linear-gradient(135deg, #c41e3a 0%, #e91e63 100%); - --gradient-surface: linear-gradient(180deg, rgba(26, 26, 36, 0.8) 0%, rgba(10, 10, 15, 0.95) 100%); - --gradient-glass: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%); - --gradient-photo-overlay: linear-gradient(180deg, transparent 0%, transparent 40%, rgba(0, 0, 0, 0.7) 70%, rgba(0, 0, 0, 0.95) 100%); + --gradient-desire: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%); + --gradient-rose: linear-gradient(135deg, #be3144 0%, #e91e63 100%); + --gradient-surface: linear-gradient(180deg, rgba(30, 30, 34, 0.8) 0%, rgba(18, 18, 18, 0.95) 100%); + --gradient-glass: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%); + --gradient-photo-overlay: linear-gradient(180deg, transparent 0%, transparent 50%, rgba(0, 0, 0, 0.6) 75%, rgba(0, 0, 0, 0.92) 100%); /* Effects */ - --glow-desire: 0 0 30px rgba(196, 30, 58, 0.3); - --glow-subtle: 0 0 60px rgba(196, 30, 58, 0.1); - --shadow-elevated: 0 25px 50px -12px rgba(0, 0, 0, 0.6); - --shadow-card: 0 4px 20px rgba(0, 0, 0, 0.4); + --glow-desire: 0 0 24px rgba(124, 58, 237, 0.25); + --glow-subtle: 0 0 48px rgba(124, 58, 237, 0.08); + --shadow-elevated: 0 16px 48px -8px rgba(0, 0, 0, 0.5); + --shadow-card: 0 4px 16px rgba(0, 0, 0, 0.3); /* Glass morphism */ - --glass-bg: rgba(26, 26, 36, 0.7); - --glass-border: rgba(255, 255, 255, 0.08); + --glass-bg: rgba(30, 30, 34, 0.72); + --glass-border: rgba(255, 255, 255, 0.06); --glass-blur: blur(20px); /* Typography */ --font-display: 'Clash Display', sans-serif; --font-body: 'Satoshi', sans-serif; - /* Spacing rhythm */ - --space-xs: 0.25rem; - --space-sm: 0.5rem; - --space-md: 1rem; - --space-lg: 1.5rem; - --space-xl: 2rem; - --space-2xl: 3rem; - --space-3xl: 4rem; + /* 8px spacing grid */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; /* Border radius */ - --radius-sm: 0.5rem; - --radius-md: 0.75rem; - --radius-lg: 1rem; - --radius-xl: 1.5rem; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 24px; --radius-full: 9999px; /* Transitions */ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1); - --transition-sensual: 600ms cubic-bezier(0.22, 1, 0.36, 1); + --transition-spring: 500ms cubic-bezier(0.22, 1, 0.36, 1); } -/* Desktop nav spacing */ +/* Desktop nav offset */ @media (min-width: 768px) { :root { - --nav-width: 104px; + --nav-width: 80px; } } -/* Base reset and defaults */ +/* ─── Base Reset ─── */ *, *::before, *::after { box-sizing: border-box; margin: 0; @@ -101,14 +112,18 @@ html { body { font-family: var(--font-body); font-weight: 400; + font-size: 16px; background-color: var(--color-void); color: var(--color-text); - min-height: 100vh; - line-height: 1.6; + min-height: 100dvh; + line-height: 1.5; overflow-x: hidden; + overscroll-behavior: none; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; } -/* Ambient background effect */ +/* Ambient glow — subtle purple in top-left */ body::before { content: ''; position: fixed; @@ -117,13 +132,13 @@ body::before { right: 0; bottom: 0; background: - radial-gradient(ellipse 80% 50% at 50% -20%, rgba(196, 30, 58, 0.08) 0%, transparent 50%), - radial-gradient(ellipse 60% 40% at 100% 100%, rgba(183, 110, 121, 0.05) 0%, transparent 50%); + radial-gradient(ellipse 70% 40% at 20% -10%, rgba(124, 58, 237, 0.06) 0%, transparent 50%), + radial-gradient(ellipse 50% 30% at 90% 100%, rgba(190, 49, 68, 0.04) 0%, transparent 50%); pointer-events: none; z-index: -1; } -/* Subtle noise texture overlay */ +/* Subtle film grain texture */ body::after { content: ''; position: fixed; @@ -131,20 +146,20 @@ body::after { left: 0; right: 0; bottom: 0; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); - opacity: 0.015; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + opacity: 0.012; pointer-events: none; z-index: 1000; } #root { - min-height: 100vh; + min-height: 100dvh; display: flex; flex-direction: column; position: relative; } -/* Typography utilities */ +/* ─── Typography ─── */ .font-display { font-family: var(--font-display); } @@ -156,9 +171,9 @@ body::after { background-clip: text; } -/* Custom scrollbar */ +/* ─── Scrollbar ─── */ ::-webkit-scrollbar { - width: 6px; + width: 4px; } ::-webkit-scrollbar-track { @@ -174,35 +189,34 @@ body::after { background: var(--color-surface-hover); } -/* Selection styling */ +/* Hide scrollbar on mobile */ +@media (max-width: 767px) { + ::-webkit-scrollbar { + display: none; + } + body { + scrollbar-width: none; + -ms-overflow-style: none; + } +} + +/* ─── Selection ─── */ ::selection { background: var(--color-desire); color: white; } -/* Focus styles */ +/* ─── Focus ─── */ :focus-visible { outline: 2px solid var(--color-desire); outline-offset: 2px; } -/* Smooth page transitions */ -.page-enter { - opacity: 0; - transform: translateY(10px); -} - -.page-enter-active { - opacity: 1; - transform: translateY(0); - transition: opacity var(--transition-slow), transform var(--transition-slow); -} - -/* Stagger animation utility */ +/* ─── Animations ─── */ @keyframes fadeSlideUp { from { opacity: 0; - transform: translateY(20px); + transform: translateY(12px); } to { opacity: 1; @@ -218,7 +232,7 @@ body::after { @keyframes scaleIn { from { opacity: 0; - transform: scale(0.95); + transform: scale(0.96); } to { opacity: 1; @@ -226,10 +240,21 @@ body::after { } } +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(100%); + } + to { + opacity: 1; + transform: translateY(0); + } +} + @keyframes slideInRight { from { opacity: 0; - transform: translateX(20px); + transform: translateX(16px); } to { opacity: 1; @@ -242,7 +267,7 @@ body::after { box-shadow: var(--glow-desire); } 50% { - box-shadow: 0 0 40px rgba(196, 30, 58, 0.4); + box-shadow: 0 0 32px rgba(124, 58, 237, 0.35); } } @@ -256,16 +281,24 @@ body::after { } @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes cardAppear { from { - transform: rotate(0deg); + opacity: 0; + transform: translateY(8px); } to { - transform: rotate(360deg); + opacity: 1; + transform: translateY(0); } } +/* Utility classes */ .animate-fade-up { - animation: fadeSlideUp var(--transition-sensual) both; + animation: fadeSlideUp var(--transition-spring) both; } .animate-fade-in { @@ -284,17 +317,21 @@ body::after { animation: pulse-glow 2s ease-in-out infinite; } -/* Stagger delays */ -.stagger-1 { animation-delay: 50ms; } -.stagger-2 { animation-delay: 100ms; } -.stagger-3 { animation-delay: 150ms; } -.stagger-4 { animation-delay: 200ms; } -.stagger-5 { animation-delay: 250ms; } -.stagger-6 { animation-delay: 300ms; } -.stagger-7 { animation-delay: 350ms; } -.stagger-8 { animation-delay: 400ms; } +.animate-card-appear { + animation: cardAppear 350ms cubic-bezier(0.22, 1, 0.36, 1) both; +} -/* Glass morphism utility */ +/* Stagger delays */ +.stagger-1 { animation-delay: 40ms; } +.stagger-2 { animation-delay: 80ms; } +.stagger-3 { animation-delay: 120ms; } +.stagger-4 { animation-delay: 160ms; } +.stagger-5 { animation-delay: 200ms; } +.stagger-6 { animation-delay: 240ms; } +.stagger-7 { animation-delay: 280ms; } +.stagger-8 { animation-delay: 320ms; } + +/* ─── Glass morphism ─── */ .glass { background: var(--glass-bg); backdrop-filter: var(--glass-blur); @@ -302,7 +339,14 @@ body::after { border: 1px solid var(--glass-border); } -/* Shimmer loading effect */ +.glass-subtle { + background: rgba(255, 255, 255, 0.04); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +/* ─── Shimmer loading ─── */ .shimmer { background: linear-gradient( 90deg, @@ -311,24 +355,31 @@ body::after { var(--color-surface) 100% ); background-size: 200% 100%; - animation: shimmer 1.5s infinite; + animation: shimmer 1.5s ease-in-out infinite; } -/* Image hover zoom effect */ -.img-zoom { - transition: transform var(--transition-sensual); +/* ─── Image zoom on hover (desktop only) ─── */ +@media (hover: hover) { + .img-zoom { + transition: transform var(--transition-spring); + } + .img-zoom:hover { + transform: scale(1.03); + } } -.img-zoom:hover { - transform: scale(1.05); -} - -/* Magnetic button effect placeholder */ -.magnetic { +/* ─── Button press effect ─── */ +.btn-press { transition: transform var(--transition-fast); + user-select: none; + -webkit-user-select: none; } -/* Hide scrollbar utility */ +.btn-press:active { + transform: scale(0.97); +} + +/* ─── No scrollbar utility ─── */ .no-scrollbar::-webkit-scrollbar { display: none; } @@ -337,3 +388,42 @@ body::after { -ms-overflow-style: none; scrollbar-width: none; } + +/* ─── Scroll snap for carousels ─── */ +.snap-x { + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; +} + +.snap-center { + scroll-snap-align: center; +} + +.snap-start { + scroll-snap-align: start; +} + +/* ─── Reduced motion ─── */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ─── Safe area utilities ─── */ +.safe-top { + padding-top: env(safe-area-inset-top, 0px); +} + +.safe-bottom { + padding-bottom: env(safe-area-inset-bottom, 0px); +} + +.safe-x { + padding-left: env(safe-area-inset-left, 0px); + padding-right: env(safe-area-inset-right, 0px); +} diff --git a/web/src/pages/ApiExplorer.tsx b/web/src/pages/ApiExplorer.tsx index b6360cf..fac986e 100755 --- a/web/src/pages/ApiExplorer.tsx +++ b/web/src/pages/ApiExplorer.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { apolloClient } from '../api/client'; -import { EXPERIMENTAL_QUERIES, FILTERED_WHO_I_LIKED_MUTATION, INTERACTIONS_OUTGOING_QUERY, DIRECT_PROFILE_LOOKUP_QUERY } from '../api/operations/experimental'; -import { WHO_LIKES_ME_QUERY, DISCOVER_PROFILES_QUERY } from '../api/operations/queries'; +import { PAST_LIKES_QUERY, PAST_DISLIKES_QUERY } from '../api/operations/experimental'; +import { WHO_LIKES_ME_QUERY, DISCOVER_PROFILES_QUERY, PROFILE_QUERY } from '../api/operations/queries'; const styles = { container: { @@ -188,21 +188,17 @@ export function ApiExplorerPage() { setRunningAll(true); setResults([]); - for (const { name, query } of EXPERIMENTAL_QUERIES) { - await runQuery(name, query); - // Small delay between requests - await new Promise(resolve => setTimeout(resolve, 500)); - } - - // Also try the mutation - await runMutation('filteredWhoILiked', FILTERED_WHO_I_LIKED_MUTATION, { - input: { filters: {}, sortBy: 'LAST_INTERACTION' }, + // pastLikes - profiles you've liked (v8.11.0) + await runQuery('pastLikes', PAST_LIKES_QUERY, { + input: { sortDirection: 'NEWEST_FIRST' }, + limit: 20, }); + await new Promise(resolve => setTimeout(resolve, 500)); - // Try interactions with direction - await runQuery('interactions(direction: outgoing)', INTERACTIONS_OUTGOING_QUERY, { - sortBy: 'LAST_INTERACTION', - direction: 'OUTGOING', + // pastDislikes - profiles you've passed (v8.11.0) + await runQuery('pastDislikes', PAST_DISLIKES_QUERY, { + input: { sortDirection: 'NEWEST_FIRST' }, + limit: 20, }); setRunningAll(false); @@ -246,7 +242,7 @@ export function ApiExplorerPage() { anonymizedProfiles.slice(0, 3).map(async (profile: any) => { try { const result = await apolloClient.query({ - query: DIRECT_PROFILE_LOOKUP_QUERY, + query: PROFILE_QUERY, variables: { profileId: profile.id }, fetchPolicy: 'no-cache', }); @@ -554,7 +550,7 @@ export function ApiExplorerPage() {

API Explorer

- Testing experimental queries to discover hidden "Who I Liked" endpoint + API Explorer — pastLikes, pastDislikes, and discovery tools

@@ -607,34 +603,25 @@ export function ApiExplorerPage() {
- {EXPERIMENTAL_QUERIES.map(({ name, query }) => ( - - ))}
diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index bd00d51..f110e9c 100755 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -1,7 +1,8 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; -import { useQuery } from '@apollo/client/react'; +import { useQuery, useMutation } from '@apollo/client/react'; import { useState, useRef, useEffect } from 'react'; import { GET_CHAT_SUMMARY_QUERY } from '../api/operations/queries'; +import { DISCONNECT_FROM_CHAT_MUTATION, CHAT_ACTIVATE_MUTATION } from '../api/operations/mutations'; import { useChannel, useStreamChat } from '../context/StreamChatContext'; import type { StreamMessage } from '../context/StreamChatContext'; import { LoadingPage } from '../components/ui/Loading'; @@ -180,8 +181,11 @@ export function ChatPage() { const [inputText, setInputText] = useState(''); const [isSending, setIsSending] = useState(false); + const [showMenu, setShowMenu] = useState(false); + const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); + const menuRef = useRef(null); // Stream Chat connection const { isConnected, isConnecting: streamConnecting, error: streamError, userId } = useStreamChat(); @@ -203,6 +207,47 @@ export function ChatPage() { const summary = summaryData?.summary; const displayName = summary?.name || chatName; const displayAvatar = proxyImageUrl(summary?.avatarSet?.[0] || chatAvatar); + const chatStatus = summary?.status; + + // Chat mutations + const [disconnectFromChat, { loading: disconnecting }] = useMutation(DISCONNECT_FROM_CHAT_MUTATION); + const [chatActivate, { loading: activating }] = useMutation(CHAT_ACTIVATE_MUTATION); + + const handleDisconnect = async () => { + try { + await disconnectFromChat({ + variables: { input: { chatId: summary?.id || channelId } }, + }); + navigate('/messages'); + } catch (err) { + console.error('Failed to disconnect:', err); + } + }; + + const handleReactivate = async () => { + try { + await chatActivate({ + variables: { input: { chatId: summary?.id || channelId } }, + }); + setShowMenu(false); + } catch (err) { + console.error('Failed to reactivate chat:', err); + } + }; + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setShowMenu(false); + setShowDisconnectConfirm(false); + } + }; + if (showMenu) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [showMenu]); // Scroll to bottom on new messages useEffect(() => { @@ -363,16 +408,100 @@ export function ChatPage() {
{/* More options */} - +
+ + + {showMenu && ( +
+ {showDisconnectConfirm ? ( +
+

+ Disconnect from {displayName}? +

+
+ + +
+
+ ) : ( + <> + {chatStatus === 'INACTIVE' && ( + + )} + + + )} +
+ )} +
{/* Messages area */} diff --git a/web/src/pages/Discover.tsx b/web/src/pages/Discover.tsx index de156b7..e0f950c 100755 --- a/web/src/pages/Discover.tsx +++ b/web/src/pages/Discover.tsx @@ -1,12 +1,14 @@ -import { useQuery } from '@apollo/client/react'; +import { useMutation, useQuery } from '@apollo/client/react'; import { gql } from '@apollo/client'; import { DISCOVER_PROFILES_QUERY } from '../api/operations/queries'; +import { DEVICE_LOCATION_UPDATE_MUTATION } from '../api/operations/mutations'; import { ProfileCard } from '../components/profile/ProfileCard'; import { ProfileDetailModal } from '../components/profile/ProfileDetailModal'; import { LoadingPage, LoadingCards } from '../components/ui/Loading'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useLocation } from '../hooks/useLocation'; import { apolloClient } from '../api/client'; +import { authManager } from '../api/auth'; // Separate query for load more to avoid cache conflicts const LOAD_MORE_PROFILES_QUERY = gql` @@ -290,6 +292,22 @@ export function DiscoverPage() { const savedLikedMeProfiles = useRef>(new Set()); const { location } = useLocation(); + // Set device location on Feeld when Discover page loads (emulates app open GPS) + const [updateDeviceLocation] = useMutation(DEVICE_LOCATION_UPDATE_MUTATION); + const locationSetRef = useRef(false); + useEffect(() => { + if (location && !locationSetRef.current) { + locationSetRef.current = true; + updateDeviceLocation({ + variables: { input: { latitude: location.latitude, longitude: location.longitude } }, + }).then(() => { + console.log(`[Discover] Location set to ${location.name || `${location.latitude},${location.longitude}`}`); + }).catch((err) => { + console.error('[Discover] Failed to set location:', err); + }); + } + }, [location, updateDeviceLocation]); + // Fetch rotation location (what the cron actually set on Feeld) const [rotationLocation, setRotationLocation] = useState(null); useEffect(() => { @@ -304,7 +322,7 @@ export function DiscoverPage() { }, []); // Save all discovered profiles to backend cache for Likes page enrichment - const saveDiscoveredProfiles = useCallback(async (profiles: any[]) => { + const saveDiscoveredProfiles = useCallback(async (profiles: any[], discoveredLocation?: string) => { if (!profiles.length) return; const safeStr = (v: any) => (typeof v === 'string' ? v : ''); @@ -321,6 +339,7 @@ export function DiscoverPage() { connectionGoals: p.connectionGoals, verificationStatus: p.verificationStatus, interactionStatus: p.interactionStatus, + discoveredLocation: discoveredLocation ?? null, discoveredAt: new Date().toISOString(), })); @@ -414,9 +433,9 @@ export function DiscoverPage() { setHasMore(data.discovery.hasNextBatch ?? false); initialLoadDone.current = true; // Cache all discovered profiles for Likes page enrichment - saveDiscoveredProfiles(data.discovery.nodes); + saveDiscoveredProfiles(data.discovery.nodes, location?.name || rotationLocation || undefined); } - }, [data]); + }, [data, location?.name, rotationLocation]); // Save profiles that liked me to the backend for matching on Likes page useEffect(() => { @@ -458,7 +477,7 @@ export function DiscoverPage() { setAllProfiles(prev => [...prev, ...newProfiles]); setHasMore(result.data?.discovery?.hasNextBatch ?? false); // Cache all discovered profiles for Likes page enrichment - saveDiscoveredProfiles(newProfiles); + saveDiscoveredProfiles(newProfiles, location?.name || rotationLocation || undefined); } catch (err) { console.error('Failed to load more profiles:', err); } finally { @@ -469,6 +488,8 @@ export function DiscoverPage() { if (loading) return ; if (error) { + // Auto-retry on auth errors: force refresh token and refetch + const isAuthError = error.message?.includes('Unauthorized') || error.message?.includes('UNAUTHENTICATED'); return (
@@ -478,6 +499,45 @@ export function DiscoverPage() {

Connection Error

{error.message}

+ + {isAuthError && ( + + )}
); } diff --git a/web/src/pages/Likes.tsx b/web/src/pages/Likes.tsx index 8eb56b0..6fb41ff 100755 --- a/web/src/pages/Likes.tsx +++ b/web/src/pages/Likes.tsx @@ -1,12 +1,12 @@ import { useMutation } from '@apollo/client/react'; import { gql } from '@apollo/client'; import { WHO_LIKES_ME_QUERY, WHO_PINGS_ME_QUERY, PROFILE_QUERY } from '../api/operations/queries'; -import { DEVICE_LOCATION_UPDATE_MUTATION } from '../api/operations/mutations'; +import { DEVICE_LOCATION_UPDATE_MUTATION, UNDO_PROFILE_DISLIKE_MUTATION, PROFILE_ACCEPT_PING_MUTATION, PROFILE_REJECT_PING_MUTATION } from '../api/operations/mutations'; +import { PAST_LIKES_QUERY, PAST_DISLIKES_QUERY } from '../api/operations/experimental'; import { ProfileCard } from '../components/profile/ProfileCard'; import { ProfileDetailModal } from '../components/profile/ProfileDetailModal'; import { LoadingPage, LoadingCards } from '../components/ui/Loading'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { useLikedProfiles } from '../hooks/useLikedProfiles'; import { useDislikedProfiles } from '../hooks/useDislikedProfiles'; import { apolloClient } from '../api/client'; import { useLocation } from '../hooks/useLocation'; @@ -339,8 +339,6 @@ interface WhoLikedYouProfile { export function LikesPage() { const [activeTab, setActiveTab] = useState('likes'); const [selectedProfileId, setSelectedProfileId] = useState(null); - const [youLikedProfiles, setYouLikedProfiles] = useState([]); - const [youLikedLoading, setYouLikedLoading] = useState(false); // Scanner state const [selectedLocationId, setSelectedLocationId] = useState(''); @@ -367,12 +365,33 @@ export function LikesPage() { const { savedLocations, setLocation } = useLocation(); const [updateLocation] = useMutation(DEVICE_LOCATION_UPDATE_MUTATION); - const { likedProfiles, getLikedProfileIds, removeLikedProfile } = useLikedProfiles(); - const { dislikedProfiles, dislikeProfile, isDisliked, removeDislikedProfile } = useDislikedProfiles(); + const { dislikeProfile, isDisliked } = useDislikedProfiles(); // Track which profiles are currently being disliked const [dislikingProfiles, setDislikingProfiles] = useState>(new Set()); + // Past Likes (You Liked tab - from API) + const [pastLikes, setPastLikes] = useState([]); + const [pastLikesLoading, setPastLikesLoading] = useState(false); + const [pastLikesTotal, setPastLikesTotal] = useState(0); + const [pastLikesCursor, setPastLikesCursor] = useState(null); + const [pastLikesHasMore, setPastLikesHasMore] = useState(false); + + // Past Dislikes (Passed tab - from API) + const [pastDislikes, setPastDislikes] = useState([]); + const [pastDislikesLoading, setPastDislikesLoading] = useState(false); + const [pastDislikesTotal, setPastDislikesTotal] = useState(0); + const [pastDislikesCursor, setPastDislikesCursor] = useState(null); + const [pastDislikesHasMore, setPastDislikesHasMore] = useState(false); + + // Undo dislike state + const [undoLoading, setUndoLoading] = useState(false); + const [undoError, setUndoError] = useState(null); + + // Ping accept/reject state + const [pingActionLoading, setPingActionLoading] = useState>(new Set()); + const [pingMatched, setPingMatched] = useState>(new Set()); + // Discovered profiles cache (all profiles seen in Discover, without photos) const [discoveredProfiles, setDiscoveredProfiles] = useState([]); @@ -482,7 +501,7 @@ export function LikesPage() { }, [fetchAllLikes, fetchAllPings]); // Helper: batch-save discovered profiles to cache (strips photos, fire-and-forget) - const saveDiscoveredProfiles = useCallback(async (profiles: any[]) => { + const saveDiscoveredProfiles = useCallback(async (profiles: any[], discoveredLocation?: string) => { if (!profiles.length) return; const safeStr = (v: any) => (typeof v === 'string' ? v : ''); const stripped = profiles.map(p => ({ @@ -496,6 +515,7 @@ export function LikesPage() { connectionGoals: p.connectionGoals, verificationStatus: p.verificationStatus, interactionStatus: p.interactionStatus, + discoveredLocation: discoveredLocation ?? null, discoveredAt: new Date().toISOString(), })); try { @@ -577,7 +597,7 @@ export function LikesPage() { }); // Save ALL scanned profiles to discovered cache for future Likes enrichment - saveDiscoveredProfiles(profiles); + saveDiscoveredProfiles(profiles, location.name); setScanStatus(scanAbortRef.current.signal.aborted ? `Cancelled. Scanned ${profiles.length} profiles, found ${matches} who liked you.` @@ -644,7 +664,7 @@ export function LikesPage() { }); // Save ALL scanned profiles to discovered cache - saveDiscoveredProfiles(profiles); + saveDiscoveredProfiles(profiles, location.name); grandTotalProfiles += profiles.length; grandTotalMatches += matches; @@ -1056,57 +1076,184 @@ export function LikesPage() { } }, [dislikeProfile, fetchAllLikes]); - // Fetch profiles for "You Liked" tab - useEffect(() => { - const fetchYouLikedProfiles = async () => { - const ids = getLikedProfileIds(); - if (ids.length === 0) { - setYouLikedProfiles([]); - return; - } + // Fetch past likes from API (You Liked tab) + const fetchPastLikes = useCallback(async (cursor?: string | null) => { + setPastLikesLoading(true); + try { + const result = await apolloClient.query({ + query: PAST_LIKES_QUERY, + variables: { + input: { sortDirection: 'NEWEST_FIRST' }, + limit: 25, + ...(cursor ? { cursor } : {}), + }, + fetchPolicy: 'network-only', + }); - setYouLikedLoading(true); - try { - const profiles = await Promise.all( - ids.slice(0, 50).map(async (id) => { // Limit to 50 - try { - const result = await apolloClient.query({ - query: PROFILE_QUERY, - variables: { profileId: id }, - fetchPolicy: 'cache-first', - }); - return result.data?.profile; - } catch (e) { - console.error(`Failed to fetch profile ${id}:`, e); - return null; - } - }) - ); - setYouLikedProfiles(profiles.filter(Boolean)); - } catch (e) { - console.error('Failed to fetch liked profiles:', e); - } finally { - setYouLikedLoading(false); - } - }; + const nodes = result.data?.pastLikes?.nodes || []; + const pageInfo = result.data?.pastLikes?.pageInfo; - if (activeTab === 'youLiked') { - fetchYouLikedProfiles(); + if (cursor) { + setPastLikes(prev => [...prev, ...nodes]); + } else { + setPastLikes(nodes); + } + setPastLikesTotal(pageInfo?.total || 0); + setPastLikesCursor(pageInfo?.nextPageCursor || null); + setPastLikesHasMore(pageInfo?.hasNextPage || false); + console.log(`Fetched ${nodes.length} past likes, total: ${pageInfo?.total}, hasMore: ${pageInfo?.hasNextPage}`); + } catch (e) { + console.error('Failed to fetch past likes:', e); + } finally { + setPastLikesLoading(false); } - }, [activeTab, likedProfiles.length]); + }, []); + + // Fetch past dislikes from API (Passed tab) + const fetchPastDislikes = useCallback(async (cursor?: string | null) => { + setPastDislikesLoading(true); + try { + const result = await apolloClient.query({ + query: PAST_DISLIKES_QUERY, + variables: { + input: { sortDirection: 'NEWEST_FIRST' }, + limit: 25, + ...(cursor ? { cursor } : {}), + }, + fetchPolicy: 'network-only', + }); + + const nodes = result.data?.pastDislikes?.nodes || []; + const pageInfo = result.data?.pastDislikes?.pageInfo; + + if (cursor) { + setPastDislikes(prev => [...prev, ...nodes]); + } else { + setPastDislikes(nodes); + } + setPastDislikesTotal(pageInfo?.total || 0); + setPastDislikesCursor(pageInfo?.nextPageCursor || null); + setPastDislikesHasMore(pageInfo?.hasNextPage || false); + console.log(`Fetched ${nodes.length} past dislikes, total: ${pageInfo?.total}, hasMore: ${pageInfo?.hasNextPage}`); + } catch (e) { + console.error('Failed to fetch past dislikes:', e); + } finally { + setPastDislikesLoading(false); + } + }, []); + + // Undo last dislike + const handleUndoDislike = useCallback(async () => { + setUndoLoading(true); + setUndoError(null); + try { + await apolloClient.mutate({ + mutation: UNDO_PROFILE_DISLIKE_MUTATION, + }); + console.log('Undo dislike successful'); + // Refetch to show updated list + fetchPastDislikes(); + } catch (e: any) { + const msg = e?.graphQLErrors?.[0]?.message || e?.message || 'Unknown error'; + if (msg.toLowerCase().includes('majestic') || msg.toLowerCase().includes('not majestic')) { + setUndoError('Account is not majestic'); + } else { + setUndoError(msg); + } + console.error('Undo dislike failed:', e); + } finally { + setUndoLoading(false); + } + }, [fetchPastDislikes]); + + // Handle accept ping + const handleAcceptPing = useCallback(async (profileId: string) => { + setPingActionLoading(prev => new Set(prev).add(profileId)); + try { + const result = await apolloClient.mutate({ + mutation: PROFILE_ACCEPT_PING_MUTATION, + variables: { targetProfileId: profileId }, + }); + console.log('Accept ping result:', result.data); + // Show matched indicator briefly + setPingMatched(prev => new Set(prev).add(profileId)); + setTimeout(() => { + setPingMatched(prev => { + const next = new Set(prev); + next.delete(profileId); + return next; + }); + // Remove from pings list + setAllPings(prev => prev.filter(p => p.id !== profileId)); + setPingsTotal(prev => Math.max(0, prev - 1)); + }, 1500); + } catch (e) { + console.error('Failed to accept ping:', e); + } finally { + setPingActionLoading(prev => { + const next = new Set(prev); + next.delete(profileId); + return next; + }); + } + }, []); + + // Handle reject ping + const handleRejectPing = useCallback(async (profileId: string) => { + setPingActionLoading(prev => new Set(prev).add(profileId)); + try { + await apolloClient.mutate({ + mutation: PROFILE_REJECT_PING_MUTATION, + variables: { targetProfileId: profileId }, + }); + console.log('Rejected ping from:', profileId); + // Remove from pings list + setAllPings(prev => prev.filter(p => p.id !== profileId)); + setPingsTotal(prev => Math.max(0, prev - 1)); + } catch (e) { + console.error('Failed to reject ping:', e); + } finally { + setPingActionLoading(prev => { + const next = new Set(prev); + next.delete(profileId); + return next; + }); + } + }, []); + + // Fetch past likes/dislikes when tab is selected + useEffect(() => { + if (activeTab === 'youLiked') { + fetchPastLikes(); + } else if (activeTab === 'disliked') { + fetchPastDislikes(); + } + }, [activeTab, fetchPastLikes, fetchPastDislikes]); const loading = likesLoading || pingsLoading; - if (loading && activeTab !== 'youLiked') return ; + if (loading && activeTab !== 'youLiked' && activeTab !== 'disliked') return ; const likes = enrichedLikes; const pings = allPings; - const youLikedTotal = likedProfiles.length; // Count how many likes have been matched with real data const matchedCount = enrichedLikes.filter((p: any) => p._matched).length; - const currentProfiles = activeTab === 'likes' ? likes : activeTab === 'pings' ? pings : activeTab === 'disliked' ? dislikedProfiles : youLikedProfiles; + // Map pastLikes nodes to profiles for the grid + const pastLikesProfiles = pastLikes.map((node: any) => ({ + ...node.profile, + _isPing: node.isPing, + _interactionSentAt: node.interactionSentAt, + })); + + // Map pastDislikes nodes to profiles for the grid + const pastDislikesProfiles = pastDislikes.map((node: any) => ({ + ...node.profile, + _interactionSentAt: node.interactionSentAt, + })); + + const currentProfiles = activeTab === 'likes' ? likes : activeTab === 'pings' ? pings : activeTab === 'disliked' ? pastDislikesProfiles : pastLikesProfiles; return (
@@ -1191,7 +1338,7 @@ export function LikesPage() { You Liked - {youLikedTotal} + {pastLikesTotal || pastLikesProfiles.length} @@ -1204,7 +1351,7 @@ export function LikesPage() { Passed - {dislikedProfiles.length} + {pastDislikesTotal || pastDislikesProfiles.length}
@@ -1316,14 +1463,65 @@ export function LikesPage() { )} {/* Loading state for You Liked */} - {activeTab === 'youLiked' && youLikedLoading && ( + {activeTab === 'youLiked' && pastLikesLoading && pastLikes.length === 0 && (
)} + {/* Loading state for Passed */} + {activeTab === 'disliked' && pastDislikesLoading && pastDislikes.length === 0 && ( +
+ +
+ )} + + {/* Undo Last Pass button for Passed tab */} + {activeTab === 'disliked' && pastDislikesProfiles.length > 0 && ( +
+ + {undoError && ( +
+ {undoError} +
+ )} +
+ )} + {/* Empty State */} - {currentProfiles.length === 0 && !(activeTab === 'youLiked' && youLikedLoading) ? ( + {currentProfiles.length === 0 && !(activeTab === 'youLiked' && pastLikesLoading && pastLikes.length === 0) && !(activeTab === 'disliked' && pastDislikesLoading && pastDislikes.length === 0) ? (
{activeTab === 'likes' ? ( @@ -1353,34 +1551,222 @@ export function LikesPage() { : activeTab === 'pings' ? 'Pings from interested members will show up here' : activeTab === 'disliked' - ? "Profiles you've passed on will be saved here" - : "Profiles you've liked will be saved here" + ? "Profiles you've passed on will appear here" + : "Profiles you've liked will appear here" }

- ) : !(activeTab === 'youLiked' && youLikedLoading) && ( - /* Profiles Grid */ -
- {currentProfiles.map((profile: any, index: number) => { - // Only show dislike for profiles that have been matched with real scanner data - // The _matched flag is set by enrichLikesWithRealData when we have real profile info - const canDislike = activeTab === 'likes' && profile._matched && !isDisliked(profile.id); + ) : !(activeTab === 'youLiked' && pastLikesLoading && pastLikes.length === 0) && !(activeTab === 'disliked' && pastDislikesLoading && pastDislikes.length === 0) && ( + <> + {/* Profiles Grid */} +
+ {currentProfiles.map((profile: any, index: number) => { + // Only show dislike for profiles that have been matched with real scanner data + const canDislike = activeTab === 'likes' && profile._matched && !isDisliked(profile.id); - return ( - setSelectedProfileId(profile.id)} - index={index} - onRefresh={(id) => refreshProfile(id, profile.imaginaryName)} - isRefreshing={refreshingProfiles.has(profile.id)} - onDislike={canDislike ? handleDislikeFromLikes : undefined} - isDisliking={dislikingProfiles.has(profile.id)} - showDislike={canDislike} - /> - ); - })} -
+ return ( +
+ {/* Ping badge for You Liked tab */} + {activeTab === 'youLiked' && profile._isPing && ( +
+ + + + Ping +
+ )} + + {/* Timestamp badge for You Liked and Passed tabs */} + {(activeTab === 'youLiked' || activeTab === 'disliked') && profile._interactionSentAt && ( +
+ {new Date(profile._interactionSentAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} +
+ )} + + {/* Matched indicator for accepted pings */} + {activeTab === 'pings' && pingMatched.has(profile.id) && ( +
+ Matched! +
+ )} + + setSelectedProfileId(profile.id)} + index={index} + onRefresh={activeTab === 'likes' ? (id) => refreshProfile(id, profile.imaginaryName) : undefined} + isRefreshing={refreshingProfiles.has(profile.id)} + onDislike={canDislike ? handleDislikeFromLikes : undefined} + isDisliking={dislikingProfiles.has(profile.id)} + showDislike={canDislike} + /> + + {/* Accept/Reject buttons for Pings tab */} + {activeTab === 'pings' && !pingMatched.has(profile.id) && ( +
+ + +
+ )} +
+ ); + })} +
+ + {/* Load More button for You Liked tab */} + {activeTab === 'youLiked' && pastLikesHasMore && ( +
+ +
+ )} + + {/* Load More button for Passed tab */} + {activeTab === 'disliked' && pastDislikesHasMore && ( +
+ +
+ )} + )} {/* Profile Detail Modal */} diff --git a/web/src/pages/Matches.tsx b/web/src/pages/Matches.tsx new file mode 100644 index 0000000..1929370 --- /dev/null +++ b/web/src/pages/Matches.tsx @@ -0,0 +1,950 @@ +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@apollo/client/react'; +import { ProfileCard } from '../components/profile/ProfileCard'; +import { ProfileDetailModal } from '../components/profile/ProfileDetailModal'; +import { useMatches } from '../hooks/useMatches'; +import type { MatchFilters, MatchWeights } from '../hooks/useMatches'; +import { apolloClient } from '../api/client'; +import { PROFILE_QUERY, PROFILE_MATCHES_QUERY, GET_PROFILE_CONNECTIONS_QUERY } from '../api/operations/queries'; +import { authManager } from '../api/auth'; + +const safeText = (v: any): string => { + if (v == null) return ''; + if (typeof v === 'string') return v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + return ''; +}; + +const styles = { + container: { + paddingBottom: '48px', + }, + header: { + marginBottom: '24px', + }, + title: { + fontFamily: "'Clash Display', sans-serif", + fontSize: '36px', + fontWeight: 700, + color: '#ffffff', + margin: 0, + marginBottom: '8px', + letterSpacing: '-0.02em', + }, + subtitle: { + fontFamily: "'Satoshi', sans-serif", + fontSize: '15px', + color: 'rgba(255,255,255,0.5)', + margin: 0, + }, + headerActions: { + display: 'flex', + gap: '8px', + marginTop: '12px', + }, + iconBtn: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '40px', + height: '40px', + borderRadius: '10px', + border: '1px solid rgba(255,255,255,0.1)', + background: 'rgba(255,255,255,0.03)', + color: 'rgba(255,255,255,0.7)', + cursor: 'pointer', + fontSize: '18px', + transition: 'all 0.2s', + }, + + // Filter bar + filterBar: { + display: 'flex', + flexWrap: 'wrap' as const, + gap: '10px', + marginBottom: '20px', + alignItems: 'center', + }, + searchInput: { + flex: '1 1 200px', + minWidth: '200px', + padding: '10px 14px', + borderRadius: '10px', + border: '1px solid rgba(255,255,255,0.1)', + background: 'rgba(255,255,255,0.03)', + color: '#ffffff', + fontSize: '14px', + fontFamily: "'Satoshi', sans-serif", + outline: 'none', + }, + numberInput: { + width: '70px', + padding: '10px 8px', + borderRadius: '10px', + border: '1px solid rgba(255,255,255,0.1)', + background: 'rgba(255,255,255,0.03)', + color: '#ffffff', + fontSize: '14px', + fontFamily: "'Satoshi', sans-serif", + outline: 'none', + textAlign: 'center' as const, + }, + select: { + padding: '10px 14px', + borderRadius: '10px', + border: '1px solid rgba(255,255,255,0.1)', + background: 'rgba(255,255,255,0.05)', + color: '#ffffff', + fontSize: '14px', + fontFamily: "'Satoshi', sans-serif", + outline: 'none', + cursor: 'pointer', + }, + toggleBtn: (active: boolean) => ({ + padding: '10px 16px', + borderRadius: '10px', + border: active ? '1px solid rgba(168,85,247,0.4)' : '1px solid rgba(255,255,255,0.1)', + background: active ? 'rgba(168,85,247,0.15)' : 'rgba(255,255,255,0.03)', + color: active ? '#c084fc' : 'rgba(255,255,255,0.6)', + fontSize: '13px', + fontWeight: 600, + fontFamily: "'Satoshi', sans-serif", + cursor: 'pointer', + transition: 'all 0.2s', + whiteSpace: 'nowrap' as const, + }), + clearBtn: { + padding: '10px 14px', + borderRadius: '10px', + border: '1px solid rgba(239,68,68,0.2)', + background: 'rgba(239,68,68,0.08)', + color: '#f87171', + fontSize: '13px', + fontWeight: 600, + fontFamily: "'Satoshi', sans-serif", + cursor: 'pointer', + whiteSpace: 'nowrap' as const, + }, + filterLabel: { + fontSize: '12px', + color: 'rgba(255,255,255,0.4)', + fontFamily: "'Satoshi', sans-serif", + whiteSpace: 'nowrap' as const, + }, + + // Sort bar + sortBar: { + display: 'flex', + gap: '8px', + marginBottom: '20px', + alignItems: 'center', + }, + sortLabel: { + fontSize: '13px', + color: 'rgba(255,255,255,0.4)', + fontFamily: "'Satoshi', sans-serif", + marginRight: '4px', + }, + sortBtn: (active: boolean) => ({ + padding: '6px 14px', + borderRadius: '8px', + border: 'none', + background: active ? 'rgba(255,255,255,0.1)' : 'transparent', + color: active ? '#ffffff' : 'rgba(255,255,255,0.5)', + fontSize: '13px', + fontWeight: active ? 600 : 400, + fontFamily: "'Satoshi', sans-serif", + cursor: 'pointer', + transition: 'all 0.2s', + }), + + // Grid + profileGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', + gap: '16px', + }, + cardWrapper: { + position: 'relative' as const, + }, + scoreBadge: { + position: 'absolute' as const, + bottom: '-8px', + left: '50%', + transform: 'translateX(-50%)', + padding: '4px 14px', + borderRadius: '12px', + background: 'linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%)', + color: '#ffffff', + fontSize: '13px', + fontWeight: 700, + fontFamily: "'Satoshi', sans-serif", + boxShadow: '0 2px 8px rgba(124,58,237,0.4)', + zIndex: 10, + cursor: 'default', + whiteSpace: 'nowrap' as const, + }, + scoreTooltip: { + position: 'absolute' as const, + bottom: '32px', + left: '50%', + transform: 'translateX(-50%)', + padding: '10px 14px', + borderRadius: '10px', + background: 'rgba(15,15,20,0.95)', + border: '1px solid rgba(255,255,255,0.1)', + color: '#ffffff', + fontSize: '12px', + fontFamily: "'Satoshi', sans-serif", + zIndex: 20, + whiteSpace: 'nowrap' as const, + boxShadow: '0 4px 20px rgba(0,0,0,0.5)', + }, + tooltipRow: { + display: 'flex', + justifyContent: 'space-between', + gap: '16px', + padding: '2px 0', + }, + tooltipLabel: { + color: 'rgba(255,255,255,0.6)', + }, + tooltipValue: { + color: '#a78bfa', + fontWeight: 600, + }, + + // Load more + loadMoreBtn: { + display: 'block', + margin: '32px auto 0', + padding: '14px 32px', + borderRadius: '14px', + border: '1px solid rgba(255,255,255,0.1)', + background: 'rgba(255,255,255,0.03)', + color: '#ffffff', + fontSize: '15px', + fontWeight: 600, + fontFamily: "'Satoshi', sans-serif", + cursor: 'pointer', + transition: 'all 0.2s', + }, + + // Empty state + emptyState: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + padding: '80px 20px', + }, + emptyIcon: { + width: '80px', + height: '80px', + borderRadius: '50%', + background: 'linear-gradient(135deg, rgba(168,85,247,0.15) 0%, rgba(168,85,247,0.05) 100%)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginBottom: '24px', + fontSize: '32px', + }, + emptyTitle: { + fontFamily: "'Clash Display', sans-serif", + fontSize: '22px', + fontWeight: 600, + color: '#ffffff', + marginBottom: '8px', + textAlign: 'center' as const, + }, + emptyText: { + fontFamily: "'Satoshi', sans-serif", + fontSize: '15px', + color: 'rgba(255,255,255,0.5)', + textAlign: 'center' as const, + maxWidth: '320px', + }, + + // Settings panel + settingsPanel: { + marginBottom: '24px', + padding: '20px', + borderRadius: '14px', + background: 'rgba(255,255,255,0.02)', + border: '1px solid rgba(255,255,255,0.06)', + }, + settingsHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '16px', + }, + settingsTitle: { + fontFamily: "'Clash Display', sans-serif", + fontSize: '18px', + fontWeight: 600, + color: '#ffffff', + }, + weightRow: { + display: 'flex', + alignItems: 'center', + gap: '12px', + marginBottom: '8px', + }, + weightLabel: { + flex: '0 0 160px', + fontSize: '13px', + color: 'rgba(255,255,255,0.6)', + fontFamily: "'Satoshi', sans-serif", + }, + weightSlider: { + flex: 1, + accentColor: '#7c3aed', + }, + weightValue: { + flex: '0 0 36px', + fontSize: '13px', + color: '#a78bfa', + fontWeight: 600, + fontFamily: "'Satoshi', sans-serif", + textAlign: 'right' as const, + }, + + // Loading + loadingContainer: { + display: 'flex', + justifyContent: 'center', + padding: '60px 20px', + color: 'rgba(255,255,255,0.5)', + fontFamily: "'Satoshi', sans-serif", + fontSize: '15px', + }, + + // Gender pills + genderPills: { + display: 'flex', + gap: '6px', + }, + + // Mode toggle + modeToggleBar: { + display: 'flex', + gap: '4px', + marginBottom: '20px', + padding: '4px', + borderRadius: '12px', + background: 'rgba(255,255,255,0.03)', + border: '1px solid rgba(255,255,255,0.06)', + width: 'fit-content', + }, + modeBtn: (active: boolean) => ({ + padding: '8px 20px', + borderRadius: '10px', + border: 'none', + background: active ? 'rgba(124,58,237,0.2)' : 'transparent', + color: active ? '#c084fc' : 'rgba(255,255,255,0.5)', + fontSize: '14px', + fontWeight: active ? 600 : 400, + fontFamily: "'Satoshi', sans-serif", + cursor: 'pointer', + transition: 'all 0.2s', + }), + + // API Matches section + sectionTitle: { + fontFamily: "'Clash Display', sans-serif", + fontSize: '20px', + fontWeight: 600, + color: '#ffffff', + margin: '0 0 16px 0', + }, + sectionSubtitle: { + fontFamily: "'Satoshi', sans-serif", + fontSize: '13px', + color: 'rgba(255,255,255,0.4)', + margin: '-12px 0 16px 0', + }, + sectionDivider: { + margin: '32px 0', + border: 'none', + borderTop: '1px solid rgba(255,255,255,0.06)', + }, + messageBtn: { + display: 'inline-flex', + alignItems: 'center', + gap: '6px', + marginTop: '8px', + padding: '6px 14px', + borderRadius: '8px', + border: '1px solid rgba(168,85,247,0.3)', + background: 'rgba(168,85,247,0.1)', + color: '#c084fc', + fontSize: '12px', + fontWeight: 600, + fontFamily: "'Satoshi', sans-serif", + cursor: 'pointer', + transition: 'all 0.2s', + }, + matchCardWrapper: { + position: 'relative' as const, + }, + chatStatusBadge: { + position: 'absolute' as const, + bottom: '-8px', + left: '50%', + transform: 'translateX(-50%)', + padding: '4px 14px', + borderRadius: '12px', + background: 'linear-gradient(135deg, #059669 0%, #047857 100%)', + color: '#ffffff', + fontSize: '12px', + fontWeight: 600, + fontFamily: "'Satoshi', sans-serif", + boxShadow: '0 2px 8px rgba(5,150,105,0.4)', + zIndex: 10, + whiteSpace: 'nowrap' as const, + }, +}; + +const GENDER_OPTIONS = ['WOMAN', 'MAN', 'NON_BINARY']; +const DISTANCE_OPTIONS = [ + { label: '15mi', value: 15 }, + { label: '30mi', value: 30 }, + { label: '50mi', value: 50 }, + { label: '100mi', value: 100 }, + { label: 'Any', value: 0 }, +]; + +const WEIGHT_LABELS: Record = { + verification: 'Verified profile', + photoBase: 'Per photo (max 6)', + photoVerified: 'Per verified photo', + bioLong: 'Bio > 200 chars', + bioMedium: 'Bio > 100 chars', + bioShort: 'Bio > 30 chars', + desiresMany: 'Desires >= 5', + desiresSome: 'Desires >= 3', + connectionGoals: 'Has goals', + distanceClose: 'Within 15mi', + distanceMedium: 'Within 30mi', + distanceFar: 'Within 50mi', + ageSweetSpot: 'Age 24-40', + ageOk: 'Age 21-45', + ageOutOfRange: 'Age out of range', + theyLikedYou: 'They liked you', + connectionDesires: 'Connection desires', +}; + +const BREAKDOWN_LABELS: Record = { + verification: 'Verified', + photos: 'Photos', + verifiedPhotos: 'Verified photos', + bio: 'Bio quality', + desires: 'Desires', + connectionGoals: 'Goals', + distance: 'Distance', + age: 'Age', + theyLikedYou: 'They liked you', + connectionDesires: 'Connection', +}; + +export function MatchesPage() { + const { + matches, total, loading, filters, setFilters, + fetchMatches, loadMore, removeMatch, hasMore, + weights, updateWeights, + } = useMatches(); + + const navigate = useNavigate(); + const [matchMode, setMatchMode] = useState<'smart' | 'api'>('smart'); + + // API Matches queries (only run when in 'api' mode) + const { data: matchesData, loading: matchesLoading, refetch: refetchMatches } = useQuery(PROFILE_MATCHES_QUERY, { + variables: { profileId: authManager.getProfileId(), limit: 25 }, + skip: matchMode !== 'api', + fetchPolicy: 'cache-and-network', + }); + + const { data: connectionsData, loading: connectionsLoading, refetch: refetchConnections } = useQuery(GET_PROFILE_CONNECTIONS_QUERY, { + variables: { limit: 25 }, + skip: matchMode !== 'api', + fetchPolicy: 'cache-and-network', + }); + + const apiMatches = matchesData?.profile?.matches?.nodes || []; + const apiMatchesPageInfo = matchesData?.profile?.matches?.pageInfo; + const apiConnections = connectionsData?.connections?.nodes || []; + const apiConnectionsPageInfo = connectionsData?.connections?.pageInfo; + + const [selectedProfileId, setSelectedProfileId] = useState(null); + const [hoveredScore, setHoveredScore] = useState(null); + const [showSettings, setShowSettings] = useState(false); + const [localWeights, setLocalWeights] = useState(null); + const [refreshedPhotos, setRefreshedPhotos] = useState>({}); + const refreshedIdsRef = useRef>(new Set()); + const refreshQueueRef = useRef>(new Set()); + const isProcessingRef = useRef(false); + + // Process the refresh queue one at a time + const processRefreshQueue = useCallback(async () => { + if (isProcessingRef.current) return; + isProcessingRef.current = true; + + while (refreshQueueRef.current.size > 0) { + const profileId = refreshQueueRef.current.values().next().value!; + refreshQueueRef.current.delete(profileId); + + try { + const result = await apolloClient.query({ + query: PROFILE_QUERY, + variables: { profileId }, + fetchPolicy: 'network-only', + }); + const fresh = result.data?.profile; + if (fresh?.photos?.length) { + setRefreshedPhotos(prev => ({ ...prev, [profileId]: fresh.photos })); + fetch('/api/discovered-profiles/update-photos', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profileId, photos: fresh.photos }), + }).catch(() => {}); + } + } catch (e) { + // silently skip failed refreshes + } + await new Promise(r => setTimeout(r, 150)); + } + + isProcessingRef.current = false; + }, []); + + // Queue a profile for photo refresh when it enters the viewport + const queueRefresh = useCallback((profileId: string) => { + if (refreshedIdsRef.current.has(profileId)) return; + refreshedIdsRef.current.add(profileId); + refreshQueueRef.current.add(profileId); + processRefreshQueue(); + }, [processRefreshQueue]); + + // IntersectionObserver to detect cards entering viewport + const observerRef = useRef(null); + useEffect(() => { + observerRef.current = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const profileId = (entry.target as HTMLElement).dataset.profileId; + if (profileId) queueRefresh(profileId); + } + } + }, + { rootMargin: '200px' } + ); + return () => observerRef.current?.disconnect(); + }, [queueRefresh]); + + // Ref callback for each card to observe/unobserve + const cardRefs = useRef>(new Map()); + const setCardRef = useCallback((profileId: string, el: HTMLDivElement | null) => { + const prev = cardRefs.current.get(profileId); + if (prev && observerRef.current) observerRef.current.unobserve(prev); + if (el) { + cardRefs.current.set(profileId, el); + if (observerRef.current) observerRef.current.observe(el); + } else { + cardRefs.current.delete(profileId); + } + }, []); + + // Merge refreshed photos into match profiles + const displayMatches = useMemo(() => matches.map(m => { + const freshPhotos = refreshedPhotos[m.id]; + return freshPhotos ? { ...m, photos: freshPhotos } : m; + }), [matches, refreshedPhotos]); + + const activeFiltersCount = [ + filters.search, filters.minAge, filters.maxAge, + filters.maxDistance, filters.gender, + filters.verifiedOnly, filters.theyLikedOnly, + ].filter(Boolean).length; + + const handleFilterChange = useCallback((key: keyof MatchFilters, value: any) => { + setFilters((prev: MatchFilters) => ({ ...prev, [key]: value || undefined })); + }, [setFilters]); + + const clearFilters = useCallback(() => { + setFilters({}); + }, [setFilters]); + + const handleWeightChange = useCallback((key: string, value: number) => { + setLocalWeights(prev => ({ ...(prev || weights || {}), [key]: value })); + }, [weights]); + + const saveWeights = useCallback(() => { + if (localWeights) { + updateWeights(localWeights); + } + }, [localWeights, updateWeights]); + + const resetWeights = useCallback(() => { + setLocalWeights(null); + updateWeights({}); + }, [updateWeights]); + + const displayWeights = localWeights || weights || {}; + + return ( +
+ {/* Header */} +
+

{matchMode === 'smart' ? 'Smart Matches' : 'API Matches'}

+

+ {matchMode === 'smart' + ? `${total} ranked profiles from your discovery cache` + : `${apiMatchesPageInfo?.total ?? apiMatches.length} matches, ${apiConnectionsPageInfo?.total ?? apiConnections.length} connections from Feeld API`} +

+
+ + {matchMode === 'smart' && ( + + )} +
+
+ + {/* Mode Toggle */} +
+ + +
+ + {/* ===== SMART MATCHES MODE ===== */} + {matchMode === 'smart' && <> + + {/* Scoring Settings */} + {showSettings && ( +
+
+ Scoring Weights +
+ + +
+
+ {Object.entries(WEIGHT_LABELS).map(([key, label]) => ( +
+ {label} + handleWeightChange(key, parseInt(e.target.value))} + style={styles.weightSlider} + /> + {displayWeights[key] ?? 0} +
+ ))} +
+ )} + + {/* Filter Bar */} +
+ handleFilterChange('search', e.target.value)} + style={styles.searchInput} + /> + Age + handleFilterChange('minAge', e.target.value ? parseInt(e.target.value) : undefined)} + style={styles.numberInput} + /> + - + handleFilterChange('maxAge', e.target.value ? parseInt(e.target.value) : undefined)} + style={styles.numberInput} + /> + +
+ {GENDER_OPTIONS.map(g => { + const selected = filters.gender?.split(',').includes(g); + return ( + + ); + })} +
+ + + {activeFiltersCount > 0 && ( + + )} +
+ + {/* Sort Bar */} +
+ Sort: + {(['score', 'distance', 'recent'] as const).map(s => ( + + ))} +
+ + {/* Loading */} + {loading && matches.length === 0 && ( +
Loading matches...
+ )} + + {/* Empty State */} + {!loading && matches.length === 0 && ( +
+
+ + + +
+
No matches found
+
+ {activeFiltersCount > 0 + ? 'Try adjusting your filters to see more profiles.' + : 'Scan profiles on the Discover page to build your match pool.'} +
+
+ )} + + {/* Profile Grid */} + {displayMatches.length > 0 && ( +
+ {displayMatches.map((profile, index) => ( +
setCardRef(profile.id, el)} + > + setSelectedProfileId(profile.id)} + index={index} + /> + {/* Score badge */} +
setHoveredScore(profile.id)} + onMouseLeave={() => setHoveredScore(null)} + > + {profile._score} pts + {profile.interactionStatus?.theirs === 'LIKED' && ' \u2764'} +
+ {/* Score tooltip */} + {hoveredScore === profile.id && profile._scoreBreakdown && ( +
+ {Object.entries(profile._scoreBreakdown).map(([key, val]) => ( +
+ {BREAKDOWN_LABELS[key] || key} + +{val} +
+ ))} + {Object.keys(profile._scoreBreakdown).length === 0 && ( +
No scoring factors
+ )} +
+ )} +
+ ))} +
+ )} + + {/* Load More */} + {hasMore && !loading && ( + + )} + {loading && matches.length > 0 && ( +
Loading more...
+ )} + + } + + {/* ===== API MATCHES MODE ===== */} + {matchMode === 'api' && ( + <> + {/* Loading */} + {(matchesLoading || connectionsLoading) && apiMatches.length === 0 && apiConnections.length === 0 && ( +
Loading API matches...
+ )} + + {/* Matches Section */} + {apiMatches.length > 0 && ( + <> +

+ Matches {apiMatchesPageInfo?.total != null && `(${apiMatchesPageInfo.total})`} +

+
+ {apiMatches.map((node: any, index: number) => ( +
+ node.profile?.id && setSelectedProfileId(node.profile.id)} + index={index} + /> + {node.chat?.streamChatId && ( +
+ {safeText(node.chat.status) || 'Matched'} +
+ )} + {node.chat?.streamChatId && ( +
+ +
+ )} +
+ ))} +
+ + )} + + {/* Divider between sections */} + {apiMatches.length > 0 && apiConnections.length > 0 && ( +
+ )} + + {/* Connections Section */} + {apiConnections.length > 0 && ( + <> +

+ Connections {apiConnectionsPageInfo?.total != null && `(${apiConnectionsPageInfo.total})`} +

+
+ {apiConnections.map((profile: any, index: number) => ( +
+ profile.id && setSelectedProfileId(profile.id)} + index={index} + /> +
+ ))} +
+ + )} + + {/* Empty State */} + {!matchesLoading && !connectionsLoading && apiMatches.length === 0 && apiConnections.length === 0 && ( +
+
+ + + +
+
No API matches found
+
+ No matches or connections returned from the Feeld API. +
+
+ )} + + )} + + {/* Profile Detail Modal */} + {selectedProfileId && ( + setSelectedProfileId(null)} + onMatch={() => { + removeMatch(selectedProfileId); + setSelectedProfileId(null); + }} + /> + )} +
+ ); +} diff --git a/web/src/pages/Messages.tsx b/web/src/pages/Messages.tsx index 7689fed..09fa01c 100755 --- a/web/src/pages/Messages.tsx +++ b/web/src/pages/Messages.tsx @@ -1,6 +1,8 @@ -import { useQuery } from '@apollo/client/react'; +import { useEffect, useState } from 'react'; +import { useQuery, useMutation } from '@apollo/client/react'; import { useNavigate } from 'react-router-dom'; import { LIST_SUMMARIES_QUERY } from '../api/operations/queries'; +import { CHATS_MARK_READ_MUTATION } from '../api/operations/mutations'; import { ChatListItem } from '../components/chat/ChatListItem'; import { LoadingPage } from '../components/ui/Loading'; @@ -11,6 +13,37 @@ export function MessagesPage() { variables: { limit: 30 }, }); + // Build a profileId -> discoveredLocation map from our cache + const [locationByProfileId, setLocationByProfileId] = useState>({}); + useEffect(() => { + fetch('/api/discovered-profiles') + .then(r => r.ok ? r.json() : null) + .then(d => { + if (!d?.profiles) return; + const map: Record = {}; + for (const p of d.profiles) { + if (p.id && typeof p.discoveredLocation === 'string' && p.discoveredLocation) { + map[p.id] = p.discoveredLocation; + } + } + setLocationByProfileId(map); + }) + .catch(() => {}); + }, []); + + const [chatsMarkRead, { loading: markingRead }] = useMutation(CHATS_MARK_READ_MUTATION); + + const handleMarkAllRead = async () => { + const chats = data?.summaries?.nodes || []; + const chatIds = chats.map((c: any) => c.id).filter(Boolean); + if (chatIds.length === 0) return; + try { + await chatsMarkRead({ variables: { input: { chatIds } } }); + } catch (err) { + console.error('Failed to mark chats as read:', err); + } + }; + if (loading) return ; if (error) { @@ -77,13 +110,43 @@ export function MessagesPage() { return (
{/* Header */} -
-

- Messages -

-

- {chats.length} {chats.length === 1 ? 'conversation' : 'conversations'} -

+
+
+

+ Messages +

+

+ {chats.length} {chats.length === 1 ? 'conversation' : 'conversations'} +

+
+
{/* Chat List */} @@ -95,7 +158,7 @@ export function MessagesPage() { style={{ animationDelay: `${(index + 1) * 30}ms` }} > { const params = new URLSearchParams(); if (chat.name) params.set('name', chat.name); diff --git a/web/src/pages/OkCupid.tsx b/web/src/pages/OkCupid.tsx new file mode 100644 index 0000000..7495ed5 --- /dev/null +++ b/web/src/pages/OkCupid.tsx @@ -0,0 +1,1376 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + getOkcToken, setOkcToken, + getMainSession, getStacks, getStack, getStackMatches, + getLikesIncoming, getRealLikesCount, getNotifications, getLikesCapInfo, + getMessages, getConversation, sendMessage, + vote, getProfile, +} from '../api/okcupid'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const safeText = (v: any): string => { + if (v == null) return ''; + if (typeof v === 'string') return v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + return ''; +}; + +function timeAgo(ms: number): string { + if (!ms) return ''; + const diff = Date.now() - ms; + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +// ─── OKC Detail Enums ──────────────────────────────────────────────────────── + +const BODY_TYPES: Record = { 1: 'Thin', 2: 'Overweight', 3: 'Skinny', 4: 'Average', 5: 'Fit', 6: 'Athletic', 7: 'Jacked', 8: 'A little extra', 9: 'Curvy', 10: 'Full-figured', 11: 'Rather not say', 20: 'Used up' }; +const REL_STATUS: Record = { 1: 'Single', 2: 'Seeing Someone', 3: 'Married', 4: "It's Complicated" }; +const REL_TYPE: Record = { 1: 'Monogamous', 2: 'Non-monogamous', 3: 'Open to either' }; +const DRINKING: Record = { 1: 'Very often', 2: 'Often', 3: 'Sometimes', 4: 'Rarely', 5: 'Never', 6: 'Desperately' }; +const SMOKING: Record = { 1: 'Yes', 2: 'Sometimes', 3: 'When drinking', 4: 'Trying to quit', 5: 'No' }; +const CHILDREN: Record = { 1: "Doesn't have kids", 2: 'Has kid(s)', 3: "Doesn't have kids, wants them", 4: "Has kid(s), wants more", 5: "Doesn't have kids, doesn't want", 6: "Has kid(s), doesn't want more", 7: "Doesn't have kids, might want", 8: "Has kid(s), might want more" }; +const POLITICS: Record = { 1: 'Liberal', 2: 'Moderate', 3: 'Conservative', 4: 'Other', 5: 'Apolitical' }; +const RELIGION_VAL: Record = { 1: 'Agnosticism', 2: 'Atheism', 3: 'Christianity', 4: 'Judaism', 5: 'Catholicism', 6: 'Islam', 7: 'Hinduism', 8: 'Buddhism', 9: 'Sikh', 10: 'Other' }; +const WEED: Record = { 14: 'Yes', 15: 'Sometimes', 16: 'Never' }; +const GENDERS: Record = { 0: 'Woman', 1: 'Man', 2: 'Agender', 3: 'Androgynous', 4: 'Bigender', 5: 'Cis Man', 6: 'Cis Woman', 7: 'Genderfluid', 8: 'Genderqueer', 9: 'Gender Nonconforming', 10: 'Hijra', 11: 'Intersex', 12: 'Non-binary', 13: 'Other', 14: 'Pangender', 15: 'Transfeminine', 16: 'Transgender', 17: 'Transmasculine', 18: 'Transsexual', 19: 'Trans Man', 20: 'Trans Woman', 21: 'Two Spirit' }; +const ORIENTATIONS: Record = { 1: 'Straight', 2: 'Bisexual', 3: 'Gay', 4: 'Lesbian', 5: 'Pansexual', 6: 'Queer', 7: 'Questioning', 8: 'Asexual', 9: 'Demisexual', 10: 'Heteroflexible', 11: 'Homoflexible', 12: 'Sapiosexual' }; + +// ─── Profile Detail Modal ──────────────────────────────────────────────────── + +function OkcProfileModal({ userId, stream, onClose, onVote }: { userId: string; stream?: string; onClose: () => void; onVote?: (id: string, type: string) => void }) { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [photoIdx, setPhotoIdx] = useState(0); + const [voting, setVoting] = useState(false); + + useEffect(() => { + setLoading(true); + setError(''); + setPhotoIdx(0); + getProfile(userId).then(data => { + setProfile(data); + }).catch(e => setError(e.message)).finally(() => setLoading(false)); + }, [userId]); + + const match = profile?.me?.match; + const user = match?.user; + const photos = user?.photos || []; + const essays = user?.essaysWithUniqueIds?.filter((e: any) => e.rawContent) || []; + const thread = profile?.me?.conversationThread; + + const handleVote = async (type: string) => { + if (voting) return; + setVoting(true); + try { + if (onVote) onVote(userId, type); + await vote(userId, type, stream); + onClose(); + } catch (e: any) { + setError(e.message); + } finally { + setVoting(false); + } + }; + + const detailItems: Array<{ label: string; value: string }> = []; + if (user) { + if (user.genders?.length) detailItems.push({ label: 'Gender', value: user.genders.map((g: number) => GENDERS[g] || g).join(', ') }); + if (user.orientations?.length) detailItems.push({ label: 'Orientation', value: user.orientations.map((o: number) => ORIENTATIONS[o] || o).join(', ') }); + if (user.pronounCategory) detailItems.push({ label: 'Pronouns', value: safeText(user.pronounCategory) }); + if (user.height) detailItems.push({ label: 'Height', value: `${Math.floor(user.height / 2.54 / 12)}'${Math.round(user.height / 2.54 % 12)}"` }); + if (user.bodyType) detailItems.push({ label: 'Body', value: BODY_TYPES[user.bodyType] || String(user.bodyType) }); + if (user.relationshipStatus) detailItems.push({ label: 'Status', value: REL_STATUS[user.relationshipStatus] || String(user.relationshipStatus) }); + if (user.relationshipType) detailItems.push({ label: 'Relationship', value: REL_TYPE[user.relationshipType] || String(user.relationshipType) }); + if (user.children) detailItems.push({ label: 'Children', value: CHILDREN[user.children] || String(user.children) }); + if (user.drinking) detailItems.push({ label: 'Drinking', value: DRINKING[user.drinking] || String(user.drinking) }); + if (user.smoking) detailItems.push({ label: 'Smoking', value: SMOKING[user.smoking] || String(user.smoking) }); + if (user.weed) detailItems.push({ label: 'Weed', value: WEED[user.weed] || String(user.weed) }); + if (user.politics) detailItems.push({ label: 'Politics', value: POLITICS[user.politics] || String(user.politics) }); + if (user.religion?.value) detailItems.push({ label: 'Religion', value: RELIGION_VAL[user.religion.value] || String(user.religion.value) }); + if (user.diet) detailItems.push({ label: 'Diet', value: safeText(user.diet) }); + if (user.pets?.length) detailItems.push({ label: 'Pets', value: user.pets.map(safeText).join(', ') }); + if (user.knownLanguages?.length) detailItems.push({ label: 'Languages', value: user.knownLanguages.map(safeText).join(', ') }); + if (user.education?.level || user.education?.school?.name) detailItems.push({ label: 'Education', value: [user.education.school?.name, user.education.level].filter(Boolean).map(safeText).join(' — ') }); + if (user.occupation?.title || user.occupation?.employer) detailItems.push({ label: 'Work', value: [user.occupation.title, user.occupation.employer].filter(Boolean).map(safeText).join(' at ') }); + if (user.badges?.length) detailItems.push({ label: 'Badges', value: user.badges.map((b: any) => safeText(b.name)).join(', ') }); + } + + return ( +
+
e.stopPropagation()} style={{ + width: '100%', maxWidth: '480px', maxHeight: '92dvh', + background: 'var(--color-deep, #1a1a1e)', + borderRadius: '20px 20px 0 0', + overflow: 'hidden', + display: 'flex', flexDirection: 'column', + }}> + {/* Drag handle */} +
+
+
+ + {loading ? ( +
Loading profile...
+ ) : error ? ( +
{error}
+ ) : !user ? ( +
Profile not found
+ ) : ( + <> + {/* Scrollable content */} +
+ {/* Photo carousel */} + {photos.length > 0 && ( +
+ + {/* Photo dots */} + {photos.length > 1 && ( +
+ {photos.map((_: any, idx: number) => ( +
+ )} + {/* Prev/Next tap zones */} + {photos.length > 1 && ( + <> +
setPhotoIdx(i => Math.max(0, i - 1))} style={{ position: 'absolute', inset: '0 50% 0 0', cursor: 'pointer' }} /> +
setPhotoIdx(i => Math.min(photos.length - 1, i + 1))} style={{ position: 'absolute', inset: '0 0 0 50%', cursor: 'pointer' }} /> + + )} + {/* Photo counter */} +
+ {photoIdx + 1}/{photos.length} +
+ {/* Match badge */} + {match?.matchPercent && ( +
+ {match.matchPercent}% Match +
+ )} +
+ )} + + {/* Name, age, location */} +
+
+ {safeText(user.displayname)} + {safeText(user.age)} + {user.isOnline && } + {user.selfieVerifiedStatus === 'VERIFIED' && ( + + + + )} +
+ {user.userLocation?.publicName && ( +
+ + + + {Array.isArray(user.userLocation.publicName) ? user.userLocation.publicName.join(', ') : safeText(user.userLocation.publicName)} +
+ )} +
+ + {/* Match info bar */} + {match && ( +
+ {match.targetLikes && ( + Likes You + )} + {match.targetLikeViaSpotlight && ( + Via Spotlight + )} + {match.targetLikeViaSuperBoost && ( + Via SuperBoost + )} + {match.senderVote === 'LIKE' && ( + You Liked + )} +
+ )} + + {/* Details grid */} + {detailItems.length > 0 && ( +
+
+ {detailItems.map(d => ( +
+
{d.label}
+
{d.value}
+
+ ))} +
+
+ )} + + {/* Essays / Prompts */} + {essays.length > 0 && ( +
+ {essays.map((e: any) => ( +
+
{safeText(e.title || e.groupTitle)}
+
{safeText(e.rawContent || e.processedContent)}
+
+ ))} +
+ )} + + {/* Existing messages */} + {thread?.messages?.length > 0 && ( +
+
Messages
+ {thread.messages.map((m: any) => ( +
+ {safeText(m.text)} +
+ ))} +
+ )} + + {/* Bottom padding for actions */} +
+
+ + {/* Fixed bottom actions */} +
+ + + +
+ + )} +
+
+ ); +} + +// ─── Styles ────────────────────────────────────────────────────────────────── + +const S = { + page: { + minHeight: '100vh', + background: 'var(--color-void, #0f0f14)', + color: '#fff', + fontFamily: 'var(--font-body, system-ui)', + } as React.CSSProperties, + header: { + padding: '20px 24px 0', + display: 'flex', + alignItems: 'center', + gap: '12px', + } as React.CSSProperties, + logo: { + width: '36px', + height: '36px', + borderRadius: '10px', + background: 'linear-gradient(135deg, #e64980, #d946ef)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 800, + fontSize: '14px', + color: '#fff', + fontFamily: 'var(--font-display, system-ui)', + flexShrink: 0, + } as React.CSSProperties, + title: { + fontFamily: 'var(--font-display, system-ui)', + fontSize: '22px', + fontWeight: 700, + color: '#fff', + } as React.CSSProperties, + tabs: { + display: 'flex', + gap: '2px', + padding: '16px 24px 0', + borderBottom: '1px solid rgba(255,255,255,0.06)', + } as React.CSSProperties, + tab: (active: boolean) => ({ + padding: '10px 18px', + fontSize: '14px', + fontWeight: active ? 600 : 500, + color: active ? '#e64980' : 'rgba(255,255,255,0.5)', + background: active ? 'rgba(230,73,128,0.1)' : 'transparent', + border: 'none', + borderBottom: active ? '2px solid #e64980' : '2px solid transparent', + borderRadius: '8px 8px 0 0', + cursor: 'pointer', + transition: 'all 200ms', + fontFamily: 'var(--font-body, system-ui)', + }) as React.CSSProperties, + content: { + padding: '20px 24px', + paddingBottom: '100px', + } as React.CSSProperties, + card: { + position: 'relative' as const, + borderRadius: '16px', + overflow: 'hidden', + cursor: 'pointer', + background: 'var(--color-surface, #1E1E22)', + border: '1px solid rgba(255,255,255,0.04)', + } as React.CSSProperties, + cardPhoto: { + width: '100%', + aspectRatio: '3/4', + objectFit: 'cover' as const, + display: 'block', + background: 'rgba(255,255,255,0.04)', + } as React.CSSProperties, + cardGradient: { + position: 'absolute' as const, + inset: 0, + background: 'linear-gradient(180deg, transparent 0%, transparent 50%, rgba(0,0,0,0.6) 75%, rgba(0,0,0,0.92) 100%)', + pointerEvents: 'none' as const, + } as React.CSSProperties, + cardInfo: { + position: 'absolute' as const, + bottom: 0, + left: 0, + right: 0, + padding: '16px', + } as React.CSSProperties, + cardRow: { + display: 'flex', + gap: '14px', + alignItems: 'center', + } as React.CSSProperties, + avatar: { + width: '56px', + height: '56px', + borderRadius: '14px', + objectFit: 'cover' as const, + background: 'rgba(255,255,255,0.06)', + flexShrink: 0, + } as React.CSSProperties, + avatarLg: { + width: '100%', + maxWidth: '360px', + aspectRatio: '1', + borderRadius: '16px', + objectFit: 'cover' as const, + background: 'rgba(255,255,255,0.06)', + } as React.CSSProperties, + name: { + fontWeight: 600, + fontSize: '18px', + fontFamily: 'var(--font-display, system-ui)', + color: '#fff', + letterSpacing: '-0.01em', + } as React.CSSProperties, + sub: { + fontSize: '12px', + color: 'rgba(255,255,255,0.6)', + marginTop: '2px', + } as React.CSSProperties, + badge: { + display: 'inline-block', + padding: '3px 10px', + borderRadius: '20px', + fontSize: '11px', + fontWeight: 600, + background: 'rgba(230,73,128,0.25)', + color: '#f4a5b0', + marginLeft: '8px', + } as React.CSSProperties, + cardActions: { + position: 'absolute' as const, + bottom: '12px', + right: '12px', + display: 'flex', + gap: '8px', + zIndex: 10, + } as React.CSSProperties, + cardActionBtn: (variant: 'like' | 'pass') => ({ + width: '44px', + height: '44px', + borderRadius: '50%', + border: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + backdropFilter: 'blur(8px)', + transition: 'all 200ms', + background: variant === 'like' ? 'rgba(34,197,94,0.8)' : 'rgba(100,116,139,0.7)', + color: '#fff', + boxShadow: '0 2px 8px rgba(0,0,0,0.3)', + }) as React.CSSProperties, + matchBadge: { + position: 'absolute' as const, + top: '10px', + left: '10px', + zIndex: 10, + padding: '4px 10px', + borderRadius: '20px', + fontSize: '11px', + fontWeight: 600, + background: 'rgba(230,73,128,0.85)', + color: '#fff', + backdropFilter: 'blur(8px)', + } as React.CSSProperties, + likesBadge: { + position: 'absolute' as const, + top: '10px', + left: '10px', + zIndex: 10, + padding: '4px 10px', + borderRadius: '20px', + fontSize: '11px', + fontWeight: 600, + background: 'rgba(34,197,94,0.85)', + color: '#fff', + backdropFilter: 'blur(8px)', + display: 'flex', + alignItems: 'center', + gap: '4px', + } as React.CSSProperties, + btn: (variant: 'primary' | 'secondary' | 'danger') => ({ + padding: '10px 20px', + borderRadius: '12px', + border: 'none', + fontWeight: 600, + fontSize: '14px', + cursor: 'pointer', + transition: 'all 200ms', + fontFamily: 'var(--font-body, system-ui)', + ...(variant === 'primary' ? { + background: 'linear-gradient(135deg, #e64980, #d946ef)', + color: '#fff', + } : variant === 'danger' ? { + background: 'rgba(239,68,68,0.15)', + color: '#ef4444', + } : { + background: 'rgba(255,255,255,0.08)', + color: 'rgba(255,255,255,0.7)', + }), + }) as React.CSSProperties, + empty: { + textAlign: 'center' as const, + padding: '40px 20px', + color: 'rgba(255,255,255,0.3)', + fontSize: '15px', + } as React.CSSProperties, + error: { + padding: '16px', + borderRadius: '12px', + background: 'rgba(239,68,68,0.1)', + border: '1px solid rgba(239,68,68,0.2)', + color: '#ef4444', + fontSize: '14px', + marginBottom: '16px', + } as React.CSSProperties, + loading: { + textAlign: 'center' as const, + padding: '40px', + color: 'rgba(255,255,255,0.4)', + fontSize: '14px', + } as React.CSSProperties, + grid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', + gap: '12px', + } as React.CSSProperties, + input: { + width: '100%', + padding: '12px 16px', + borderRadius: '12px', + border: '1px solid rgba(255,255,255,0.1)', + background: 'rgba(255,255,255,0.04)', + color: '#fff', + fontSize: '14px', + fontFamily: 'var(--font-body, system-ui)', + outline: 'none', + boxSizing: 'border-box' as const, + } as React.CSSProperties, + msgBubble: (isMine: boolean) => ({ + maxWidth: '75%', + padding: '10px 14px', + borderRadius: '14px', + fontSize: '14px', + lineHeight: '1.45', + marginBottom: '6px', + alignSelf: isMine ? 'flex-end' : 'flex-start', + background: isMine ? 'linear-gradient(135deg, #e64980, #d946ef)' : 'rgba(255,255,255,0.08)', + color: '#fff', + }) as React.CSSProperties, + stackChip: (active: boolean) => ({ + padding: '8px 16px', + borderRadius: '20px', + fontSize: '13px', + fontWeight: 600, + cursor: 'pointer', + border: active ? '1px solid #e64980' : '1px solid rgba(255,255,255,0.1)', + background: active ? 'rgba(230,73,128,0.15)' : 'rgba(255,255,255,0.04)', + color: active ? '#e64980' : 'rgba(255,255,255,0.6)', + whiteSpace: 'nowrap' as const, + transition: 'all 200ms', + }) as React.CSSProperties, + section: { + marginBottom: '24px', + } as React.CSSProperties, + sectionTitle: { + fontSize: '13px', + fontWeight: 600, + color: 'rgba(255,255,255,0.4)', + textTransform: 'uppercase' as const, + letterSpacing: '0.06em', + marginBottom: '12px', + } as React.CSSProperties, + profileActions: { + display: 'flex', + gap: '10px', + marginTop: '12px', + } as React.CSSProperties, +}; + +// ─── Discover Tab ──────────────────────────────────────────────────────────── + +function DiscoverTab() { + const [stacks, setStacks] = useState([]); + const [selectedStack, setSelectedStack] = useState(''); + const [profiles, setProfiles] = useState([]); + const [excludedIds, setExcludedIds] = useState([]); + const [likesInfo, setLikesInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [votingId, setVotingId] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [selectedProfileId, setSelectedProfileId] = useState(null); + const [expandedProfile, setExpandedProfile] = useState(null); + + const loadStacks = useCallback(async () => { + try { + setLoading(true); + setError(''); + const [stacksData, capData] = await Promise.all([getStacks(), getLikesCapInfo()]); + const s = stacksData?.me?.stacks || []; + setStacks(s); + if (s.length > 0 && !selectedStack) { + setSelectedStack(s[0].id); + } + setLikesInfo(capData?.me?.likesCap); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }, [selectedStack]); + + const [loadingMore, setLoadingMore] = useState(false); + + const loadStack = useCallback(async (stackId: string, append = false) => { + if (!stackId) return; + try { + if (append) setLoadingMore(true); else setLoading(true); + setError(''); + const currentIds = append ? [...excludedIds, ...profiles.map(m => m?.user?.id).filter(Boolean)] : excludedIds; + const data = await getStack(stackId, currentIds); + const stackData = data?.me?.stack?.data || []; + const matches = stackData + .filter((d: any) => d.__typename === 'StackMatch' && d.match?.user?.id) + .map((d: any) => ({ ...d.match, _stream: d.stream })); + if (append) { + setProfiles(prev => [...prev, ...matches]); + setExcludedIds(currentIds); + } else { + setProfiles(matches); + } + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, [excludedIds, profiles]); + + useEffect(() => { loadStacks(); }, []); + useEffect(() => { if (selectedStack) loadStack(selectedStack); }, [selectedStack]); + + const handleVote = async (targetId: string, type: 'LIKE' | 'PASS') => { + try { + setVotingId(targetId); + const profile = profiles.find(p => p?.user?.id === targetId); + const stream = profile?._stream; + const result = await vote(targetId, type, stream); + setExcludedIds(prev => [...prev, targetId]); + setProfiles(prev => prev.filter(p => p?.user?.id !== targetId)); + if (result?.userVote?.likesRemaining != null) { + setLikesInfo((prev: any) => prev ? { ...prev, likesRemaining: result.userVote.likesRemaining } : prev); + } + } catch (e: any) { + setError(e.message); + } finally { + setVotingId(null); + } + }; + + const handleExpand = async (userId: string) => { + if (expandedId === userId) { + setExpandedId(null); + setExpandedProfile(null); + return; + } + setExpandedId(userId); + try { + const data = await getProfile(userId); + setExpandedProfile(data?.me?.match); + } catch { + setExpandedProfile(null); + } + }; + + return ( +
+ {error &&
{error}
} + + {likesInfo && ( +
+ Likes remaining + + {safeText(likesInfo.likesRemaining)} / {safeText(likesInfo.likesCapTotal)} + +
+ )} + + {stacks.length > 0 && ( +
+ {stacks.map((s: any) => ( + + ))} + +
+ )} + + {loading ? ( +
Loading profiles...
+ ) : profiles.length === 0 ? ( +
No profiles in this stack. Try another or refresh.
+ ) : ( +
+ {profiles.map((match: any) => { + const user = match?.user; + if (!user) return null; + const photoUrl = user.primaryImage?.square800; + return ( +
setSelectedProfileId(user.id)}> + {photoUrl ? ( + + ) : ( +
+ + + +
+ )} +
+ + {match.matchPercent && ( +
{safeText(match.matchPercent)}%
+ )} + + {match.targetLikes && ( +
+ + + + Likes you +
+ )} + +
+
+ {safeText(user.displayname)} + {safeText(user.age)} +
+
{safeText(user.userLocation?.publicName)}
+
+ +
+ + +
+
+ ); + })} +
+ )} + + {profiles.length > 0 && !loading && ( +
+ +
+ )} + + {selectedProfileId && ( + p?.user?.id === selectedProfileId)?._stream} + onClose={() => setSelectedProfileId(null)} + onVote={(id, type) => { + setProfiles(prev => prev.filter(m => m?.user?.id !== id)); + setExcludedIds(prev => [...prev, id]); + }} + /> + )} +
+ ); +} + +// ─── Likes Tab ─────────────────────────────────────────────────────────────── + +function LikesTab() { + const [likes, setLikes] = useState([]); + const [selectedProfileId, setSelectedProfileId] = useState(null); + const [pageInfo, setPageInfo] = useState(null); + const [notifications, setNotifications] = useState(null); + const [realLikeCount, setRealLikeCount] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const load = useCallback(async (nextPageKey: string | null = null) => { + try { + setLoading(true); + setError(''); + const [likesData, notifData, realCount] = await Promise.all([ + getLikesIncoming(20, nextPageKey), + getNotifications(), + nextPageKey ? Promise.resolve(realLikeCount) : getRealLikesCount(), + ]); + const items = likesData?.me?.likesIncomingWithPreviews?.data || []; + setLikes(prev => nextPageKey ? [...prev, ...items] : items); + setPageInfo(likesData?.me?.likesIncomingWithPreviews?.pageInfo); + setNotifications(notifData?.me?.notificationCounts); + if (typeof realCount === 'number') setRealLikeCount(realCount); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, []); + + return ( +
+ {error &&
{error}
} + + {notifications && ( +
+
+
{realLikeCount}
+
Real likes
+
+
+
{safeText(notifications.likesIncoming)}
+
Views
+
+
+
{safeText(notifications.likesMutual)}
+
Mutual matches
+
+
+
{safeText(notifications.messages)}
+
Messages
+
+
+ )} + + {loading && likes.length === 0 ? ( +
Loading likes...
+ ) : likes.length === 0 ? ( +
No incoming likes yet.
+ ) : ( + <> +
+ {likes.map((item: any, i: number) => { + const user = item?.user; + const preview = item; + const img = user?.primaryImage?.square800 || preview?.primaryImage?.square800 || preview?.primaryImageBlurred?.square800; + const highlights = item?.matchHighlights || preview?.matchHighlights; + const matchPct = item?.matchPercent || highlights?.matchScore; + const location = highlights?.dynamicHighlight?.summary || user?.userLocation?.publicName; + const isOnline = user?.isOnline || highlights?.isOnline; + const isVerified = highlights?.isVerified; + const isSuperlike = item?.targetSuperlikes || preview?.targetSuperlikes; + const hasIntro = item?.hasFirstMessage || preview?.hasFirstMessage || highlights?.hasIntroMessage; + const viewedMe = item?.targetViewedMe || preview?.targetViewedMe; + const age = user?.age || highlights?.age; + const displayName = user?.displayname; + + // OKC puts real likes first, views after + const isRealLike = i < realLikeCount; + const isJustViewed = !isRealLike; + + return ( +
user?.id && setSelectedProfileId(user.id)}> + {img ? ( + + ) : ( +
+ + + +
+ )} +
+ + {/* Top-left badges */} +
+ {isRealLike && ( +
+ + + + Liked You +
+ )} + {isJustViewed && ( +
+ Viewed +
+ )} + {matchPct &&
{safeText(matchPct)}% Match
} + {isSuperlike && ( +
+ SuperLike +
+ )} +
+ + {/* Top-right badges */} +
+ {isVerified && ( +
+ + + +
+ )} + {isOnline && ( +
+ )} +
+ + {/* Bottom info */} +
+
+ {displayName ? safeText(displayName) : 'Hidden'} + {age && {safeText(age)}} +
+ + {location && ( +
+ + + + {safeText(location)} +
+ )} + + {/* Status tags */} +
+ {hasIntro && ( + + Intro + + )} + {viewedMe && ( + + Viewed + + )} +
+ + {item?.firstMessage && ( +
+ "{safeText(item.firstMessage.text)}" +
+ )} +
+
+ ); + })} +
+ {pageInfo?.hasMore && ( +
+ +
+ )} + + )} + + {selectedProfileId && ( + setSelectedProfileId(null)} + /> + )} +
+ ); +} + +// ─── Messages Tab ──────────────────────────────────────────────────────────── + +function MessagesTab() { + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [activeConvo, setActiveConvo] = useState(null); + const [messages, setMessages] = useState([]); + const [convoLoading, setConvoLoading] = useState(false); + const [convoCorrespondent, setConvoCorrespondent] = useState(null); + const [msgText, setMsgText] = useState(''); + const [sending, setSending] = useState(false); + const [myId, setMyId] = useState(''); + + const loadConversations = useCallback(async () => { + try { + setLoading(true); + setError(''); + const data = await getMessages('ALL'); + setConversations(data?.me?.conversationsAndMatches?.data || []); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadConversations(); + getMainSession().then(d => { if (d?.me?.id) setMyId(d.me.id); }).catch(() => {}); + }, []); + + const openConvo = async (targetId: string, correspondent: any) => { + try { + setActiveConvo(targetId); + setConvoLoading(true); + setConvoCorrespondent(correspondent); + const data = await getConversation(targetId); + setMessages(data?.me?.conversationThread?.messages || []); + } catch (e: any) { + setError(e.message); + } finally { + setConvoLoading(false); + } + }; + + const handleSend = async () => { + if (!msgText.trim() || !activeConvo) return; + try { + setSending(true); + await sendMessage(activeConvo, msgText.trim()); + setMsgText(''); + // Reload conversation + const data = await getConversation(activeConvo); + setMessages(data?.me?.conversationThread?.messages || []); + } catch (e: any) { + setError(e.message); + } finally { + setSending(false); + } + }; + + if (activeConvo) { + const corrUser = convoCorrespondent?.user; + return ( +
+
+ + {corrUser?.primaryImage?.square225 && ( + + )} +
{safeText(corrUser?.displayname)}
+
+ + {error &&
{error}
} + + {convoLoading ? ( +
Loading messages...
+ ) : ( +
+ {messages.map((msg: any) => { + const isMine = msg.senderId === myId; + return ( +
+
{safeText(msg.text)}
+
+ {timeAgo(msg.time)} +
+
+ ); + })} +
+ )} + +
+ setMsgText(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }} + /> + +
+
+ ); + } + + return ( +
+ {error &&
{error}
} + + {loading ? ( +
Loading conversations...
+ ) : conversations.length === 0 ? ( +
No conversations yet.
+ ) : ( + conversations.map((item: any, i: number) => { + const isConvo = item.__typename === 'Conversation'; + const isMutual = item.__typename === 'MutualMatch'; + const correspondent = isConvo ? item.correspondent : isMutual ? item.match : null; + const user = correspondent?.user; + if (!user) return null; + return ( +
openConvo(user.id, correspondent)} + > +
+ {user.primaryImage?.square225 && ( + + )} +
+
+
+ {safeText(user.displayname)} + {user.isOnline && Online} +
+ {isConvo && item.time && ( +
{timeAgo(item.time)}
+ )} +
+ {isConvo && item.snippet?.text && ( +
+ {item.isUnread && *} + {safeText(item.snippet.text).slice(0, 80)}{item.snippet.text?.length > 80 ? '...' : ''} +
+ )} + {isMutual && ( +
New match!
+ )} +
+
+
+ ); + }) + )} +
+ ); +} + +// ─── Profile Tab ───────────────────────────────────────────────────────────── + +function ProfileTab() { + const [session, setSession] = useState(null); + const [likesInfo, setLikesInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [tokenInput, setTokenInput] = useState(''); + const [tokenSaved, setTokenSaved] = useState(false); + + useEffect(() => { + (async () => { + try { + setLoading(true); + const [sessionData, capData] = await Promise.all([getMainSession(), getLikesCapInfo()]); + setSession(sessionData); + setLikesInfo(capData?.me?.likesCap); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + })(); + }, []); + + const handleSaveToken = () => { + const cleaned = tokenInput.trim(); + if (!cleaned) return; + setOkcToken(cleaned); + setTokenInput(''); + setTokenSaved(true); + setTimeout(() => setTokenSaved(false), 3000); + }; + + if (loading) return
Loading profile...
; + + const me = session?.me; + const xFields = me?.xMatchFields; + + return ( +
+ {error &&
{error}
} + +
+
Token Management
+
+
+ Paste a new JWT token from Proxyman. Current token: {getOkcToken().slice(0, 20)}... +
+
+ setTokenInput(e.target.value)} + /> + +
+ {tokenSaved &&
Token saved!
} +
+
+ + {me && ( +
+
Your Profile
+
+
+ {me.primaryImage?.square800 && ( + + )} +
+
{safeText(me.displayname)}, {safeText(me.age)}
+
{safeText(me.userLocation?.publicName)}
+
{safeText(me.emailAddress)}
+
+
+ +
+ {me.orientations?.length > 0 && ( +
Orientations: {me.orientations.map(safeText).join(', ')}
+ )} + {me.relationshipType &&
Relationship: {safeText(me.relationshipType)}
} + {me.joinDate &&
Joined: {new Date(me.joinDate).toLocaleDateString()}
} +
+
+
+ )} + + {xFields && ( +
+
Cross-Sell Profile Data
+
+
+ {xFields.firstName &&
Name: {safeText(xFields.firstName)}
} + {xFields.bio &&
Bio: {safeText(xFields.bio)}
} + {xFields.genderPresentation &&
Gender: {safeText(xFields.genderPresentation)}
} + {xFields.bodyType &&
Body: {safeText(xFields.bodyType)}
} + {xFields.heightInCm &&
Height: {safeText(xFields.heightInCm)}cm
} + {xFields.relationshipStatus &&
Status: {safeText(xFields.relationshipStatus)}
} + {xFields.relationshipIntent &&
Intent: {safeText(xFields.relationshipIntent)}
} + {xFields.lookingFor?.length > 0 &&
Looking for: {xFields.lookingFor.map(safeText).join(', ')}
} + {xFields.drinking &&
Drinking: {safeText(xFields.drinking)}
} + {xFields.smoking &&
Smoking: {safeText(xFields.smoking)}
} + {xFields.marijuana &&
Marijuana: {safeText(xFields.marijuana)}
} + {xFields.pets?.length > 0 &&
Pets: {xFields.pets.map(safeText).join(', ')}
} + {xFields.education &&
Education: {safeText(xFields.education)}
} + {xFields.jobTitle &&
Job: {safeText(xFields.jobTitle)}{xFields.jobCompany ? ` at ${safeText(xFields.jobCompany)}` : ''}
} +
+
+
+ )} + + {likesInfo && ( +
+
Likes Cap
+
+
+
+
{safeText(likesInfo.likesRemaining)}
+
Remaining
+
+
+
{safeText(likesInfo.likesCapTotal)}
+
Total
+
+
+
{safeText(likesInfo.viewCount)}
+
Views
+
+ {likesInfo.resetTime && ( +
+
{new Date(likesInfo.resetTime).toLocaleString()}
+
Resets
+
+ )} +
+
+
+ )} + + {me?.photos?.length > 0 && ( +
+
Your Photos
+
+ {me.photos.map((p: any) => ( + + ))} +
+
+ )} + +
+
Subscription Status
+
+
+ {[ + ['Basic', me?.ALIST_BASIC], + ['Premium', me?.ALIST_PREMIUM], + ['Premium+', me?.ALIST_PREMIUM_PLUS], + ['Incognito', me?.INCOGNITO], + ].map(([label, val]) => ( +
+ {label as string}: + {val ? 'Active' : 'No'} +
+ ))} +
+
+
+
+ ); +} + +// ─── Main Page ─────────────────────────────────────────────────────────────── + +type TabId = 'discover' | 'likes' | 'messages' | 'profile'; + +export function OkCupidPage() { + const [tab, setTab] = useState('discover'); + + const tabs: { id: TabId; label: string }[] = [ + { id: 'discover', label: 'Discover' }, + { id: 'likes', label: 'Likes' }, + { id: 'messages', label: 'Messages' }, + { id: 'profile', label: 'Profile' }, + ]; + + return ( +
+
+
OK
+
OkCupid
+
+ +
+ {tabs.map((t) => ( + + ))} +
+ +
+ {tab === 'discover' && } + {tab === 'likes' && } + {tab === 'messages' && } + {tab === 'profile' && } +
+
+ ); +} diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index 0cd6bcc..9b6dd97 100755 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation } from '@apollo/client/react'; import { PROFILE_QUERY } from '../api/operations/queries'; import { PROFILE_UPDATE_MUTATION } from '../api/operations/mutations'; -import { TEST_CREDENTIALS } from '../config/constants'; +import { authManager } from '../api/auth'; import { LoadingPage } from '../components/ui/Loading'; import { ProxiedImage } from '../components/ui/ProxiedImage'; import { getBestImageUrl } from '../utils/images'; @@ -455,13 +455,13 @@ export function ProfilePage() { }); const { data, loading, error, refetch } = useQuery(PROFILE_QUERY, { - variables: { profileId: TEST_CREDENTIALS.PROFILE_ID }, + variables: { profileId: authManager.getProfileId() }, fetchPolicy: 'network-only', // Always fetch from network, don't use cache }); const [updateProfile] = useMutation(PROFILE_UPDATE_MUTATION, { refetchQueries: [ - { query: PROFILE_QUERY, variables: { profileId: TEST_CREDENTIALS.PROFILE_ID } } + { query: PROFILE_QUERY, variables: { profileId: authManager.getProfileId() } } ], }); diff --git a/web/src/pages/SentPings.tsx b/web/src/pages/SentPings.tsx index 53c3352..51a39f5 100755 --- a/web/src/pages/SentPings.tsx +++ b/web/src/pages/SentPings.tsx @@ -209,12 +209,13 @@ const styles = { }, // Badge - badge: (variant: 'pending' | 'matched' | 'expired' | 'liked') => { + badge: (variant: 'pending' | 'matched' | 'expired' | 'liked' | 'declined') => { const colors = { pending: { bg: 'rgba(245,158,11,0.15)', color: '#fcd34d', border: 'rgba(245,158,11,0.3)' }, matched: { bg: 'rgba(34,197,94,0.15)', color: '#86efac', border: 'rgba(34,197,94,0.3)' }, expired: { bg: 'rgba(156,163,175,0.15)', color: '#9ca3af', border: 'rgba(156,163,175,0.3)' }, liked: { bg: 'rgba(190,49,68,0.15)', color: '#f4a5b0', border: 'rgba(190,49,68,0.3)' }, + declined: { bg: 'rgba(239,68,68,0.12)', color: '#f87171', border: 'rgba(239,68,68,0.25)' }, }; return { display: 'inline-flex', @@ -390,6 +391,7 @@ export function SentPingsPage() { const getStatusBadge = (ping: EnrichedSentPing) => { // Check if they liked back const theyLikedBack = ping.profile?.interactionStatus?.theirs === 'LIKED'; + const theyDeclined = ping.profile?.interactionStatus?.theirs === 'DISLIKED'; if (theyLikedBack) { return ( @@ -402,6 +404,17 @@ export function SentPingsPage() { ); } + if (theyDeclined) { + return ( + + + + + Declined + + ); + } + switch (ping.status) { case 'MATCHED': return ( diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 60dd301..e25697d 100755 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1,8 +1,7 @@ import { useQuery, useMutation } from '@apollo/client/react'; -import { DISCOVER_SEARCH_SETTINGS_QUERY, IS_INCOGNITO_QUERY } from '../api/operations/queries'; -import { DEVICE_LOCATION_UPDATE_MUTATION, SEARCH_SETTINGS_UPDATE_MUTATION } from '../api/operations/mutations'; -import { TEST_CREDENTIALS, getCredentials, setCredentials, clearCredentials } from '../config/constants'; -import { saveCredentials as syncCredentialsToServer, saveCustomLocation } from '../api/dataSync'; +import { DISCOVER_SEARCH_SETTINGS_QUERY, IS_INCOGNITO_QUERY, POPULAR_LOCATIONS_QUERY, APP_SETTINGS_QUERY, REDEEMED_OFFERS_QUERY, HAS_LINKED_REFLECTION_QUERY } from '../api/operations/queries'; +import { DEVICE_LOCATION_UPDATE_MUTATION, SEARCH_SETTINGS_UPDATE_MUTATION, PROFILE_LOCATION_UPDATE_MUTATION, APP_SETTINGS_UPDATE_MUTATION, SYNC_ACCOUNT_MUTATION, ACCOUNT_REDEEM_OFFER_MUTATION, ACCOUNT_DEACTIVATE_MUTATION, ACCOUNT_TERMINATE_MUTATION } from '../api/operations/mutations'; +import { saveCustomLocation } from '../api/dataSync'; import { useAuth } from '../hooks/useAuth'; import { authManager } from '../api/auth'; import type { AuthStatus } from '../api/auth'; @@ -220,10 +219,9 @@ export function SettingsPage() { const [tempDesiringFor, setTempDesiringFor] = useState([]); const [savingDesiringFor, setSavingDesiringFor] = useState(false); - // Auth credentials state - const currentCreds = getCredentials(); - const [profileIdInput, setProfileIdInput] = useState(currentCreds.PROFILE_ID); - const [refreshTokenInput, setRefreshTokenInput] = useState(currentCreds.REFRESH_TOKEN); + // Auth credentials state — backend is single source of truth + const [profileIdInput, setProfileIdInput] = useState(authManager.getProfileId()); + const [refreshTokenInput, setRefreshTokenInput] = useState(''); const [authStatus, setAuthStatus] = useState(authManager.getStatus()); const [authSaving, setAuthSaving] = useState(false); const [authMessage, setAuthMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -268,29 +266,160 @@ export function SettingsPage() { return () => clearInterval(interval); }, []); - // Seed token to backend on load + // Load current profile ID from backend on mount useEffect(() => { - const seedToken = async () => { - const creds = getCredentials(); - if (creds.REFRESH_TOKEN && creds.PROFILE_ID) { - try { - await fetch('/api/location-rotation/seed-token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - refreshToken: creds.REFRESH_TOKEN, - profileId: creds.PROFILE_ID, - analyticsId: creds.EVENT_ANALYTICS_ID, - }), - }); - } catch (e) {} - } - }; - seedToken(); + setProfileIdInput(authManager.getProfileId()); }, []); const [rotationError, setRotationError] = useState(null); + // Teleport state + const [teleportLoading, setTeleportLoading] = useState(false); + const [teleportStatus, setTeleportStatus] = useState(null); + const [teleportCity, setTeleportCity] = useState(null); + + // Notifications/Preferences state + const [syncingAccount, setSyncingAccount] = useState(false); + const [syncResult, setSyncResult] = useState(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) => { setRotationError(null); try { @@ -388,31 +517,34 @@ export function SettingsPage() { const profileId = profileIdInput.trim(); const refreshToken = refreshTokenInput.trim(); - // Save to localStorage - setCredentials({ profileId, refreshToken }); + if (!refreshToken) { + setAuthMessage({ type: 'error', text: 'Refresh token is required' }); + return; + } - // Sync to server for cross-browser persistence - await syncCredentialsToServer(profileId, refreshToken); + // Seed directly to backend — single source of truth + const resp = await fetch('/api/auth/seed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken, profileId }), + }); + + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${resp.status}`); + } await authManager.forceRefresh(); - setAuthMessage({ type: 'success', text: 'Credentials saved and synced to server!' }); - // Reload page to apply new credentials everywhere + setAuthMessage({ type: 'success', text: 'Credentials saved to server! All devices will use the new token.' }); + setRefreshTokenInput(''); setTimeout(() => window.location.reload(), 1500); } catch (err) { - setAuthMessage({ type: 'error', text: err instanceof Error ? err.message : 'Failed to refresh token' }); + setAuthMessage({ type: 'error', text: err instanceof Error ? err.message : 'Failed to save credentials' }); } finally { setAuthSaving(false); } }; - const handleResetCredentials = () => { - clearCredentials(); - const defaultCreds = getCredentials(); - setProfileIdInput(defaultCreds.PROFILE_ID); - setRefreshTokenInput(defaultCreds.REFRESH_TOKEN); - setAuthMessage({ type: 'success', text: 'Credentials reset to defaults. Reload to apply.' }); - }; - const handleRefreshToken = async () => { setAuthSaving(true); setAuthMessage(null); @@ -438,19 +570,20 @@ export function SettingsPage() { const { data: settingsData, loading: settingsLoading } = useQuery( DISCOVER_SEARCH_SETTINGS_QUERY, { - variables: { profileId: TEST_CREDENTIALS.PROFILE_ID }, + variables: { profileId: authManager.getProfileId() }, } ); const { data: incognitoData, loading: incognitoLoading } = useQuery( IS_INCOGNITO_QUERY, { - variables: { profileId: TEST_CREDENTIALS.PROFILE_ID }, + variables: { profileId: authManager.getProfileId() }, } ); const [updateLocation] = useMutation(DEVICE_LOCATION_UPDATE_MUTATION); const [updateSearchSettings] = useMutation(SEARCH_SETTINGS_UPDATE_MUTATION); + const [locationStatus, setLocationStatus] = useState<{ type: 'success' | 'error'; text: string } | null>(null); if (settingsLoading || incognitoLoading) return ; @@ -483,6 +616,8 @@ export function SettingsPage() { }, }); + setLocationStatus({ type: 'success', text: `Location set to ${result.displayName} — you'll appear to users in this area` }); + setTimeout(() => setLocationStatus(null), 4000); setSearchQuery(''); } else { setSearchError('Location not found. Try a different search.'); @@ -501,9 +636,10 @@ export function SettingsPage() { name: saved.name, }; setLocation(newLocation); + setLocationStatus(null); try { - const result = await updateLocation({ + await updateLocation({ variables: { input: { latitude: saved.latitude, @@ -511,15 +647,13 @@ export function SettingsPage() { }, }, }); - console.log('DeviceLocationUpdate response:', result.data); - - // Check if API actually updated the location - const deviceLocation = result.data?.deviceLocationUpdate?.location?.device; - if (deviceLocation && (deviceLocation.latitude === 0 && deviceLocation.longitude === 0)) { - console.warn('API returned 0,0 - location update may require premium/Majestic membership'); - } + // API returns 0,0 as privacy masking — location IS set server-side + setLocationStatus({ type: 'success', text: `Location set to ${saved.name} — you'll appear to users in this area` }); + setTimeout(() => setLocationStatus(null), 4000); } catch (error) { console.error('Failed to update location:', error); + setLocationStatus({ type: 'error', text: 'Failed to update location' }); + setTimeout(() => setLocationStatus(null), 4000); } }; @@ -742,6 +876,22 @@ export function SettingsPage() { })}
)} + + {/* Location status toast */} + {locationStatus && ( +
+ {locationStatus.text} +
+ )}
@@ -1263,6 +1413,422 @@ export function SettingsPage() {
+ {/* Teleport */} +
+
+
+
+ + + +
+

Teleport

+
+ + {/* Teleport Status Badge */} +
+ + {teleportCity ? ( + + Teleporting to {teleportCity} + + ) : ( + + Device Location + + )} + {teleportStatus === 'error' && ( +

Failed to update teleport location.

+ )} +
+ + {/* Popular Locations Grid */} +
+ +
+ {(popularLocationsData?.popularLocations || []).map((loc: any, i: number) => ( + + ))} +
+ {teleportLoading && ( +

Teleporting...

+ )} +
+ + {/* Reset to Device Location */} + + +

+ Teleport your profile to appear in a different city. Popular locations are provided by Feeld. Reset to return to your device location. +

+
+
+ + {/* Notifications & Preferences */} +
+
+
+
+ + + +
+

Notifications & Preferences

+
+ + {(() => { + const acct = appSettingsData?.account; + const appSettings = acct?.appSettings; + if (!appSettings) return

Loading notification settings...

; + + 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 ( + <> +
+ {toggles.map(({ label, field, value }) => ( +
handleToggleNotification(field, value)} + > +
+

{label}

+
+
+
+
+
+ ))} +
+ + {/* Distance Units */} +
handleToggleNotification('isDistanceInMiles', !!acct?.isDistanceInMiles)} + > +
+

Distance in Miles

+

+ {acct?.isDistanceInMiles ? 'Using miles' : 'Using kilometers'} +

+
+
+
+
+
+ + {/* Sync Account */} + + {syncResult && ( +

{syncResult}

+ )} + + ); + })()} + +

+ Toggle notification preferences for your account. Changes are saved immediately. Sync Account refreshes your subscription status from the server. +

+
+
+ + {/* Account Management */} +
+
+
+
+ + + +
+

Account

+
+ + {/* Subscription Status */} +
+ +
+ + {appSettingsData?.account?.isMajestic ? 'Majestic' : 'Free'} + + {appSettingsData?.account?.isUplift && ( + Uplift Active + )} + + {appSettingsData?.account?.availablePings ?? '?'} Pings Available + +
+
+ + {/* Reflect Link Status */} +
+ + + {linkedReflectionData?.hasLinkedReflection ? 'Linked' : 'Not Linked'} + +
+ + {/* Redeemed Offers */} +
+ + {(redeemedOffersData?.account?.redeemedOffers || []).length > 0 ? ( +
+ {(redeemedOffersData?.account?.redeemedOffers || []).map((offer: any, i: number) => ( +
+

{offer.offerName}

+ {offer.redeemedAt && ( +

+ Redeemed: {new Date(offer.redeemedAt).toLocaleDateString()} +

+ )} +
+ ))} +
+ ) : ( +

No offers redeemed

+ )} +
+ + {/* Redeem Offer */} +
+ +
+ setOfferCode(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleRedeemOffer()} + placeholder="Enter offer code..." + style={styles.input} + /> + +
+ {offerResult && ( +

+ {offerResult.text} +

+ )} +
+ + {/* Pause Account */} +
+ {!pauseConfirm ? ( + + ) : ( +
+

+ Are you sure you want to pause your account? Your profile will be hidden. +

+
+ + +
+
+ )} +
+ + {/* Delete Account */} +
+ {deleteConfirm === 0 && ( + + )} + {deleteConfirm === 1 && ( +
+

+ This will permanently delete your account. This action cannot be undone. Are you sure? +

+
+ + +
+
+ )} + {deleteConfirm === 2 && ( +
+

+ FINAL WARNING: This is irreversible. Your account, matches, and messages will be permanently deleted. +

+
+ + +
+
+ )} +
+ + {/* Account Action Result */} + {accountActionResult && ( +
+ {accountActionResult.text} +
+ )} + +

+ Pausing hides your profile temporarily. Deleting permanently removes your account, all data, matches, and messages. +

+
+
+ {/* Auth Credentials */}
@@ -1314,15 +1880,15 @@ export function SettingsPage() {

- {currentCreds.PROFILE_ID} + {authManager.getProfileId()}

- {/* Current Refresh Token */} + {/* Refresh Token (managed by backend) */}

- {currentCreds.REFRESH_TOKEN} + Managed by backend server

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