diff --git a/api/index.js b/api/index.js index db19924..824fd1e 100644 --- a/api/index.js +++ b/api/index.js @@ -1,11 +1,65 @@ const express = require('express'); const cors = require('cors'); const { GoogleGenerativeAI } = require('@google/generative-ai'); +const { createClient } = require('@supabase/supabase-js'); const app = express(); app.use(cors()); app.use(express.json()); +const GUEST_WEEKLY_LIMIT = 5; +const AUTH_WEEKLY_LIMIT = 15; +const REFERRAL_BONUS = 10; + +const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY); +const supabaseAdmin = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); + +async function upstash(path, method = 'POST') { + const res = await fetch(`${process.env.UPSTASH_REDIS_REST_URL}${path}`, { + method, + headers: { Authorization: `Bearer ${process.env.UPSTASH_REDIS_REST_TOKEN}` }, + }); + return res.json(); +} + +async function checkRateLimit(ip) { + const key = `fmp:rl:${ip}`; + const { result: count } = await upstash(`/incr/${key}`); + if (count === 1) await upstash(`/expire/${key}/604800`); + return count <= GUEST_WEEKLY_LIMIT; +} + +async function checkAuthRateLimit(userId, bonusSearches = 0) { + const key = `fmp:rl:user:${userId}`; + const { result: count } = await upstash(`/incr/${key}`); + if (count === 1) await upstash(`/expire/${key}/604800`); + return count <= AUTH_WEEKLY_LIMIT + bonusSearches; +} + +async function getAuthenticatedUser(authHeader) { + if (!authHeader?.startsWith('Bearer ')) return null; + try { + const { data: { user }, error } = await supabase.auth.getUser(authHeader.slice(7)); + if (error || !user) return null; + return user; + } catch { + return null; + } +} + +function generateReferralCode(userId) { + return userId.replace(/-/g, '').slice(0, 8); +} + +async function getBonusSearches(userId) { + const { data } = await supabase + .from('bonus_searches') + .select('bonus_count') + .eq('user_id', userId) + .single(); + return data?.bonus_count || 0; +} + const gemini = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); const SYSTEM_PROMPT = `You are FindMyPro's AI assistant — a calm, helpful guide that connects people with the right professional. You are NOT a lawyer, doctor, or financial advisor. You help people FIND the right one. @@ -197,6 +251,23 @@ async function searchWithSerper(query) { app.post('/api/search', async (req, res) => { try { + const user = await getAuthenticatedUser(req.headers.authorization); + if (!user) { + const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim() + || req.socket?.remoteAddress + || 'unknown'; + const allowed = await checkRateLimit(ip); + if (!allowed) { + return res.status(429).json({ error: 'weekly_limit_reached', limit: GUEST_WEEKLY_LIMIT }); + } + } else { + const bonus = await getBonusSearches(user.id); + const allowed = await checkAuthRateLimit(user.id, bonus); + if (!allowed) { + return res.status(429).json({ error: 'weekly_limit_reached', limit: AUTH_WEEKLY_LIMIT + bonus }); + } + } + const { queries } = req.body; const results = await Promise.all( @@ -213,4 +284,89 @@ app.post('/api/search', async (req, res) => { } }); +/* ─── Referral endpoints ───────────────────────────────── */ + +app.get('/api/referral/info', async (req, res) => { + try { + const user = await getAuthenticatedUser(req.headers.authorization); + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + + const referralCode = generateReferralCode(user.id); + + const { data: referrals } = await supabase + .from('referrals') + .select('id, rewarded, created_at') + .eq('referrer_id', user.id); + + const successfulReferrals = (referrals || []).filter(r => r.rewarded).length; + const bonusSearches = successfulReferrals * REFERRAL_BONUS; + + res.json({ + referralCode, + referralLink: `https://findmyspecialist.vercel.app?ref=${referralCode}`, + totalReferrals: (referrals || []).length, + successfulReferrals, + bonusSearches, + }); + } catch (error) { + console.error('Referral info error:', error); + res.status(500).json({ error: 'Failed to fetch referral info' }); + } +}); + +app.post('/api/referral/claim', async (req, res) => { + try { + const user = await getAuthenticatedUser(req.headers.authorization); + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + + const { referralCode } = req.body; + if (!referralCode) return res.status(400).json({ error: 'No referral code provided' }); + + const { data: allUsers } = await supabaseAdmin.auth.admin.listUsers(); + const referrer = (allUsers?.users || []).find( + u => generateReferralCode(u.id) === referralCode + ); + + if (!referrer) return res.status(400).json({ error: 'Invalid referral code' }); + if (referrer.id === user.id) return res.status(400).json({ error: 'Cannot refer yourself' }); + + const { data: existing } = await supabase + .from('referrals') + .select('id') + .eq('referred_user_id', user.id) + .single(); + + if (existing) return res.status(400).json({ error: 'Already referred' }); + + const { error: insertErr } = await supabase + .from('referrals') + .insert({ referrer_id: referrer.id, referred_user_id: user.id, rewarded: true }); + + if (insertErr) throw insertErr; + + const currentBonus = await getBonusSearches(referrer.id); + await supabase + .from('bonus_searches') + .upsert({ user_id: referrer.id, bonus_count: currentBonus + REFERRAL_BONUS, updated_at: new Date().toISOString() }); + + res.json({ success: true }); + } catch (error) { + console.error('Referral claim error:', error); + res.status(500).json({ error: 'Failed to process referral' }); + } +}); + +app.get('/api/referral/validate/:code', async (req, res) => { + try { + const { code } = req.params; + const { data: allUsers } = await supabaseAdmin.auth.admin.listUsers(); + const referrer = (allUsers?.users || []).find( + u => generateReferralCode(u.id) === code + ); + res.json({ valid: !!referrer }); + } catch { + res.json({ valid: false }); + } +}); + module.exports = app; diff --git a/client/src/App.jsx b/client/src/App.jsx index 27ba527..fa52a05 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -11,7 +11,9 @@ const API_URL = import.meta.env.DEV ? 'http://localhost:3001/api' : '/api' const STORAGE_KEY = 'findmypro_session'; const SESSIONS_KEY = 'findmypro_sessions'; const USAGE_KEY = 'findmypro_usage'; +const REFERRAL_KEY = 'findmypro_referral'; const WEEKLY_LIMIT = 5; +const AUTH_WEEKLY_LIMIT = 15; const SUGGESTIONS = [ { kind: 'Legal', text: "I got into a car accident in Chicago and my back hurts" }, @@ -641,7 +643,7 @@ function GateModal({ onClose, onShowAuth }) {

You've used your {WEEKLY_LIMIT} free searches this week

- Create a free account for unlimited searches and to save your conversation history across devices. + Create a free account for {AUTH_WEEKLY_LIMIT} searches per week (plus bonus searches from referrals) and save your conversation history across devices.

+
+ +
+
+ {info?.successfulReferrals || 0} + Successful referrals +
+
+ +{info?.bonusSearches || 0} + Bonus searches/week +
+
+ + + + + ); +} + /* ─── Auth controls ──────────────────────────────────── */ -function AuthControls({ session, onShowAuth, onSignOut }) { +function AuthControls({ session, onShowAuth, onSignOut, onShowReferral }) { const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); const name = userName(session); @@ -686,6 +760,9 @@ function AuthControls({ session, onShowAuth, onSignOut }) { {menuOpen && (
{session.user.email} + @@ -723,6 +800,7 @@ function App() { const [sidebarOpen, setSidebarOpen] = useState(false); const [gateOpen, setGateOpen] = useState(false); const [authModal, setAuthModal] = useState(null); // null | 'signin' | 'signup' + const [referralOpen, setReferralOpen] = useState(false); const [darkMode, setDarkMode] = useState(() => { const saved = localStorage.getItem('findmypro_theme'); if (saved) return saved === 'dark'; @@ -734,12 +812,38 @@ function App() { const resultsRef = useRef(null); const prevResultsRef = useRef(null); + // Capture referral code from URL on mount + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const ref = params.get('ref'); + if (ref) { + localStorage.setItem(REFERRAL_KEY, ref); + window.history.replaceState({}, '', window.location.pathname); + } + }, []); + // Supabase auth state useEffect(() => { supabase.auth.getSession().then(({ data: { session } }) => setSupaSession(session)); const { data: { subscription } } = supabase.auth.onAuthStateChange((_e, session) => { setSupaSession(session); - if (session) setAuthModal(null); // close modal on successful sign-in + if (session) { + setAuthModal(null); + // Claim referral if one exists in localStorage + const storedRef = localStorage.getItem(REFERRAL_KEY); + if (storedRef) { + fetch(`${API_URL}/referral/claim`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ referralCode: storedRef }), + }).finally(() => { + localStorage.removeItem(REFERRAL_KEY); + }); + } + } }); return () => subscription.unsubscribe(); }, []); @@ -1027,6 +1131,7 @@ function App() { setReferralOpen(true)} onSignOut={async () => { await supabase.auth.signOut(); setSupaSession(null); @@ -1134,6 +1239,7 @@ function App() { {authModal && setAuthModal(null)} />} {gateOpen && setGateOpen(false)} onShowAuth={setAuthModal} />} + {referralOpen && setReferralOpen(false)} />} {toast &&
{toast}
} ); diff --git a/client/src/styles.css b/client/src/styles.css index 0a2c06d..79d34e9 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -1729,3 +1729,35 @@ body::after { .about-main { padding: 40px 20px 60px; gap: 48px; } .about-bio-card { flex-direction: column; } } + +/* ─── Referral panel ─────────────────────────────────── */ + +.referral-modal { + max-width: 420px; +} +.referral-link-box { + margin: 16px 0; + width: 100%; +} +.referral-stats { + display: flex; + gap: 24px; + justify-content: center; + margin-top: 16px; +} +.referral-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} +.referral-stat-num { + font-size: 1.5rem; + font-weight: 700; + color: var(--accent-deep); +} +.referral-stat-label { + font-size: 0.78rem; + color: var(--ink-3); + letter-spacing: 0.02em; +} diff --git a/server/index.js b/server/index.js index 5df81d6..d2afe83 100644 --- a/server/index.js +++ b/server/index.js @@ -11,7 +11,9 @@ const app = express(); app.use(cors()); app.use(express.json()); -const WEEKLY_LIMIT = 5; +const GUEST_WEEKLY_LIMIT = 5; +const AUTH_WEEKLY_LIMIT = 15; +const REFERRAL_BONUS = 10; async function upstash(path, method = 'POST') { const res = await fetch(`${process.env.UPSTASH_REDIS_REST_URL}${path}`, { @@ -24,22 +26,49 @@ async function upstash(path, method = 'POST') { async function checkRateLimit(ip) { const key = `fmp:rl:${ip}`; const { result: count } = await upstash(`/incr/${key}`); - if (count === 1) await upstash(`/expire/${key}/604800`); // 7-day TTL - return count <= WEEKLY_LIMIT; + if (count === 1) await upstash(`/expire/${key}/604800`); + return count <= GUEST_WEEKLY_LIMIT; +} + +async function checkAuthRateLimit(userId, bonusSearches = 0) { + const key = `fmp:rl:user:${userId}`; + const { result: count } = await upstash(`/incr/${key}`); + if (count === 1) await upstash(`/expire/${key}/604800`); + return count <= AUTH_WEEKLY_LIMIT + bonusSearches; } const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY); +const supabaseAdmin = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); -async function isAuthenticated(authHeader) { - if (!authHeader?.startsWith('Bearer ')) return false; +async function getAuthenticatedUser(authHeader) { + if (!authHeader?.startsWith('Bearer ')) return null; try { const { data: { user }, error } = await supabase.auth.getUser(authHeader.slice(7)); - return !error && !!user; + if (error || !user) return null; + return user; } catch { - return false; + return null; } } +async function isAuthenticated(authHeader) { + return !!(await getAuthenticatedUser(authHeader)); +} + +function generateReferralCode(userId) { + const hash = userId.replace(/-/g, '').slice(0, 8); + return hash; +} + +async function getBonusSearches(userId) { + const { data } = await supabase + .from('bonus_searches') + .select('bonus_count') + .eq('user_id', userId) + .single(); + return data?.bonus_count || 0; +} + const gemini = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); const SYSTEM_PROMPT = `You are FindMyPro's AI assistant — a calm, helpful guide that connects people with the right professional. You are NOT a lawyer, doctor, or financial advisor. You help people FIND the right one. @@ -267,14 +296,20 @@ async function searchWithGemini(query) { app.post('/api/search', async (req, res) => { try { - const authed = await isAuthenticated(req.headers.authorization); - if (!authed) { + const user = await getAuthenticatedUser(req.headers.authorization); + if (!user) { const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket?.remoteAddress || 'unknown'; const allowed = await checkRateLimit(ip); if (!allowed) { - return res.status(429).json({ error: 'weekly_limit_reached', limit: WEEKLY_LIMIT }); + return res.status(429).json({ error: 'weekly_limit_reached', limit: GUEST_WEEKLY_LIMIT }); + } + } else { + const bonus = await getBonusSearches(user.id); + const allowed = await checkAuthRateLimit(user.id, bonus); + if (!allowed) { + return res.status(429).json({ error: 'weekly_limit_reached', limit: AUTH_WEEKLY_LIMIT + bonus }); } } @@ -282,8 +317,6 @@ app.post('/api/search', async (req, res) => { const results = await Promise.all( queries.map(async ({ query, label, reason }) => { - // Use Gemini if USE_GEMINI=true in .env (requires GCP billing enabled) - // otherwise uses Serper (default) let items = []; if (process.env.USE_GEMINI === 'true') { try { @@ -307,6 +340,94 @@ app.post('/api/search', async (req, res) => { } }); +/* ─── Referral endpoints ───────────────────────────────── */ + +app.get('/api/referral/info', async (req, res) => { + try { + const user = await getAuthenticatedUser(req.headers.authorization); + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + + const referralCode = generateReferralCode(user.id); + + const { data: referrals } = await supabase + .from('referrals') + .select('id, rewarded, created_at') + .eq('referrer_id', user.id); + + const successfulReferrals = (referrals || []).filter(r => r.rewarded).length; + const bonusSearches = successfulReferrals * REFERRAL_BONUS; + + res.json({ + referralCode, + referralLink: `https://findmyspecialist.vercel.app?ref=${referralCode}`, + totalReferrals: (referrals || []).length, + successfulReferrals, + bonusSearches, + }); + } catch (error) { + console.error('Referral info error:', error); + res.status(500).json({ error: 'Failed to fetch referral info' }); + } +}); + +app.post('/api/referral/claim', async (req, res) => { + try { + const user = await getAuthenticatedUser(req.headers.authorization); + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + + const { referralCode } = req.body; + if (!referralCode) return res.status(400).json({ error: 'No referral code provided' }); + + // Find the referrer by their code (first 8 chars of UUID without dashes) + const { data: allUsers } = await supabaseAdmin.auth.admin.listUsers(); + const referrer = (allUsers?.users || []).find( + u => generateReferralCode(u.id) === referralCode + ); + + if (!referrer) return res.status(400).json({ error: 'Invalid referral code' }); + if (referrer.id === user.id) return res.status(400).json({ error: 'Cannot refer yourself' }); + + // Check if this user was already referred + const { data: existing } = await supabase + .from('referrals') + .select('id') + .eq('referred_user_id', user.id) + .single(); + + if (existing) return res.status(400).json({ error: 'Already referred' }); + + // Insert referral record + const { error: insertErr } = await supabase + .from('referrals') + .insert({ referrer_id: referrer.id, referred_user_id: user.id, rewarded: true }); + + if (insertErr) throw insertErr; + + // Add bonus searches to referrer + const currentBonus = await getBonusSearches(referrer.id); + await supabase + .from('bonus_searches') + .upsert({ user_id: referrer.id, bonus_count: currentBonus + REFERRAL_BONUS, updated_at: new Date().toISOString() }); + + res.json({ success: true }); + } catch (error) { + console.error('Referral claim error:', error); + res.status(500).json({ error: 'Failed to process referral' }); + } +}); + +app.get('/api/referral/validate/:code', async (req, res) => { + try { + const { code } = req.params; + const { data: allUsers } = await supabaseAdmin.auth.admin.listUsers(); + const referrer = (allUsers?.users || []).find( + u => generateReferralCode(u.id) === code + ); + res.json({ valid: !!referrer }); + } catch { + res.json({ valid: false }); + } +}); const PORT = process.env.PORT || 3001; app.listen(PORT, () => { diff --git a/server/package-lock.json b/server/package-lock.json index bca940d..d2bf9d6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.95.1", "@google/generative-ai": "^0.24.1", + "@supabase/supabase-js": "^2.106.2", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", @@ -62,6 +63,90 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz", + "integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz", + "integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", + "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz", + "integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz", + "integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz", + "integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.106.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz", + "integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.106.2", + "@supabase/functions-js": "2.106.2", + "@supabase/postgrest-js": "2.106.2", + "@supabase/realtime-js": "2.106.2", + "@supabase/storage-js": "2.106.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -496,6 +581,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -944,6 +1038,12 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/server/package.json b/server/package.json index 02b793f..4984668 100644 --- a/server/package.json +++ b/server/package.json @@ -13,6 +13,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.95.1", "@google/generative-ai": "^0.24.1", + "@supabase/supabase-js": "^2.106.2", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1",