diff --git a/.gitignore b/.gitignore
index c7d909c..9d56059 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ data/github-so-geo.json
data/fetch-progress.json
data/geocode-cache.json
data/pipeline.log
+data/activation-campaign.json
app/promo/
public/promo/
*.log
diff --git a/README.md b/README.md
index 4f4ddee..001bd03 100644
--- a/README.md
+++ b/README.md
@@ -226,6 +226,14 @@ See the [lifecycle email PRD](docs/prd/lifecycle-email-notifications.md).
Verified users can explicitly opt in to a Monday weekly digest from the user menu. The digest includes current global and country rankings, rank movement since the previous digest, current DevGlobe features, and an Explore DevGlobe link. Vercel invokes `/api/cron/weekly-digest` at 13:00 UTC each Monday; only verified contacts with `productUpdatesEnabled: true` are eligible. Each message uses a per-user, per-week idempotency key and includes one-click unsubscribe headers and a signed unsubscribe link.
+Generate a manual-review activation queue and weekly social spotlight from public, unclaimed profiles:
+
+```bash
+npm run activation-campaign -- --limit=100 --output=data/activation-campaign.json
+```
+
+The command is read-only against Cosmos DB and never sends messages or retrieves private contact details. Review each draft before contacting a developer through an appropriate public channel.
+
### Live developer activity
The Activity tab is anonymous and shows a rolling 24-hour feed for indexed developers. Create its dedicated Cosmos container before deployment:
diff --git a/app/api/auth/callback/route.js b/app/api/auth/callback/route.js
index d908f97..70161b4 100644
--- a/app/api/auth/callback/route.js
+++ b/app/api/auth/callback/route.js
@@ -86,7 +86,9 @@ export async function GET(request) {
const token = await createSessionToken(session);
const cookie = buildSessionCookie(token);
- const response = NextResponse.redirect(baseUrl);
+ const successUrl = new URL(baseUrl);
+ successUrl.searchParams.set('auth', 'success');
+ const response = NextResponse.redirect(successUrl);
response.cookies.set(cookie);
try {
await saveActivities([createPlatformActivity({
diff --git a/app/page.jsx b/app/page.jsx
index 7b637d6..27c1d08 100644
--- a/app/page.jsx
+++ b/app/page.jsx
@@ -1,6 +1,7 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { track } from '@vercel/analytics';
import Header from '../components/Header.jsx';
import SearchBar from '../components/SearchBar.jsx';
import Leaderboard from '../components/Leaderboard.jsx';
@@ -70,6 +71,17 @@ export default function Home() {
} catch { /* localStorage unavailable; leave the tour closed */ }
}, []);
+ useEffect(() => {
+ const referrer = new URLSearchParams(window.location.search).get('ref')?.trim();
+ if (!referrer || !/^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i.test(referrer)) return;
+ const key = `devglobe-referral-${referrer.toLowerCase()}`;
+ try {
+ if (sessionStorage.getItem(key)) return;
+ sessionStorage.setItem(key, '1');
+ } catch { /* Analytics can still record the visit without session storage. */ }
+ track('referral_landing', { referrer: referrer.toLowerCase() });
+ }, []);
+
// Fetch session on mount
useEffect(() => {
async function loadSession() {
@@ -78,6 +90,16 @@ export default function Home() {
const data = await res.json();
if (data.user) {
setUser(data.user);
+ const url = new URL(window.location.href);
+ if (url.searchParams.get('auth') === 'success') {
+ let source = 'signin';
+ try {
+ if (localStorage.getItem(PENDING_CLAIM_KEY)) source = 'claim';
+ } catch { /* localStorage is optional for attribution. */ }
+ track('github_auth_completed', { source });
+ url.searchParams.delete('auth');
+ window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
+ }
}
} catch { /* not authenticated */ }
}
@@ -109,6 +131,7 @@ export default function Home() {
}, [user]);
const handleClaim = useCallback(async () => {
+ track('claim_started');
try {
const res = await fetch('/api/auth/claim', { method: 'POST' });
if (res.ok) {
@@ -123,6 +146,7 @@ export default function Home() {
return { ok: false, ...result };
}
setClaimStatus('claimed');
+ track('claim_completed', { profile_status: 'public' });
setClaimedLogins(prev => new Set(prev).add(user.login));
let claimedDeveloper = developers.find(developer => developer.login === user.login);
// If a new profile was created, reload developers to include it
@@ -162,10 +186,12 @@ export default function Home() {
return { ok: true, ...result };
} else {
const data = await res.json();
+ track('claim_failed', { reason: 'request_failed' });
console.error('Claim failed:', data.error);
return { ok: false, ...data };
}
} catch (err) {
+ track('claim_failed', { reason: 'network_error' });
console.error('Claim error:', err);
return { ok: false, error: err.message };
}
diff --git a/components/AddMeModal.jsx b/components/AddMeModal.jsx
index c5d8cb9..0f98105 100644
--- a/components/AddMeModal.jsx
+++ b/components/AddMeModal.jsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
+import { track } from '@vercel/analytics';
import styles from './AddMeModal.module.css';
const PENDING_CLAIM_KEY = 'devglobe-pending-claim';
@@ -60,6 +61,7 @@ export default function AddMeModal({ onClose, user, onVerify, verificationUserna
}
setUsername(data.username || clean);
setStatus('success');
+ track('nomination_submitted');
} catch (err) {
setStatus('error');
setError('Network error. Please try again.');
@@ -72,6 +74,7 @@ export default function AddMeModal({ onClose, user, onVerify, verificationUserna
} catch { /* Continue with the current session when storage is unavailable. */ }
if (!user) {
+ track('github_auth_started', { source: 'nomination_claim' });
window.location.assign(`/api/auth/github?login=${encodeURIComponent(normalizedUsername)}`);
return;
}
@@ -80,6 +83,7 @@ export default function AddMeModal({ onClose, user, onVerify, verificationUserna
setStatus('verifying');
setError('');
+ track('claim_clicked', { source: 'nomination' });
const result = await onVerify();
if (!result?.ok) {
setStatus('success');
diff --git a/components/DetailPanel.jsx b/components/DetailPanel.jsx
index 9cbfe56..f85e31f 100644
--- a/components/DetailPanel.jsx
+++ b/components/DetailPanel.jsx
@@ -20,6 +20,10 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi
const heatmapRef = useRef(null);
const langRef = useRef(null);
+ useEffect(() => {
+ track('profile_viewed', { login: dev.login });
+ }, [dev.login]);
+
const handleGenerateCard = () => {
track('card_generated', { login: dev.login });
onCardGenerated?.(dev.login);
@@ -705,21 +709,28 @@ function CardModal({ dev, claimSuccess, onClose }) {
a.download = `devglobe-${login}.png`;
a.click();
URL.revokeObjectURL(url);
+ track('identity_card_downloaded', { login });
} catch {
setLoading(false);
setError(true);
}
};
- const handleCopyLink = () => {
- navigator.clipboard.writeText(shareUrl);
+ const handleCopyLink = async () => {
+ await navigator.clipboard.writeText(shareUrl);
+ track('identity_card_shared', { login, channel: 'copy_link' });
};
const handleLinkedInShare = () => {
navigator.clipboard?.writeText(linkedinCaption).then(() => setLinkedinCopied(true)).catch(() => {});
+ track('identity_card_shared', { login, channel: 'linkedin' });
window.open(shareLinks.linkedin, '_blank', 'noopener,noreferrer');
};
+ const handleSocialShare = channel => {
+ track('identity_card_shared', { login, channel });
+ };
+
return (
e.stopPropagation()}>
@@ -786,12 +797,12 @@ function CardModal({ dev, claimSuccess, onClose }) {
Share on:
-
+ handleSocialShare('twitter')}>
-
+ handleSocialShare('facebook')}>
@@ -801,7 +812,7 @@ function CardModal({ dev, claimSuccess, onClose }) {
-
+ handleSocialShare('reddit')}>
diff --git a/components/DeveloperActivityPage.jsx b/components/DeveloperActivityPage.jsx
index fc84000..e130ceb 100644
--- a/components/DeveloperActivityPage.jsx
+++ b/components/DeveloperActivityPage.jsx
@@ -2,6 +2,7 @@
import Link from 'next/link';
import { useEffect, useState } from 'react';
+import { track } from '@vercel/analytics';
import { formatNum, formatRelativeTime } from '../lib/format.js';
import { useActivityFeed } from './useActivityFeed.js';
import SpecialTags from './SpecialTags.jsx';
@@ -18,6 +19,11 @@ export default function DeveloperActivityPage({ login }) {
lastUpdated,
} = useActivityFeed(login, { limit: 20 });
+ useEffect(() => {
+ const source = new URLSearchParams(window.location.search).get('utm_source') || 'direct';
+ track('profile_viewed', { login, source });
+ }, [login]);
+
useEffect(() => {
let cancelled = false;
@@ -39,6 +45,12 @@ export default function DeveloperActivityPage({ login }) {
return () => { cancelled = true; };
}, [login]);
+ function claimProfile() {
+ try { localStorage.setItem('devglobe-pending-claim', login); } catch { /* OAuth can continue without persistence. */ }
+ track('claim_clicked', { source: 'developer_page' });
+ window.location.assign(`/api/auth/github?login=${encodeURIComponent(login)}`);
+ }
+
return (
@@ -66,6 +78,9 @@ export default function DeveloperActivityPage({ login }) {
{developer.soUserId && (
Stack Overflow
)}
+ {!developer.claimed && (
+
+ )}
diff --git a/components/UserMenu.jsx b/components/UserMenu.jsx
index 97f6e8c..a0a3699 100644
--- a/components/UserMenu.jsx
+++ b/components/UserMenu.jsx
@@ -1,12 +1,14 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
+import { track } from '@vercel/analytics';
export default function UserMenu({ user, onLogout, onClaim, onEditAiProfile, onOpenIntroductions, claimStatus }) {
const [open, setOpen] = useState(false);
const [verificationStatus, setVerificationStatus] = useState('idle');
const [digestPreference, setDigestPreference] = useState(null);
const [digestStatus, setDigestStatus] = useState('idle');
+ const [inviteStatus, setInviteStatus] = useState('');
const menuRef = useRef(null);
useEffect(() => {
@@ -21,7 +23,10 @@ export default function UserMenu({ user, onLogout, onClaim, onEditAiProfile, onO
useEffect(() => {
const status = new URLSearchParams(window.location.search).get('email_verification');
- if (status === 'success') setVerificationStatus('verified');
+ if (status === 'success') {
+ setVerificationStatus('verified');
+ track('email_verified');
+ }
if (status === 'invalid') setVerificationStatus('invalid');
}, []);
@@ -47,6 +52,7 @@ export default function UserMenu({ user, onLogout, onClaim, onEditAiProfile, onO
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Could not send verification email');
setVerificationStatus(result.alreadyVerified ? 'verified' : 'sent');
+ track(result.alreadyVerified ? 'email_already_verified' : 'email_verification_requested');
} catch {
setVerificationStatus('error');
}
@@ -69,9 +75,27 @@ export default function UserMenu({ user, onLogout, onClaim, onEditAiProfile, onO
}
}
+ async function inviteDeveloper() {
+ const url = `${window.location.origin}/?ref=${encodeURIComponent(user.login)}`;
+ const text = 'Find your open-source profile, developer rank, and OSS Worth on DevGlobe.';
+ try {
+ if (navigator.share) {
+ await navigator.share({ title: 'Join me on DevGlobe', text, url });
+ setInviteStatus('Invite shared');
+ track('developer_invite_shared', { channel: 'native_share' });
+ } else {
+ await navigator.clipboard.writeText(`${text}\n${url}`);
+ setInviteStatus('Invite copied');
+ track('developer_invite_shared', { channel: 'copy_link' });
+ }
+ } catch (error) {
+ if (error.name !== 'AbortError') setInviteStatus('Unable to share invite');
+ }
+ }
+
if (!user) {
return (
-
+ track('github_auth_started', { source: 'header' })}>
@@ -108,12 +132,18 @@ export default function UserMenu({ user, onLogout, onClaim, onEditAiProfile, onO
{claimStatus === 'unclaimed' && (
-
+ <>
+
+ Make this profile yours
+ Verified identity card, AI collaboration controls, impact history, and weekly rankings.
+
+
+ >
)}
{claimStatus === 'claimed' && (
<>
@@ -140,6 +170,15 @@ export default function UserMenu({ user, onLogout, onClaim, onEditAiProfile, onO
Agent requests
+
+ {inviteStatus && {inviteStatus}
}