diff --git a/app/analyze/[reportCode]/AnalyzeClient.tsx b/app/analyze/[reportCode]/AnalyzeClient.tsx index 0eb381a..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 */} @@ -131,7 +143,7 @@ export default function AnalyzeClient({ reportCode }: { reportCode: string }) { {report && ( + + )} + {/* Guide links — cross-link to SEO content */} {(player.result || raid.result || cla.result) && (
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`; +} diff --git a/lib/url-parser.ts b/lib/url-parser.ts index 97f663c..cb48a71 100644 --- a/lib/url-parser.ts +++ b/lib/url-parser.ts @@ -1,12 +1,41 @@ import { ParsedWCLUrl } from "./wcl-types"; +/** + * Pull fight/source out of a raw fragment/query blob. Handles both the + * hash style (`#fight=5&source=12`) and the query style (`?fight=5&source=12`) + * that Warcraft Logs uses interchangeably. + */ +function extractFightSource(...parts: string[]): { + fightId?: number; + sourceId?: number; +} { + const blob = parts.filter(Boolean).join("&"); + const out: { fightId?: number; sourceId?: number } = {}; + + const fight = blob.match(/(?:^|[#&?])fight=([^&\s]+)/); + if (fight && fight[1] !== "last") { + const n = parseInt(fight[1], 10); + if (!Number.isNaN(n)) out.fightId = n; + } + + const source = blob.match(/(?:^|[#&?])source=(\d+)/); + if (source) { + const n = parseInt(source[1], 10); + if (!Number.isNaN(n)) out.sourceId = n; + } + + return out; +} + /** * Parse a Warcraft Logs URL into its components. * Supports: * https://classic.warcraftlogs.com/reports/ABC123#fight=5&source=12 - * https://www.warcraftlogs.com/reports/ABC123#fight=5&source=12 + * https://www.warcraftlogs.com/reports/ABC123?fight=5&source=12 * https://www.warcraftlogs.com/reports/ABC123 * ABC123 (just the report code) + * Pasted text with a report URL somewhere inside it (e.g. shared from a + * phone or Discord: "check this out https://.../reports/ABC123 gg") */ export function parseWCLUrl(input: string): ParsedWCLUrl | null { const trimmed = input.trim(); @@ -16,36 +45,34 @@ export function parseWCLUrl(input: string): ParsedWCLUrl | null { return { code: trimmed }; } - // Try as a URL + // Try as a well-formed URL first — most reliable for fragment/query params. try { - // Handle fragment params const urlStr = trimmed.includes("://") ? trimmed : `https://${trimmed}`; const url = new URL(urlStr); - const pathMatch = url.pathname.match(/\/reports\/([a-zA-Z0-9]+)/); - if (!pathMatch) return null; - - const code = pathMatch[1]; - const result: ParsedWCLUrl = { code }; - - // Parse fragment params (#fight=5&source=12) - if (url.hash) { - const hashParams = new URLSearchParams(url.hash.slice(1)); - const fight = hashParams.get("fight"); - const source = hashParams.get("source"); - - if (fight && fight !== "last") { - result.fightId = parseInt(fight, 10); - } - if (source) { - result.sourceId = parseInt(source, 10); - } + const pathMatch = url.pathname.match(/\/reports\/([a-zA-Z0-9]{10,20})/); + if (pathMatch) { + return { + code: pathMatch[1], + ...extractFightSource(url.hash, url.search), + }; } - - return result; } catch { - return null; + // Not a parseable URL on its own — fall through to a loose scan. + } + + // Loose fallback: extract a /reports/ 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 {