184 lines
5.3 KiB
TypeScript
184 lines
5.3 KiB
TypeScript
'use client';
|
|
|
|
import { useRef } from 'react';
|
|
import { trackShareCardGenerated } from '@/lib/analytics';
|
|
|
|
interface ShareCardProps {
|
|
sport: 'NBA' | 'MLB' | 'WNBA';
|
|
player: string;
|
|
stat: string;
|
|
line: number;
|
|
direction: 'over' | 'under';
|
|
grade: string;
|
|
projection?: number;
|
|
sampleSize?: number;
|
|
}
|
|
|
|
const SPORT_COLOR: Record<ShareCardProps['sport'], string> = {
|
|
NBA: '#E94B3C',
|
|
MLB: '#1E90FF',
|
|
WNBA: '#FFB347',
|
|
};
|
|
|
|
function gradeColor(grade: string): string {
|
|
const g = (grade || '').trim().toUpperCase().charAt(0);
|
|
if (g === 'A') return '#00C896';
|
|
if (g === 'B') return '#4A9EFF';
|
|
if (g === 'C') return '#FFB347';
|
|
return '#FF6B6B';
|
|
}
|
|
|
|
/**
|
|
* Renders a 1200x630 OG-shaped share image into a hidden canvas, then
|
|
* provides Download + Copy actions. Intentionally hides the analysis —
|
|
* shares the GRADE only, which is what drives traffic back to the site.
|
|
*/
|
|
export function useShareCard(props: ShareCardProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
|
|
const ensureCanvas = (): HTMLCanvasElement => {
|
|
if (canvasRef.current) return canvasRef.current;
|
|
const c = document.createElement('canvas');
|
|
c.width = 1200;
|
|
c.height = 630;
|
|
canvasRef.current = c;
|
|
return c;
|
|
};
|
|
|
|
const renderToCanvas = async (): Promise<HTMLCanvasElement> => {
|
|
const c = ensureCanvas();
|
|
const ctx = c.getContext('2d');
|
|
if (!ctx) throw new Error('No 2D context.');
|
|
|
|
// Background — obsidian with diagonal accent
|
|
ctx.fillStyle = '#0A0A0F';
|
|
ctx.fillRect(0, 0, c.width, c.height);
|
|
|
|
// Diagonal gradient overlay
|
|
const g = ctx.createLinearGradient(0, 0, c.width, c.height);
|
|
g.addColorStop(0, 'rgba(26,74,58,0.20)');
|
|
g.addColorStop(1, 'rgba(0,200,150,0.02)');
|
|
ctx.fillStyle = g;
|
|
ctx.fillRect(0, 0, c.width, c.height);
|
|
|
|
// VYNDR wordmark top-left
|
|
ctx.fillStyle = '#F0F0F5';
|
|
ctx.font = '800 38px "JetBrains Mono", "SF Mono", ui-monospace, monospace';
|
|
ctx.fillText('VYND', 64, 96);
|
|
ctx.fillStyle = '#00D4A0';
|
|
ctx.fillText('R', 64 + ctx.measureText('VYND').width, 96);
|
|
|
|
// Sport badge
|
|
const sportColor = SPORT_COLOR[props.sport];
|
|
ctx.font = '700 16px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = sportColor;
|
|
ctx.fillText(props.sport, 64, 220);
|
|
|
|
// Player name (large)
|
|
ctx.fillStyle = '#F0F0F5';
|
|
ctx.font = '700 72px "Instrument Sans", system-ui, sans-serif';
|
|
wrapText(ctx, props.player, 64, 300, 780, 80);
|
|
|
|
// Prop line (mono)
|
|
ctx.fillStyle = '#8A8A9A';
|
|
ctx.font = '500 28px "JetBrains Mono", monospace';
|
|
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
ctx.fillText(
|
|
`${cap(props.direction)} ${props.line} ${props.stat.replace(/_/g, ' ')}`,
|
|
64,
|
|
420,
|
|
);
|
|
|
|
// Grade letter (huge, colored)
|
|
const gc = gradeColor(props.grade);
|
|
ctx.fillStyle = gc;
|
|
ctx.font = '800 240px "JetBrains Mono", monospace';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(props.grade || '—', c.width - 64, 380);
|
|
|
|
// Glow ring behind the grade
|
|
ctx.shadowColor = gc;
|
|
ctx.shadowBlur = 60;
|
|
ctx.fillText(props.grade || '—', c.width - 64, 380);
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Projection (small, beneath player line)
|
|
if (props.projection != null) {
|
|
ctx.textAlign = 'left';
|
|
ctx.fillStyle = '#5A5A6A';
|
|
ctx.font = '500 22px "JetBrains Mono", monospace';
|
|
ctx.fillText(`Projection ${props.projection.toFixed(1)}`, 64, 470);
|
|
}
|
|
|
|
// Footer — watermark
|
|
ctx.textAlign = 'left';
|
|
ctx.fillStyle = '#5A5A6A';
|
|
ctx.font = '500 18px "JetBrains Mono", monospace';
|
|
ctx.fillText('vyndr.app', 64, 580);
|
|
ctx.textAlign = 'right';
|
|
ctx.fillStyle = '#5A5A6A';
|
|
ctx.fillText('Built in Detroit.', c.width - 64, 580);
|
|
|
|
return c;
|
|
};
|
|
|
|
const download = async () => {
|
|
const c = await renderToCanvas();
|
|
c.toBlob((blob) => {
|
|
if (!blob) return;
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `vyndr-${props.player.replace(/\W+/g, '-')}-${props.grade}.png`.toLowerCase();
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
trackShareCardGenerated({ sport: props.sport, grade: props.grade });
|
|
}, 'image/png');
|
|
};
|
|
|
|
const copyToClipboard = async (): Promise<boolean> => {
|
|
if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) return false;
|
|
const c = await renderToCanvas();
|
|
return new Promise<boolean>((resolve) => {
|
|
c.toBlob(async (blob) => {
|
|
if (!blob) return resolve(false);
|
|
try {
|
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
trackShareCardGenerated({ sport: props.sport, grade: props.grade });
|
|
resolve(true);
|
|
} catch {
|
|
resolve(false);
|
|
}
|
|
}, 'image/png');
|
|
});
|
|
};
|
|
|
|
return { download, copyToClipboard };
|
|
}
|
|
|
|
function wrapText(
|
|
ctx: CanvasRenderingContext2D,
|
|
text: string,
|
|
x: number,
|
|
y: number,
|
|
maxWidth: number,
|
|
lineHeight: number,
|
|
) {
|
|
const words = text.split(' ');
|
|
let line = '';
|
|
for (const word of words) {
|
|
const test = line ? `${line} ${word}` : word;
|
|
const m = ctx.measureText(test);
|
|
if (m.width > maxWidth && line) {
|
|
ctx.fillText(line, x, y);
|
|
line = word;
|
|
y += lineHeight;
|
|
} else {
|
|
line = test;
|
|
}
|
|
}
|
|
if (line) ctx.fillText(line, x, y);
|
|
}
|