Fix option card readability and image loading
This commit is contained in:
File diff suppressed because one or more lines are too long
18
client/dist/assets/index-OJVYK787.js
vendored
18
client/dist/assets/index-OJVYK787.js
vendored
File diff suppressed because one or more lines are too long
18
client/dist/assets/index-P6j6uqrQ.js
vendored
Normal file
18
client/dist/assets/index-P6j6uqrQ.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
client/dist/index.html
vendored
4
client/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
17
seed-data.js
17
seed-data.js
@@ -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) {
|
||||
|
||||
79
server.js
79
server.js
@@ -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 || [];
|
||||
|
||||
Reference in New Issue
Block a user