Fix option card readability and image loading

This commit is contained in:
TopherMayor
2026-06-12 11:10:27 -07:00
parent fa0a7f44b7
commit 4cce703544
7 changed files with 133 additions and 52 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

18
client/dist/assets/index-P6j6uqrQ.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cabo Bachelor Party</title>
<script type="module" crossorigin src="/assets/index-OJVYK787.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DKrFtyUx.css">
<script type="module" crossorigin src="/assets/index-P6j6uqrQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B5xoFPr6.css">
</head>
<body>
<div id="root"></div>

View File

@@ -24,7 +24,6 @@ const CAT_EMOJI = {
itinerary: '🗺️',
budget: '💸',
}
function normalizeSourceKey(value) {
return String(value || '')
.trim()
@@ -83,6 +82,12 @@ function cx(...classes) {
return classes.filter(Boolean).join(' ')
}
function getOptionImageUrl(option) {
if (option.imageUrl) return option.imageUrl
const bookingUrl = option.bookingUrl || option.url || option.links?.[0]?.url || ''
return bookingUrl ? `/api/preview-image?url=${encodeURIComponent(bookingUrl)}` : ''
}
function useCaboData() {
const [state, setState] = useState({
categories: [],
@@ -367,7 +372,7 @@ function MainRoute({ data, tabs, guest, token, send, mobileView, setMobileView }
</div>
)}
<div className={cx('grid gap-4', showMap && 'md:grid-cols-[minmax(0,1fr)_minmax(360px,42vw)]')}>
<div className={cx('grid gap-4', showMap && 'lg:grid-cols-[minmax(420px,520px)_minmax(0,1fr)] xl:grid-cols-[minmax(520px,640px)_minmax(0,1fr)]')}>
<section className={cx(showMap && mobileView === 'map' && 'hidden md:block')}>
<OptionList tabId={tabId} tab={tab} options={visibleOptions} data={data} guest={guest} token={token} send={send} />
<AddOptionForm categories={data.categories} token={token} guest={guest} />
@@ -438,6 +443,7 @@ function OptionCard({ option, tabId, guest, token, send }) {
const latestPoint = getLatestPoint(option, tabId)
const source = getAvailableSources(option, tabId)[0]
const bookingUrl = getBookingUrl(option, tabId)
const imageUrl = getOptionImageUrl({ ...option, bookingUrl })
const links = getVisibleLinks(option, tabId)
const price = latestPoint?.displayPrice || (typeof latestPoint?.price === 'number' ? formatMoney(latestPoint.price, latestPoint.currency) : source?.latestDisplayPrice || '')
const dates = [formatDate(latestPoint?.tripCheckIn), formatDate(latestPoint?.tripCheckOut)].filter(Boolean).join(' to ')
@@ -453,21 +459,30 @@ function OptionCard({ option, tabId, guest, token, send }) {
}
return (
<article className={cx('overflow-hidden rounded-xl border bg-panel shadow-lg transition hover:-translate-y-0.5 hover:border-aqua/70', hasVoted ? 'border-aqua/70' : 'border-line')}>
<div className="grid gap-0 md:grid-cols-[minmax(132px,34%)_1fr]">
<div className="min-h-44 bg-panel2">
<img src={option.imageUrl} alt="" className="h-full min-h-44 w-full object-cover" loading="lazy" />
<article className={cx('min-w-0 overflow-hidden rounded-xl border bg-panel shadow-lg transition hover:-translate-y-0.5 hover:border-aqua/70', hasVoted ? 'border-aqua/70' : 'border-line')}>
<div className="grid gap-0">
<div className="grid h-44 place-items-center overflow-hidden bg-gradient-to-br from-aqua/10 via-panel2 to-gold/10 sm:h-52">
{imageUrl ? (
<img
src={imageUrl}
alt={`${option.name} booking site preview`}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="px-6 text-center text-sm font-black uppercase tracking-wider text-slate-500">No booking image</div>
)}
</div>
<div className="p-4">
<div className="min-w-0 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-black text-white">{option.name}</h2>
<p className="mt-1 text-sm leading-5 text-slate-400">{option.desc}</p>
<div className="min-w-0">
<h2 className="break-words text-base font-black text-white">{option.name}</h2>
<p className="mt-1 break-words text-sm leading-5 text-slate-400">{option.desc}</p>
</div>
<div className="shrink-0 text-sm font-black text-aqua">{votes.length} vote{votes.length === 1 ? '' : 's'}</div>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<div className="mt-3 grid gap-2 min-[560px]:grid-cols-2">
<Fact label="Current price" value={price || 'Not tracked yet'} sub={dates} />
<Fact label="Source" value={source?.sourceLabel || latestPoint?.source || 'Planning data'} />
<Fact label="Booking" value={formatBookingType(source?.bookingType || latestPoint?.bookingType, source?.priceBasis || latestPoint?.priceBasis) || 'Option link'} />
@@ -476,7 +491,7 @@ function OptionCard({ option, tabId, guest, token, send }) {
{!!chips.length && (
<div className="mt-3 flex flex-wrap gap-1.5">
{chips.map((chip) => <span key={chip} className="rounded-full bg-panel2 px-2.5 py-1 text-[11px] text-slate-300">{chip}</span>)}
{chips.map((chip) => <span key={chip} className="max-w-full rounded-full bg-panel2 px-2.5 py-1 text-[11px] text-slate-300">{chip}</span>)}
</div>
)}
@@ -487,7 +502,7 @@ function OptionCard({ option, tabId, guest, token, send }) {
</a>
)}
{links.slice(0, 4).map((link) => (
<a key={`${option.id}-${link.label}`} href={link.url} target="_blank" rel="noreferrer" className="rounded-full border border-aqua/20 bg-aqua/10 px-3 py-2 text-xs font-bold text-aqua hover:bg-aqua/20">
<a key={`${option.id}-${link.label}`} href={link.url} target="_blank" rel="noreferrer" className="max-w-full rounded-full border border-aqua/20 bg-aqua/10 px-3 py-2 text-xs font-bold text-aqua hover:bg-aqua/20">
{link.label}
</a>
))}
@@ -508,10 +523,10 @@ function OptionCard({ option, tabId, guest, token, send }) {
function Fact({ label, value, sub }) {
return (
<div className="rounded-lg border border-white/5 bg-white/[0.03] p-3">
<div className="min-w-0 rounded-lg border border-white/5 bg-white/[0.03] p-3">
<div className="text-[10px] font-black uppercase tracking-wider text-slate-500">{label}</div>
<div className="mt-1 text-sm font-bold text-white">{value}</div>
{sub && <div className="mt-1 text-xs text-slate-500">{sub}</div>}
<div className="mt-1 break-words text-sm font-bold leading-5 text-white">{value}</div>
{sub && <div className="mt-1 break-words text-xs leading-4 text-slate-500">{sub}</div>}
</div>
)
}

View File

@@ -176,23 +176,10 @@ const BUDGET_SCENARIOS = [
},
];
const OPTION_IMAGE_QUERIES = {
hotel: 'cabo san lucas resort',
flight: 'airplane mexico coast',
golf: 'los cabos golf course',
nightlife: 'cabo san lucas nightlife',
excursion: 'cabo san lucas boat',
itinerary: 'los cabos beach marina',
budget: 'cabo san lucas marina',
};
function buildOptionImageUrl(option) {
if (option.imageUrl) return option.imageUrl;
const query = [
option.name,
OPTION_IMAGE_QUERIES[option.categoryId] || 'los cabos mexico',
].filter(Boolean).join(' ');
return `https://source.unsplash.com/640x420/?${encodeURIComponent(query)}`;
const primaryUrl = option.links?.[0]?.url || option.url || option.bookingUrl || '';
return primaryUrl ? `/api/preview-image?url=${encodeURIComponent(primaryUrl)}` : null;
}
function createOption(option) {

View File

@@ -28,6 +28,8 @@ const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
: DEFAULT_PRICE_HISTORY_FILE;
const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-03';
const TRIP_CHECK_OUT = process.env.TRIP_CHECK_OUT || '2027-02-07';
const PREVIEW_IMAGE_CACHE_MS = 1000 * 60 * 60 * 24;
const previewImageCache = new Map();
app.use(cors());
app.use(express.json());
@@ -84,6 +86,48 @@ function normalizeSourceLabel(value) {
return String(value || 'Unknown source').trim() || 'Unknown source';
}
function parseHttpUrl(value) {
try {
const url = new URL(String(value || '').trim());
if (!['http:', 'https:'].includes(url.protocol)) return null;
return url;
} catch {
return null;
}
}
function resolveHtmlUrl(value, baseUrl) {
if (!value) return null;
try {
const resolved = new URL(value, baseUrl);
if (!['http:', 'https:'].includes(resolved.protocol)) return null;
return resolved.toString();
} catch {
return null;
}
}
function extractPreviewImage(html, pageUrl) {
const patterns = [
/<meta[^>]+property=["']og:image:secure_url["'][^>]+content=["']([^"']+)["'][^>]*>/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image:secure_url["'][^>]*>/i,
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["'][^>]*>/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["'][^>]*>/i,
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["'][^>]*>/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["'][^>]*>/i,
/<link[^>]+rel=["'][^"']*image_src[^"']*["'][^>]+href=["']([^"']+)["'][^>]*>/i,
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*image_src[^"']*["'][^>]*>/i,
];
for (const pattern of patterns) {
const match = html.match(pattern);
const imageUrl = resolveHtmlUrl(match?.[1], pageUrl);
if (imageUrl) return imageUrl;
}
return null;
}
function formatCurrencyValue(value, currency = 'USD') {
if (typeof value !== 'number' || !Number.isFinite(value)) return '';
return new Intl.NumberFormat('en-US', {
@@ -800,6 +844,41 @@ app.get('/api/options', (req, res) => {
res.json(decorateOptionsWithPriceHistory(options, priceHistoryState));
});
app.get('/api/preview-image', async (req, res) => {
const pageUrl = parseHttpUrl(req.query.url);
if (!pageUrl) return res.status(400).send('A valid http(s) url is required');
const cacheKey = pageUrl.toString();
const cached = previewImageCache.get(cacheKey);
if (cached && Date.now() - cached.checkedAt < PREVIEW_IMAGE_CACHE_MS) {
return res.redirect(302, cached.imageUrl);
}
const fallbackUrl = new URL('/favicon.ico', pageUrl.origin).toString();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(pageUrl, {
signal: controller.signal,
headers: {
Accept: 'text/html,application/xhtml+xml',
'User-Agent': 'Mozilla/5.0 (compatible; CaboVotePreview/1.0)',
},
});
clearTimeout(timeout);
if (!response.ok) throw new Error(`Preview fetch failed: ${response.status}`);
const html = await response.text();
const imageUrl = extractPreviewImage(html.slice(0, 250000), pageUrl.toString()) || fallbackUrl;
previewImageCache.set(cacheKey, { imageUrl, checkedAt: Date.now() });
return res.redirect(302, imageUrl);
} catch (error) {
previewImageCache.set(cacheKey, { imageUrl: fallbackUrl, checkedAt: Date.now() });
return res.redirect(302, fallbackUrl);
}
});
app.get('/api/results', (req, res) => {
const priceHistoryState = loadPriceHistoryState();
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];