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

View File

@@ -24,7 +24,6 @@ const CAT_EMOJI = {
itinerary: '🗺️', itinerary: '🗺️',
budget: '💸', budget: '💸',
} }
function normalizeSourceKey(value) { function normalizeSourceKey(value) {
return String(value || '') return String(value || '')
.trim() .trim()
@@ -83,6 +82,12 @@ function cx(...classes) {
return classes.filter(Boolean).join(' ') 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() { function useCaboData() {
const [state, setState] = useState({ const [state, setState] = useState({
categories: [], categories: [],
@@ -367,7 +372,7 @@ function MainRoute({ data, tabs, guest, token, send, mobileView, setMobileView }
</div> </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')}> <section className={cx(showMap && mobileView === 'map' && 'hidden md:block')}>
<OptionList tabId={tabId} tab={tab} options={visibleOptions} data={data} guest={guest} token={token} send={send} /> <OptionList tabId={tabId} tab={tab} options={visibleOptions} data={data} guest={guest} token={token} send={send} />
<AddOptionForm categories={data.categories} token={token} guest={guest} /> <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 latestPoint = getLatestPoint(option, tabId)
const source = getAvailableSources(option, tabId)[0] const source = getAvailableSources(option, tabId)[0]
const bookingUrl = getBookingUrl(option, tabId) const bookingUrl = getBookingUrl(option, tabId)
const imageUrl = getOptionImageUrl({ ...option, bookingUrl })
const links = getVisibleLinks(option, tabId) const links = getVisibleLinks(option, tabId)
const price = latestPoint?.displayPrice || (typeof latestPoint?.price === 'number' ? formatMoney(latestPoint.price, latestPoint.currency) : source?.latestDisplayPrice || '') 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 ') const dates = [formatDate(latestPoint?.tripCheckIn), formatDate(latestPoint?.tripCheckOut)].filter(Boolean).join(' to ')
@@ -453,21 +459,30 @@ function OptionCard({ option, tabId, guest, token, send }) {
} }
return ( 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')}> <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 md:grid-cols-[minmax(132px,34%)_1fr]"> <div className="grid gap-0">
<div className="min-h-44 bg-panel2"> <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">
<img src={option.imageUrl} alt="" className="h-full min-h-44 w-full object-cover" loading="lazy" /> {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>
<div className="p-4"> <div className="min-w-0 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div className="min-w-0">
<h2 className="text-base font-black text-white">{option.name}</h2> <h2 className="break-words text-base font-black text-white">{option.name}</h2>
<p className="mt-1 text-sm leading-5 text-slate-400">{option.desc}</p> <p className="mt-1 break-words text-sm leading-5 text-slate-400">{option.desc}</p>
</div> </div>
<div className="shrink-0 text-sm font-black text-aqua">{votes.length} vote{votes.length === 1 ? '' : 's'}</div> <div className="shrink-0 text-sm font-black text-aqua">{votes.length} vote{votes.length === 1 ? '' : 's'}</div>
</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="Current price" value={price || 'Not tracked yet'} sub={dates} />
<Fact label="Source" value={source?.sourceLabel || latestPoint?.source || 'Planning data'} /> <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'} /> <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 && ( {!!chips.length && (
<div className="mt-3 flex flex-wrap gap-1.5"> <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> </div>
)} )}
@@ -487,7 +502,7 @@ function OptionCard({ option, tabId, guest, token, send }) {
</a> </a>
)} )}
{links.slice(0, 4).map((link) => ( {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} {link.label}
</a> </a>
))} ))}
@@ -508,10 +523,10 @@ function OptionCard({ option, tabId, guest, token, send }) {
function Fact({ label, value, sub }) { function Fact({ label, value, sub }) {
return ( 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="text-[10px] font-black uppercase tracking-wider text-slate-500">{label}</div>
<div className="mt-1 text-sm font-bold text-white">{value}</div> <div className="mt-1 break-words text-sm font-bold leading-5 text-white">{value}</div>
{sub && <div className="mt-1 text-xs text-slate-500">{sub}</div>} {sub && <div className="mt-1 break-words text-xs leading-4 text-slate-500">{sub}</div>}
</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) { function buildOptionImageUrl(option) {
if (option.imageUrl) return option.imageUrl; if (option.imageUrl) return option.imageUrl;
const query = [ const primaryUrl = option.links?.[0]?.url || option.url || option.bookingUrl || '';
option.name, return primaryUrl ? `/api/preview-image?url=${encodeURIComponent(primaryUrl)}` : null;
OPTION_IMAGE_QUERIES[option.categoryId] || 'los cabos mexico',
].filter(Boolean).join(' ');
return `https://source.unsplash.com/640x420/?${encodeURIComponent(query)}`;
} }
function createOption(option) { function createOption(option) {

View File

@@ -28,6 +28,8 @@ const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
: DEFAULT_PRICE_HISTORY_FILE; : DEFAULT_PRICE_HISTORY_FILE;
const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-03'; 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 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(cors());
app.use(express.json()); app.use(express.json());
@@ -84,6 +86,48 @@ function normalizeSourceLabel(value) {
return String(value || 'Unknown source').trim() || 'Unknown source'; 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') { function formatCurrencyValue(value, currency = 'USD') {
if (typeof value !== 'number' || !Number.isFinite(value)) return ''; if (typeof value !== 'number' || !Number.isFinite(value)) return '';
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
@@ -800,6 +844,41 @@ app.get('/api/options', (req, res) => {
res.json(decorateOptionsWithPriceHistory(options, priceHistoryState)); 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) => { app.get('/api/results', (req, res) => {
const priceHistoryState = loadPriceHistoryState(); const priceHistoryState = loadPriceHistoryState();
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || []; const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];