Splits the two hostnames across one deployment. No files move: the Next app still serves every route on both hosts, and the middleware decides per host which paths it owns and 301s the rest. /login and /signup stay on www - all 82 marketing CTAs point at /signup, which carries a hard canonical to www plus ad traffic. src/lib/hosts.ts is the single source of truth for the boundary (APP_PATH_PREFIXES, isAppPath, wwwUrl, appUrl, urlForPath). The middleware and every absolute-URL builder read from it so they cannot drift apart. - Split the overloaded NEXT_PUBLIC_APP_URL into a www and an app origin. It previously fed both public URLs and in-app URLs, so any single value was wrong somewhere. Most important: QRCodeCard encodes this origin into the QR code the user downloads and prints, so it must stay on www. - Route Stripe return URLs, email links and OAuth redirects per path rather than against one origin, so /dashboard lands on app and /pricing on www. - Cross the host boundary once, after a successful login: the router cannot push across origins, so that jump needs a full load. The user arrives signed in because the session cookie is scoped to COOKIE_DOMAIN. - Keep the app host out of search indexes: X-Robots-Tag on every response plus a Disallow-all robots.txt via rewrite, and /sitemap.xml redirects to www. - Point the TikTok callback fallback at www explicitly. It used to read NEXT_PUBLIC_APP_URL, whose meaning changed here, and only the apex domain is verified with TikTok. Host splitting is inert while both origins are equal, so development is unaffected. Verified: tsc clean, production build succeeds including the Edge middleware bundle, and the path-to-host mapping is unit-checked (prefix traps like /created and /settings-guide stay on www, query strings do not break matching). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import Barcode from 'react-barcode';
|
|
import StyledQRCode from '@/components/generator/StyledQRCode';
|
|
import { Card, CardContent } from '@/components/ui/Card';
|
|
import { Badge } from '@/components/ui/Badge';
|
|
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
|
|
import { formatDate } from '@/lib/utils';
|
|
import { getWwwOrigin } from '@/lib/hosts';
|
|
import {
|
|
ONBOARDING_DOWNLOAD_COMPLETE_EVENT,
|
|
ONBOARDING_DOWNLOAD_COMPLETE_KEY,
|
|
} from '@/lib/revops';
|
|
|
|
function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string {
|
|
const cloned = svgElement.cloneNode(true) as SVGElement;
|
|
const NS = 'http://www.w3.org/2000/svg';
|
|
|
|
const widthAttr = cloned.getAttribute('width');
|
|
const heightAttr = cloned.getAttribute('height');
|
|
const width = widthAttr ? parseFloat(widthAttr) : 200;
|
|
const height = heightAttr ? parseFloat(heightAttr) : 100;
|
|
const extraHeight = 18;
|
|
|
|
cloned.setAttribute('height', String(height + extraHeight));
|
|
const viewBox = cloned.getAttribute('viewBox');
|
|
if (viewBox) {
|
|
const parts = viewBox.split(/\s+/);
|
|
if (parts.length === 4) {
|
|
cloned.setAttribute(
|
|
'viewBox',
|
|
`${parts[0]} ${parts[1]} ${parts[2]} ${parseFloat(parts[3]) + extraHeight}`
|
|
);
|
|
}
|
|
}
|
|
|
|
const text = document.createElementNS(NS, 'text');
|
|
text.setAttribute('x', String(width / 2));
|
|
text.setAttribute('y', String(height + 12));
|
|
text.setAttribute('text-anchor', 'middle');
|
|
text.setAttribute('font-size', '9');
|
|
text.setAttribute('font-family', 'Arial, Helvetica, sans-serif');
|
|
text.setAttribute('fill', '#666666');
|
|
text.textContent = caption;
|
|
cloned.appendChild(text);
|
|
|
|
return new XMLSerializer().serializeToString(cloned);
|
|
}
|
|
|
|
interface QRCodeCardProps {
|
|
qr: {
|
|
id: string;
|
|
title: string;
|
|
type: 'STATIC' | 'DYNAMIC';
|
|
contentType: string;
|
|
content?: any;
|
|
slug: string;
|
|
createdAt: string;
|
|
scans?: number;
|
|
style?: any;
|
|
};
|
|
onEdit: (id: string) => void;
|
|
onDelete: (id: string) => void;
|
|
}
|
|
|
|
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
|
qr,
|
|
onEdit,
|
|
onDelete,
|
|
}) => {
|
|
const markDownloadComplete = () => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
localStorage.setItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY, '1');
|
|
window.dispatchEvent(new CustomEvent(ONBOARDING_DOWNLOAD_COMPLETE_EVENT));
|
|
};
|
|
|
|
// For dynamic QR codes, use the redirect URL for tracking
|
|
// For static QR codes, use the direct URL from content
|
|
//
|
|
// Must be the WWW origin, never the app origin: this value gets encoded into the QR
|
|
// code the user downloads and prints. /r/<slug> is served by the marketing host.
|
|
const baseUrl = getWwwOrigin();
|
|
|
|
// Get the QR URL based on type
|
|
let qrUrl = '';
|
|
|
|
// SIMPLE FIX: For STATIC QR codes, ALWAYS use the direct content
|
|
if (qr.type === 'STATIC') {
|
|
// Extract the actual URL/content based on contentType
|
|
if (qr.contentType === 'URL' && qr.content?.url) {
|
|
qrUrl = qr.content.url;
|
|
} else if (qr.contentType === 'PHONE' && qr.content?.phone) {
|
|
qrUrl = `tel:${qr.content.phone}`;
|
|
} else if (qr.contentType === 'VCARD') {
|
|
// VCARD content needs to be formatted properly
|
|
qrUrl = `BEGIN:VCARD
|
|
VERSION:3.0
|
|
FN:${qr.content.firstName || ''} ${qr.content.lastName || ''}
|
|
N:${qr.content.lastName || ''};${qr.content.firstName || ''};;;
|
|
${qr.content.organization ? `ORG:${qr.content.organization}` : ''}
|
|
${qr.content.title ? `TITLE:${qr.content.title}` : ''}
|
|
${qr.content.email ? `EMAIL:${qr.content.email}` : ''}
|
|
${qr.content.phone ? `TEL:${qr.content.phone}` : ''}
|
|
END:VCARD`;
|
|
} else if (qr.contentType === 'GEO' && qr.content) {
|
|
const lat = qr.content.latitude || 0;
|
|
const lon = qr.content.longitude || 0;
|
|
const label = qr.content.label ? `?q=${encodeURIComponent(qr.content.label)}` : '';
|
|
qrUrl = `geo:${lat},${lon}${label}`;
|
|
} else if (qr.contentType === 'TEXT' && qr.content?.text) {
|
|
qrUrl = qr.content.text;
|
|
} else if (qr.content?.qrContent) {
|
|
// Fallback to qrContent if it exists
|
|
qrUrl = qr.content.qrContent;
|
|
} else {
|
|
// Last resort fallback
|
|
qrUrl = `${baseUrl}/r/${qr.slug}`;
|
|
}
|
|
console.log(`STATIC QR [${qr.title}]: ${qrUrl}`);
|
|
} else {
|
|
// DYNAMIC QR codes always use redirect for tracking
|
|
qrUrl = `${baseUrl}/r/${qr.slug}`;
|
|
console.log(`DYNAMIC QR [${qr.title}]: ${qrUrl}`);
|
|
}
|
|
|
|
const downloadQR = async (format: 'png' | 'svg') => {
|
|
// Use the tight download wrapper
|
|
const container = document.querySelector(`#qr-download-${qr.id}`);
|
|
if (!container) return;
|
|
|
|
// Dynamic import of html-to-image
|
|
const { toPng } = await import('html-to-image');
|
|
|
|
try {
|
|
if (format === 'png') {
|
|
const dataUrl = await toPng(container as HTMLElement, {
|
|
cacheBust: true,
|
|
pixelRatio: 3,
|
|
backgroundColor: '#ffffff' // White background for clean export
|
|
});
|
|
const link = document.createElement('a');
|
|
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
|
|
link.href = dataUrl;
|
|
link.click();
|
|
markDownloadComplete();
|
|
} else {
|
|
// For SVG, if no frame, export just the QR code SVG for vector quality
|
|
// If frame exists, use toPng as fallback since HTML-to-SVG is complex
|
|
if (qr.style?.frameType && qr.style.frameType !== 'none') {
|
|
// Frame exists - use PNG for full capture
|
|
const dataUrl = await toPng(container as HTMLElement, {
|
|
cacheBust: true,
|
|
pixelRatio: 3,
|
|
backgroundColor: '#ffffff'
|
|
});
|
|
const link = document.createElement('a');
|
|
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
|
|
link.href = dataUrl;
|
|
link.click();
|
|
markDownloadComplete();
|
|
} else {
|
|
// No frame - export clean SVG from the svg wrapper
|
|
const svgContainer = document.querySelector(`#qr-svg-${qr.id}`);
|
|
const svg = svgContainer?.querySelector('svg');
|
|
if (svg) {
|
|
let svgData = qr.contentType === 'BARCODE'
|
|
? addBarcodeCaptionToSvg(svg as SVGElement, 'Scan: iPhone -> Barcode Scanner App | Android -> Google Lens')
|
|
: new XMLSerializer().serializeToString(svg);
|
|
|
|
if (qr.style?.cornerStyle === 'rounded') {
|
|
const width = svg.getAttribute('width') || '96';
|
|
const height = svg.getAttribute('height') || '96';
|
|
const borderRadius = 10;
|
|
svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
<defs>
|
|
<clipPath id="rounded-corners-${qr.id}">
|
|
<rect x="0" y="0" width="${width}" height="${height}" rx="${borderRadius}" ry="${borderRadius}"/>
|
|
</clipPath>
|
|
</defs>
|
|
<g clip-path="url(#rounded-corners-${qr.id})">
|
|
${svgData}
|
|
</g>
|
|
</svg>`;
|
|
}
|
|
|
|
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
markDownloadComplete();
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Download failed:', err);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card hover className="rounded-[24px] border-slate-200 p-0 shadow-none">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3>
|
|
<div className="flex items-center space-x-2">
|
|
<Badge className={qr.type === 'DYNAMIC' ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-700'}>
|
|
{qr.type}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<Dropdown
|
|
align="right"
|
|
trigger={
|
|
<button className="p-1 hover:bg-gray-100 rounded">
|
|
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
|
</svg>
|
|
</button>
|
|
}
|
|
>
|
|
<DropdownItem onClick={() => window.location.href = `/qr/${qr.id}`}>View Details</DropdownItem>
|
|
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
|
|
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
|
|
{qr.type === 'DYNAMIC' && (
|
|
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
|
|
)}
|
|
<DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600">
|
|
Delete
|
|
</DropdownItem>
|
|
</Dropdown>
|
|
</div>
|
|
|
|
<div className="mb-3 flex flex-col items-center justify-center rounded-[20px] bg-slate-50 p-4">
|
|
{/* Download wrapper - tightly wraps content */}
|
|
<div
|
|
id={`qr-download-${qr.id}`}
|
|
className="inline-flex flex-col items-center rounded-[20px] border border-slate-100 bg-white p-4"
|
|
>
|
|
{/* Frame Label */}
|
|
{qr.style?.frameType && qr.style.frameType !== 'none' && (
|
|
<div
|
|
className="mb-3 px-4 py-1.5 rounded-full font-bold text-xs tracking-widest uppercase shadow-sm text-white"
|
|
style={{ backgroundColor: qr.style?.foregroundColor || '#000000' }}
|
|
>
|
|
{qr.style.frameType === 'scanme' ? 'Scan Me' :
|
|
qr.style.frameType === 'website' ? 'Website' :
|
|
qr.style.frameType === 'visit' ? 'Visit' :
|
|
qr.style.frameType === 'callme' ? 'Call Me' :
|
|
qr.style.frameType === 'call' ? 'Call' :
|
|
qr.style.frameType === 'findus' ? 'Find Us' :
|
|
qr.style.frameType === 'navigate' ? 'Navigate' :
|
|
qr.style.frameType === 'contact' ? 'Contact' :
|
|
qr.style.frameType === 'save' ? 'Save' :
|
|
qr.style.frameType === 'textme' ? 'Text Me' :
|
|
qr.style.frameType === 'message' ? 'Message' :
|
|
qr.style.frameType === 'chatme' ? 'Chat Me' :
|
|
qr.style.frameType === 'whatsapp' ? 'WhatsApp' :
|
|
qr.style.frameType === 'read' ? 'Read' :
|
|
qr.style.frameType === 'info' ? 'Info' :
|
|
qr.style.frameType.charAt(0).toUpperCase() + qr.style.frameType.slice(1)}
|
|
</div>
|
|
)}
|
|
<div id={`qr-svg-${qr.id}`} className={qr.style?.cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
|
|
{qr.contentType === 'BARCODE' ? (
|
|
<div className="w-full max-w-[240px] [&_svg]:!w-full [&_svg]:!h-auto [&_svg]:!max-w-full">
|
|
<Barcode
|
|
key={`${qr.id}-${qr.type === 'STATIC' ? qr.content?.value : qrUrl}-${qr.content?.format}`}
|
|
value={qr.type === 'STATIC' ? (qr.content?.value || '123456789') : qrUrl}
|
|
format={(qr.content?.format as any) || 'CODE128'}
|
|
lineColor={qr.style?.foregroundColor || '#000000'}
|
|
background={qr.style?.backgroundColor || '#FFFFFF'}
|
|
width={2}
|
|
height={70}
|
|
margin={8}
|
|
displayValue={true}
|
|
fontSize={11}
|
|
/>
|
|
<p className="mt-1 text-center text-[9px] leading-tight text-gray-600 px-1">
|
|
Scan: iPhone → Barcode Scanner App · Android → Google Lens
|
|
</p>
|
|
</div>
|
|
) : (
|
|
/* Same renderer as the detail page and the bulk export.
|
|
qrcode.react only knows two colours, so a code saved with a
|
|
module shape, gradient or logo showed up here as a plain
|
|
black grid - the design looked lost even when it was stored. */
|
|
<StyledQRCode
|
|
value={qrUrl}
|
|
size={96}
|
|
fgColor={qr.style?.foregroundColor || '#000000'}
|
|
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
|
|
moduleShape={qr.style?.moduleShape || 'square'}
|
|
eyeFrameShape={qr.style?.eyeFrameShape || 'square'}
|
|
eyeBallShape={qr.style?.eyeBallShape || 'square'}
|
|
gradient={
|
|
qr.style?.gradientMode && qr.style.gradientMode !== 'none'
|
|
? {
|
|
type: qr.style.gradientMode,
|
|
from: qr.style.foregroundColor || '#000000',
|
|
to: qr.style.gradientTo || '#000000',
|
|
}
|
|
: null
|
|
}
|
|
errorCorrection="H"
|
|
logoUrl={qr.style?.imageSettings?.src}
|
|
logoScale={(qr.style?.imageSettings?.width ?? 24) / 200}
|
|
margin={0}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-500">Type:</span>
|
|
<span className="text-gray-900">{qr.contentType}</span>
|
|
</div>
|
|
{qr.type === 'DYNAMIC' && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-500">Scans:</span>
|
|
<span className="text-gray-900">{qr.scans || 0}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-500">Created:</span>
|
|
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
|
|
</div>
|
|
{qr.type === 'DYNAMIC' && (
|
|
<div className="border-t border-slate-100 pt-2">
|
|
<p className="text-xs text-gray-500">
|
|
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{/* Feedback Button - only for FEEDBACK type */}
|
|
{qr.contentType === 'FEEDBACK' && (
|
|
<button
|
|
onClick={() => window.location.href = `/qr/${qr.id}/feedback`}
|
|
className="w-full mt-3 py-2 px-3 bg-amber-50 hover:bg-amber-100 text-amber-700 rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
|
>
|
|
⭐ View Customer Feedback
|
|
</button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|