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
37 changes: 37 additions & 0 deletions apps/web/app/api/favicon/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextRequest } from 'next/server';

// Always answers 200 with an image so a store logo is never a broken link.
// See lib/logo.ts.

const HOSTNAME = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
const CACHE = 'public, max-age=604800, s-maxage=604800, stale-while-revalidate=86400';

function letterTile(domain: string): Response {
const letter = (domain.replace(/^www\./i, '')[0] ?? '?').toUpperCase().replace(/[^A-Z0-9]/, '?');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#fff7ed"/><text x="32" y="42" font-family="system-ui,sans-serif" font-size="30" font-weight="900" text-anchor="middle" fill="#f97316">${letter}</text></svg>`;
return new Response(svg, { status: 200, headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': CACHE } });
}

export async function GET(req: NextRequest) {
const domain = (req.nextUrl.searchParams.get('domain') ?? '').trim().toLowerCase();
const sz = Math.min(256, Math.max(16, Number(req.nextUrl.searchParams.get('sz')) || 128));
if (!HOSTNAME.test(domain)) return letterTile(domain);

try {
const res = await fetch(`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=${sz}`, {
redirect: 'follow',
signal: AbortSignal.timeout(5000),
});
const type = res.headers.get('content-type') ?? '';
// A 404 from Google still carries its generic globe icon; serve it as 200.
if (type.startsWith('image/')) {
const body = await res.arrayBuffer();
if (body.byteLength > 0) {
return new Response(body, { status: 200, headers: { 'Content-Type': type, 'Cache-Control': CACHE } });
}
}
} catch {
// fall through to the letter tile
}
return letterTile(domain);
}
3 changes: 2 additions & 1 deletion apps/web/app/stores/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Metadata } from 'next';
import { cache } from 'react';
import { notFound } from 'next/navigation';
import Image from 'next/image';
import { logoSrc } from '@/lib/logo';
import CouponCard from '@/components/CouponCard';
import { getDb } from '@/lib/db';
import { Coupon, Store } from '@/lib/types';
Expand Down Expand Up @@ -69,7 +70,7 @@ export default async function StorePage({ params }: { params: Promise<{ slug: st
<div className="flex flex-col gap-8">
<div className="flex items-center gap-4 bg-white border border-gray-200 rounded-xl p-6">
{store.logo_url ? (
<Image src={store.logo_url} alt={store.name} width={64} height={64} className="rounded object-contain" />
<Image {...logoSrc(store.logo_url)} alt={store.name} width={64} height={64} className="rounded object-contain" />
) : (
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center text-2xl font-bold text-gray-400">
{store.name[0]}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/components/CouponCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from 'next/link';
import Image from 'next/image';
import { logoSrc } from '@/lib/logo';
import CopyButton from './CopyButton';
import VoteButton from './VoteButton';
import { Coupon } from '@/lib/types';
Expand All @@ -11,7 +12,7 @@ export default function CouponCard({ coupon }: { coupon: Coupon }) {
<div className="flex items-center gap-2.5">
{coupon.store_logo ? (
<Image
src={coupon.store_logo}
{...logoSrc(coupon.store_logo)}
alt={coupon.store_name ?? ''}
width={32}
height={32}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/components/StoreCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from 'next/link';
import Image from 'next/image';
import { logoSrc } from '@/lib/logo';
import { Store } from '@/lib/types';

interface StoreWithCount extends Store {
Expand All @@ -14,7 +15,7 @@ export default function StoreCard({ store }: { store: StoreWithCount }) {
>
{store.logo_url ? (
<Image
src={store.logo_url}
{...logoSrc(store.logo_url)}
alt={store.name}
width={48}
height={48}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/lib/logo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Store logos scraped from Reddit/nichedb/Flipp often fall back to Google's s2
// favicon service. For domains Google has no icon for it answers 404 (with a
// generic globe PNG as the body), and next/image passes that 404 straight
// through, so /_next/image?url=...s2/favicons... showed up as broken links.
// Those URLs are routed through /api/favicon instead, which always answers 200
// with an image (Google's icon, its globe, or a letter tile).

const GOOGLE_FAVICON = /^https:\/\/www\.google\.com\/s2\/favicons\?/i;

export function logoSrc(url: string): { src: string; unoptimized: boolean } {
if (!GOOGLE_FAVICON.test(url)) return { src: url, unoptimized: false };
const params = new URL(url).searchParams;
const domain = params.get('domain') ?? '';
const sz = params.get('sz') ?? '128';
return {
src: `/api/favicon?domain=${encodeURIComponent(domain)}&sz=${encodeURIComponent(sz)}`,
// A 16-128px icon gains nothing from the optimizer, and a local src with a
// query string would need images.localPatterns.
unoptimized: true,
};
}
Loading