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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion app/api/auth/callback/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
26 changes: 26 additions & 0 deletions app/page.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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() {
Expand All @@ -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 */ }
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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 };
}
Expand Down
4 changes: 4 additions & 0 deletions components/AddMeModal.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.');
Expand All @@ -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;
}
Expand All @@ -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');
Expand Down
21 changes: 16 additions & 5 deletions components/DetailPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<div className="card-modal-backdrop" onClick={onClose}>
<div className="card-modal" onClick={e => e.stopPropagation()}>
Expand Down Expand Up @@ -786,12 +797,12 @@ function CardModal({ dev, claimSuccess, onClose }) {

<div className="card-modal__share">
<span className="card-modal__share-label">Share on:</span>
<a href={shareLinks.twitter} target="_blank" rel="noreferrer" className="card-modal__social card-modal__social--twitter" title="Share on X/Twitter">
<a href={shareLinks.twitter} target="_blank" rel="noreferrer" className="card-modal__social card-modal__social--twitter" title="Share on X/Twitter" onClick={() => handleSocialShare('twitter')}>
<svg viewBox="0 0 16 16" width="18" height="18" fill="currentColor">
<path d="M13.5 1h-3.7L8 3.6 6.2 1H2.5L6.6 6.5 2.3 13h1.7l2.5-3.2L9 13h4.2l-4.5-6.7L13.5 1zm-1.1 11h-1L4.5 2h1l6.9 10z" />
</svg>
</a>
<a href={shareLinks.facebook} target="_blank" rel="noreferrer" className="card-modal__social card-modal__social--facebook" title="Share on Facebook" aria-label="Share on Facebook">
<a href={shareLinks.facebook} target="_blank" rel="noreferrer" className="card-modal__social card-modal__social--facebook" title="Share on Facebook" aria-label="Share on Facebook" onClick={() => handleSocialShare('facebook')}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
<path d="M13.5 22v-9h3l.5-3.5h-3.5V7.25c0-1 .3-1.75 1.75-1.75H17V2.38A23.7 23.7 0 0014.44 2C11.9 2 10 3.55 10 6.4v3.1H7V13h3v9h3.5z" />
</svg>
Expand All @@ -801,7 +812,7 @@ function CardModal({ dev, claimSuccess, onClose }) {
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</button>
<a href={shareLinks.reddit} target="_blank" rel="noreferrer" className="card-modal__social card-modal__social--reddit" title="Share on Reddit">
<a href={shareLinks.reddit} target="_blank" rel="noreferrer" className="card-modal__social card-modal__social--reddit" title="Share on Reddit" onClick={() => handleSocialShare('reddit')}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M12 0A12 12 0 000 12a12 12 0 0012 12 12 12 0 0012-12A12 12 0 0012 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 000-.463.327.327 0 00-.462 0c-.545.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.205-.094z" />
</svg>
Expand Down
15 changes: 15 additions & 0 deletions components/DeveloperActivityPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand All @@ -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 (
<main className="activity-page">
<header className="activity-page__nav">
Expand Down Expand Up @@ -66,6 +78,9 @@ export default function DeveloperActivityPage({ login }) {
{developer.soUserId && (
<a href={`https://stackoverflow.com/users/${developer.soUserId}`} target="_blank" rel="noopener noreferrer">Stack Overflow</a>
)}
{!developer.claimed && (
<button type="button" onClick={claimProfile}>Claim this profile</button>
)}
</div>
</div>
<dl className="activity-profile__stats">
Expand Down
55 changes: 47 additions & 8 deletions components/UserMenu.jsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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');
}, []);

Expand All @@ -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');
}
Expand All @@ -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 (
<a href="/api/auth/github" className="btn btn--signin" aria-label="Sign in with GitHub" title="Sign in with GitHub">
<a href="/api/auth/github" className="btn btn--signin" aria-label="Sign in with GitHub" title="Sign in with GitHub" onClick={() => track('github_auth_started', { source: 'header' })}>
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
Expand Down Expand Up @@ -108,12 +132,18 @@ export default function UserMenu({ user, onLogout, onClaim, onEditAiProfile, onO
</div>
<div className="user-menu__divider" />
{claimStatus === 'unclaimed' && (
<button className="user-menu__item" onClick={() => { onClaim(); setOpen(false); }}>
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<path d="M16 8A8 8 0 110 8a8 8 0 0116 0zm-3.97-3.03a.75.75 0 00-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 00-1.06 1.06L6.97 11.03a.75.75 0 001.079-.02l3.992-4.99a.75.75 0 00-.01-1.05z" />
</svg>
Claim my profile
</button>
<>
<div className="user-menu__claim-benefits">
<strong>Make this profile yours</strong>
<span>Verified identity card, AI collaboration controls, impact history, and weekly rankings.</span>
</div>
<button className="user-menu__item" onClick={() => { track('claim_clicked', { source: 'user_menu' }); onClaim(); setOpen(false); }}>
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<path d="M16 8A8 8 0 110 8a8 8 0 0116 0zm-3.97-3.03a.75.75 0 00-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 00-1.06 1.06L6.97 11.03a.75.75 0 001.079-.02l3.992-4.99a.75.75 0 00-.01-1.05z" />
</svg>
Claim and unlock profile
</button>
</>
)}
{claimStatus === 'claimed' && (
<>
Expand All @@ -140,6 +170,15 @@ export default function UserMenu({ user, onLogout, onClaim, onEditAiProfile, onO
</svg>
Agent requests
</button>
<button className="user-menu__item" onClick={inviteDeveloper}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M15 19a6 6 0 00-12 0" />
<circle cx="9" cy="7" r="4" />
<path d="M19 8v6M22 11h-6" />
</svg>
Invite a developer
</button>
{inviteStatus && <div className="user-menu__message" role="status">{inviteStatus}</div>}
<button
className="user-menu__item"
onClick={requestEmailVerification}
Expand Down
Loading