From 8b08edbab60ce6962f48c7eaac008ad850808f8a Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Thu, 16 Jul 2026 15:36:25 -0700 Subject: [PATCH 1/3] Add viral backlink to Discord copy + demo report on landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Growth changes from the PostHog funnel analysis (2.8% share rate, 47.6% visitor→submit): - ComparisonSummary: "Copy for Discord" now appends a deep link back to the report so shares drive inbound traffic and Discord unfurls the OG scorecard image. Adds discord_copied.has_link for measurement. - ReportUrlForm + lib/demo-report.ts: landing page gains a "See a live example" button targeting visitors who arrive without a log URL handy (the ~52% who bounce before submitting). Fires demo_report_clicked. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TG7Bgzws6wZi3hA1P8Gs2i --- app/components/ComparisonSummary.tsx | 15 +++++++++++---- app/components/ReportUrlForm.tsx | 18 ++++++++++++++++++ lib/demo-report.ts | 17 +++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 lib/demo-report.ts diff --git a/app/components/ComparisonSummary.tsx b/app/components/ComparisonSummary.tsx index c980def..c2b9345 100644 --- a/app/components/ComparisonSummary.tsx +++ b/app/components/ComparisonSummary.tsx @@ -123,7 +123,7 @@ function PerformanceBreakdown({ data, previousSnapshot }: { data: MetricPercenti ); } -function formatForDiscord(data: AnalysisResult): string { +function formatForDiscord(data: AnalysisResult, shareUrl?: string): string { const metricLabel = data.playerRole === "healer" ? "HPS" : "DPS"; const dpsVal = data.dps.playerDps.toLocaleString(); const pct = `${data.dps.percentile}th`; @@ -156,8 +156,12 @@ function formatForDiscord(data: AnalysisResult): string { metricsLine = `\n**Performance:** ${data.metricPercentiles.overallGrade}(${data.metricPercentiles.overallScore}%) — ${scores}`; } + // Link back to the full interactive analysis so shares drive traffic and + // Discord unfurls the report's OG scorecard image. + const linkLine = shareUrl ? `\n\nFull breakdown → ${shareUrl}` : ""; + if (data.suggestions.length === 0) { - return scorecard + metricsLine; + return scorecard + metricsLine + linkLine; } const priorityMarker: Record = { @@ -171,14 +175,16 @@ function formatForDiscord(data: AnalysisResult): string { return `${marker} **[${(categoryLabels[s.category] ?? s.category).toUpperCase()}]** ${s.title}\n> ${s.description}${s.estimatedImpact ? `\n> _${s.estimatedImpact}_` : ""}`; }); - return `${scorecard}${metricsLine}\n\n**Suggestions**\n${lines.join("\n\n")}`; + return `${scorecard}${metricsLine}\n\n**Suggestions**\n${lines.join("\n\n")}${linkLine}`; } export default function ComparisonSummary({ data, previousSnapshot }: { data: AnalysisResult; previousSnapshot?: AnalysisSnapshot | null }) { const [copied, setCopied] = useState(false); const handleCopyDiscord = async () => { - const text = formatForDiscord(data); + const shareUrl = + typeof window !== "undefined" ? window.location.href : undefined; + const text = formatForDiscord(data, shareUrl); await navigator.clipboard.writeText(text); setCopied(true); posthog.capture("discord_copied", { @@ -187,6 +193,7 @@ export default function ComparisonSummary({ data, previousSnapshot }: { data: An encounter: data.encounterName, percentile: data.dps.percentile, overall_grade: data.metricPercentiles?.overallGrade, + has_link: !!shareUrl, }); setTimeout(() => setCopied(false), 2000); }; diff --git a/app/components/ReportUrlForm.tsx b/app/components/ReportUrlForm.tsx index 14261dd..4fbe2d1 100644 --- a/app/components/ReportUrlForm.tsx +++ b/app/components/ReportUrlForm.tsx @@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { ShimmerButton } from "@/components/ui/shimmer-button"; import { parseWCLUrl } from "@/lib/url-parser"; +import { DEMO_REPORT, demoReportPath } from "@/lib/demo-report"; import posthog from "posthog-js"; export default function ReportUrlForm() { @@ -43,6 +44,12 @@ export default function ReportUrlForm() { router.push(path); }; + const handleDemo = () => { + posthog.capture("demo_report_clicked", { report_code: DEMO_REPORT.code }); + setLoading(true); + router.push(demoReportPath()); + }; + return (
@@ -71,6 +78,17 @@ export default function ReportUrlForm() { {error} )} +

+ Don't have a log handy?{" "} + +

Supports Classic and Season of Discovery reports. Example:{" "} diff --git a/lib/demo-report.ts b/lib/demo-report.ts new file mode 100644 index 0000000..6970098 --- /dev/null +++ b/lib/demo-report.ts @@ -0,0 +1,17 @@ +// The report featured by the landing-page "See a live example" button. +// +// This must always point at a PUBLIC Warcraft Logs report so the button never +// dead-ends a first-time visitor. To feature a different parse, swap the values +// below and confirm it loads publicly (e.g. open /og?report=&fight=&source= +// and check it returns an image). +export const DEMO_REPORT = { + code: "ZjKgNYxVcAqR8pGJ", + fight: 23, + source: 12, +} as const; + +/** Deep link to the featured player scorecard for the demo report. */ +export function demoReportPath(): string { + const { code, fight, source } = DEMO_REPORT; + return `/analyze/${code}?fight=${fight}&source=${source}&tab=player`; +} From dbd4895334d51d34b4e790af450dcd62f9fa7e1c Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Thu, 16 Jul 2026 15:36:25 -0700 Subject: [PATCH 2/3] Promote and ungate sharing on the analysis view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the 2.8% share rate found in the funnel analysis: - Header "Share" button goes from outline to filled (default) so it's discoverable, not a faint top-right icon. - Adds a "Found this useful? Share it with your guild" CTA at the bottom of every tab (raid/player/CLA), where users finish reading — sharing was previously only surfaced in the player scorecard. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TG7Bgzws6wZi3hA1P8Gs2i --- app/analyze/[reportCode]/AnalyzeClient.tsx | 30 +++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/app/analyze/[reportCode]/AnalyzeClient.tsx b/app/analyze/[reportCode]/AnalyzeClient.tsx index 0eb381a..88a6769 100644 --- a/app/analyze/[reportCode]/AnalyzeClient.tsx +++ b/app/analyze/[reportCode]/AnalyzeClient.tsx @@ -131,7 +131,7 @@ export default function AnalyzeClient({ reportCode }: { reportCode: string }) {

{report && ( + + )} + {/* Guide links — cross-link to SEO content */} {(player.result || raid.result || cla.result) && (
From 9ef6545992cff791f310fe764ec27c16142ff2d5 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Thu, 16 Jul 2026 15:36:25 -0700 Subject: [PATCH 3/3] Reduce paste friction + instrument private-report failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targets the higher mobile invalid-URL rate (14% vs 11% desktop) and a blind spot found in the funnel analysis: - url-parser: extract a /reports/ from pasted text that includes surrounding words (mobile/Discord shares), and read fight/source from both #hash and ?query styles. Fully backward-compatible. - AnalyzeClient: fire report_load_failed when a report passes validation but won't load (usually a private log) — previously a silent drop-off. - Clearer error copy telling users to set a private log to Public/Unlisted. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TG7Bgzws6wZi3hA1P8Gs2i --- app/analyze/[reportCode]/AnalyzeClient.tsx | 16 ++++- lib/url-parser.ts | 75 +++++++++++++++------- 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/app/analyze/[reportCode]/AnalyzeClient.tsx b/app/analyze/[reportCode]/AnalyzeClient.tsx index 88a6769..a6cafed 100644 --- a/app/analyze/[reportCode]/AnalyzeClient.tsx +++ b/app/analyze/[reportCode]/AnalyzeClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { Link2, Check, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -102,6 +102,18 @@ export default function AnalyzeClient({ reportCode }: { reportCode: string }) { }); }, [reportCode]); + // A report that passes URL validation but fails to load (most often a + // private/permission-gated log) was previously a silent drop-off. Capture it + // so the leak is measurable. + useEffect(() => { + if (reportError) { + posthog.capture("report_load_failed", { + report_code: reportCode, + error: reportError.message, + }); + } + }, [reportError, reportCode]); + return (
{/* Report header */} @@ -157,6 +169,8 @@ export default function AnalyzeClient({ reportCode }: { reportCode: string }) { {reportError.message || "Couldn't load this report. Check the Warcraft Logs URL and try again."}{" "} + If this is a private log, set its visibility to Public (or Unlisted) + on Warcraft Logs, then reload.{" "} from text that may include + // surrounding words (common when sharing from a mobile app or Discord), + // where the input isn't a bare URL and `new URL()` above fails. + const loose = trimmed.match(/\/reports\/([a-zA-Z0-9]{10,20})/); + if (loose) { + const rest = trimmed.slice( + trimmed.indexOf(loose[0]) + loose[0].length + ); + return { code: loose[1], ...extractFightSource(rest) }; } + + return null; } export function buildWCLUrl(code: string, fightId?: number, sourceId?: number): string {