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
46 changes: 44 additions & 2 deletions app/analyze/[reportCode]/AnalyzeClient.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<main className="py-6 space-y-6">
{/* Report header */}
Expand Down Expand Up @@ -131,7 +143,7 @@ export default function AnalyzeClient({ reportCode }: { reportCode: string }) {
</div>
{report && (
<Button
variant="outline"
variant="default"
size="sm"
onClick={handleShareLink}
className="shrink-0"
Expand All @@ -157,6 +169,8 @@ export default function AnalyzeClient({ reportCode }: { reportCode: string }) {
<AlertDescription>
{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.{" "}
<a
href={`https://www.warcraftlogs.com/reports/${reportCode}`}
target="_blank"
Expand Down Expand Up @@ -362,6 +376,34 @@ export default function AnalyzeClient({ reportCode }: { reportCode: string }) {
)}
</>
)}
{/* Share CTA — surface sharing on every tab where users finish reading,
not just the player scorecard's "Copy for Discord". */}
{(player.result || raid.result || cla.result) && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg glass px-4 py-3">
<p className="text-sm text-muted-foreground">
Found this useful? Share it with your guild.
</p>
<Button
variant="default"
size="sm"
onClick={handleShareLink}
className="shrink-0"
>
{copied ? (
<>
<Check className="size-3.5 text-emerald-400" />
Copied!
</>
) : (
<>
<Link2 className="size-3.5" />
Share link
</>
)}
</Button>
</div>
)}

{/* Guide links — cross-link to SEO content */}
{(player.result || raid.result || cla.result) && (
<div className="border-t border-white/[0.06] pt-6 mt-8">
Expand Down
15 changes: 11 additions & 4 deletions app/components/ComparisonSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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<string, string> = {
Expand All @@ -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", {
Expand All @@ -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);
};
Expand Down
18 changes: 18 additions & 0 deletions app/components/ReportUrlForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 (
<form onSubmit={handleSubmit} className="w-full space-y-4">
<div className="flex gap-2">
Expand Down Expand Up @@ -71,6 +78,17 @@ export default function ReportUrlForm() {
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<p className="text-caption">
Don&apos;t have a log handy?{" "}
<button
type="button"
onClick={handleDemo}
disabled={loading}
className="font-medium text-gold-from hover:underline disabled:opacity-50 disabled:pointer-events-none"
>
See a live example &rarr;
</button>
</p>
<p className="text-caption">
Supports Classic and Season of Discovery reports. Example:{" "}
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-xs">
Expand Down
17 changes: 17 additions & 0 deletions lib/demo-report.ts
Original file line number Diff line number Diff line change
@@ -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=<code>&fight=<f>&source=<s>
// 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`;
}
75 changes: 51 additions & 24 deletions lib/url-parser.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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/<code> 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 {
Expand Down