Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions api/index.js
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +29 to +36
}

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.
Expand Down Expand Up @@ -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(
Expand 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
);
Comment on lines +325 to +328

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 });
Comment on lines +341 to +343

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() });
Comment on lines +347 to +350

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;
112 changes: 109 additions & 3 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -641,7 +643,7 @@ function GateModal({ onClose, onShowAuth }) {
<CompassIcon size={34} />
<h2 className="gate-title">You've used your {WEEKLY_LIMIT} free searches this week</h2>
<p className="gate-text">
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.
</p>
<div className="gate-actions">
<button className="cta-btn" onClick={() => { onClose(); onShowAuth('signup'); }}>
Expand All @@ -657,9 +659,81 @@ function GateModal({ onClose, onShowAuth }) {
);
}

/* ─── Referral Panel ────────────────────────────────────── */

function ReferralPanel({ session, onClose }) {
const [info, setInfo] = useState(null);
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(true);

useEffect(() => {
if (!session) return;
fetch(`${API_URL}/referral/info`, {
headers: { Authorization: `Bearer ${session.access_token}` },
})
.then(r => r.json())
.then(data => { setInfo(data); setLoading(false); })
.catch(() => setLoading(false));
}, [session]);

const copyLink = () => {
if (!info?.referralLink) return;
navigator.clipboard.writeText(info.referralLink).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};

if (loading) return (
<div className="gate-overlay" onClick={onClose}>
<div className="gate-modal" onClick={e => e.stopPropagation()}>
<p>Loading...</p>
</div>
</div>
);

return (
<div className="gate-overlay" onClick={onClose}>
<div className="gate-modal referral-modal" onClick={e => e.stopPropagation()}>
<CompassIcon size={34} />
<h2 className="gate-title">Refer a Friend</h2>
<p className="gate-text">
Share your link — when someone signs up, you get <strong>+10 bonus searches/week</strong>.
</p>

<div className="referral-link-box">
<input
type="text"
readOnly
value={info?.referralLink || ''}
className="auth-input"
style={{ fontSize: '0.82rem', marginBottom: 0 }}
/>
<button className="cta-btn" onClick={copyLink} style={{ marginTop: 8, width: '100%', justifyContent: 'center' }}>
{copied ? 'Copied!' : 'Copy referral link'}
</button>
</div>

<div className="referral-stats">
<div className="referral-stat">
<span className="referral-stat-num">{info?.successfulReferrals || 0}</span>
<span className="referral-stat-label">Successful referrals</span>
</div>
<div className="referral-stat">
<span className="referral-stat-num">+{info?.bonusSearches || 0}</span>
<span className="referral-stat-label">Bonus searches/week</span>
</div>
</div>

<button className="ghost-btn" onClick={onClose} style={{ marginTop: 12 }}>Close</button>
</div>
</div>
);
}

/* ─── 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);
Expand All @@ -686,6 +760,9 @@ function AuthControls({ session, onShowAuth, onSignOut }) {
{menuOpen && (
<div className="avatar-menu">
<span className="avatar-menu-email">{session.user.email}</span>
<button className="avatar-menu-item" onClick={() => { setMenuOpen(false); onShowReferral(); }}>
Refer a Friend
</button>
<button className="avatar-menu-item" onClick={() => { setMenuOpen(false); onSignOut(); }}>
Sign out
</button>
Expand Down Expand Up @@ -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';
Expand All @@ -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);
Comment on lines +832 to +843
});
Comment on lines +842 to +844
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve stored referral when claim request fails

The client deletes findmypro_referral in finally, so transient failures (network error, 5xx, or temporary auth issue) permanently drop the referral before it is successfully claimed. In that case the user has no retry path and the referral bonus is silently lost. Only clear the stored code after a confirmed successful claim response.

Useful? React with 👍 / 👎.

}
}
});
return () => subscription.unsubscribe();
}, []);
Expand Down Expand Up @@ -1027,6 +1131,7 @@ function App() {
<AuthControls
session={supaSession}
onShowAuth={setAuthModal}
onShowReferral={() => setReferralOpen(true)}
onSignOut={async () => {
await supabase.auth.signOut();
setSupaSession(null);
Expand Down Expand Up @@ -1134,6 +1239,7 @@ function App() {

{authModal && <AuthModal initialTab={authModal} onClose={() => setAuthModal(null)} />}
{gateOpen && <GateModal onClose={() => setGateOpen(false)} onShowAuth={setAuthModal} />}
{referralOpen && <ReferralPanel session={supaSession} onClose={() => setReferralOpen(false)} />}
{toast && <div className="toast">{toast}</div>}
</>
);
Expand Down
32 changes: 32 additions & 0 deletions client/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading