446 lines
19 KiB
JavaScript
446 lines
19 KiB
JavaScript
// ------------------------------------------------------------------------
|
||
// components/DayTabs.js - Day-tab strip, pro-prompt card, confidence
|
||
// band bar, and filter-profile row for the UTCIForecast app.
|
||
//
|
||
// Extracted from app.js to keep the main component under the AI edit
|
||
// safe-zone. All state lives in useAppState - this component is purely
|
||
// presentational and receives everything it needs as props.
|
||
//
|
||
// Props:
|
||
// days - array of day objects from buildHourlyRows
|
||
// selectedDay - index of the active day tab
|
||
// setSelectedDay - setter for selectedDay
|
||
// isPro - boolean Pro status
|
||
// FREE_DAYS - number of free days
|
||
// proPromptDay - index of locked day that was clicked
|
||
// setProPromptDay - setter for proPromptDay
|
||
// proPromptSource - string key for upsell copy
|
||
// setProPromptSource - setter for proPromptSource
|
||
// activeProfile - current filter profile key
|
||
// activateProfile - function to switch profile
|
||
// activeCols - effective column set for current profile
|
||
// visibleCols - object of col-key -> boolean visibility
|
||
// activityOptions - dropdown options for Activities selector
|
||
// placeOptions - dropdown options for Places selector
|
||
// activityValue - current activity dropdown value
|
||
// activityLabel - current activity dropdown label
|
||
// placeValue - current place dropdown value
|
||
// placeLabel - current place dropdown label
|
||
// outdoorsVariant - current outdoors sub-variant key
|
||
// setOutdoorsVariantAndSave - setter that also persists to localStorage
|
||
// setActiveProfile - raw setter for activeProfile
|
||
// setVisibleCols - raw setter for visibleCols
|
||
// setIndoorMode - setter for indoorMode
|
||
// setIndoorManaged - setter for indoorManaged
|
||
// dayTabsRef - ref for the scrollable tab strip element
|
||
// canScrollLeft - boolean for left-fade chevron
|
||
// canScrollRight - boolean for right-fade chevron
|
||
// scrollDayTabs - function(dir) to scroll strip left/right
|
||
// ------------------------------------------------------------------------
|
||
|
||
import { h, Fragment } from '../../vendor/preact.js';
|
||
import htm from '../../vendor/htm.js';
|
||
import { confidenceBand } from '../utils.js';
|
||
import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys } from '../config.js';
|
||
import { CloudIcon, PrecipIcon, CustomSelect } from '../components.js';
|
||
|
||
const html = htm.bind(h);
|
||
|
||
export function DayTabs({
|
||
days,
|
||
selectedDay, setSelectedDay,
|
||
isPro,
|
||
proPromptDay, setProPromptDay,
|
||
proPromptSource, setProPromptSource,
|
||
activeProfile, activateProfile, activeCols,
|
||
visibleCols,
|
||
activityOptions, placeOptions,
|
||
activityValue, activityLabel,
|
||
placeValue, placeLabel,
|
||
outdoorsVariant, setOutdoorsVariantAndSave,
|
||
setActiveProfile, setVisibleCols,
|
||
setIndoorMode, setIndoorManaged,
|
||
dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs,
|
||
}) {
|
||
return html`
|
||
<${Fragment}>
|
||
|
||
<div class=${`utci-day-tabs-wrap${canScrollLeft ? ' fade-left' : ''}${canScrollRight ? ' fade-right' : ''}`}>
|
||
<button
|
||
class=${`utci-day-scroll left${canScrollLeft ? '' : ' hidden'}`}
|
||
onClick=${() => scrollDayTabs(-1)}
|
||
aria-label="Scroll days left"
|
||
type="button">‹</button>
|
||
<button
|
||
class=${`utci-day-scroll right${canScrollRight ? '' : ' hidden'}`}
|
||
onClick=${() => scrollDayTabs(1)}
|
||
aria-label="Scroll days right"
|
||
type="button">›</button>
|
||
<div class="utci-day-tabs" ref=${dayTabsRef}>
|
||
${days.map((d, i) => {
|
||
const band = confidenceBand(i);
|
||
const locked = !isPro && i >= FREE_DAYS;
|
||
const isActive = i === selectedDay;
|
||
const dDate = new Date(d.key + 'T00:00Z');
|
||
const dayName = i === 0 ? 'Today'
|
||
: i === 1 ? 'Tomorrow'
|
||
: dDate.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
|
||
const utcipVals = d.rows.map(r => r.utciAdj).filter(v => isFinite(v));
|
||
const dayHi = utcipVals.length ? Math.round(Math.max(...utcipVals)) : null;
|
||
const dayLo = utcipVals.length ? Math.round(Math.min(...utcipVals)) : null;
|
||
|
||
// Day-tab weather icon - use daytime rows where available
|
||
const dayRows = d.rows.filter(r => r.elev > 0);
|
||
const repRows = dayRows.length > 0 ? dayRows : d.rows;
|
||
const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0);
|
||
const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0);
|
||
const catCounts = {};
|
||
repRows.forEach(r => { if (r.cloudCat) catCounts[r.cloudCat] = (catCounts[r.cloudCat] || 0) + 1; });
|
||
const modalCloudCat = Object.keys(catCounts).sort((a, b) => catCounts[b] - catCounts[a])[0] || 'clear';
|
||
const midRow = repRows[Math.floor(repRows.length / 2)];
|
||
const repElev = midRow ? midRow.elev : 45;
|
||
const repDt = midRow ? midRow.dt : dDate;
|
||
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
|
||
|
||
return html`
|
||
<button
|
||
key=${d.key}
|
||
class=${`utci-day-tab ${isActive ? 'active' : ''}${locked ? ' locked' : ''}`}
|
||
onClick=${() => {
|
||
if (locked) {
|
||
setProPromptSource('day');
|
||
setProPromptDay(i);
|
||
} else {
|
||
setSelectedDay(i);
|
||
setProPromptDay(null);
|
||
}
|
||
}}
|
||
title=${locked
|
||
? `${band.label} · SunScope Extra unlocks day ${i + 1}`
|
||
: `${band.label} · day ${i + 1} of 14`}
|
||
style=${{
|
||
background: band.bg,
|
||
color: '#2a1d10',
|
||
borderStyle: 'solid',
|
||
borderWidth: '0 0 3px 0',
|
||
borderBottomColor: isActive ? '#1e1208' : band.edge,
|
||
opacity: locked ? 0.5 : 1,
|
||
cursor: locked ? 'not-allowed' : 'pointer',
|
||
position: 'relative',
|
||
filter: isActive ? 'saturate(1.15) brightness(1.02)' : 'none',
|
||
}}
|
||
>
|
||
${locked && html`
|
||
<span style=${{ position: 'absolute', top: '3px', right: '5px', fontSize: '10px', opacity: 0.75 }}>🔒</span>`}
|
||
${dayName}
|
||
<span class="utci-day-date" style=${{ color: '#5a3f24' }}>
|
||
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
||
</span>
|
||
<span style=${{ display: 'block', margin: '3px auto 0', lineHeight: 1 }}>
|
||
${showPrecip
|
||
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${22} />`
|
||
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${22} />`}
|
||
</span>
|
||
${dayHi !== null && html`
|
||
<span style=${{
|
||
display: 'block',
|
||
fontFamily: 'Fraunces, serif',
|
||
fontStyle: 'italic',
|
||
fontSize: '11px',
|
||
marginTop: '2px',
|
||
letterSpacing: 0,
|
||
textTransform: 'none',
|
||
}}>
|
||
<span style=${{ color: '#c0622a', fontWeight: 600 }}>${dayHi}°</span>
|
||
<span style=${{ opacity: 0.5 }}> / </span>
|
||
<span style=${{ color: '#3a6080' }}>${dayLo}°</span>
|
||
</span>`}
|
||
</button>`;
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
${proPromptDay !== null && days[proPromptDay] && (() => {
|
||
const promptDate = new Date(days[proPromptDay].key + 'T00:00Z');
|
||
const dayName = promptDate.toLocaleDateString('en-GB', { weekday: 'long', timeZone: 'UTC' });
|
||
const extraPromptCopy = {
|
||
'variant:sailing': {
|
||
title: 'Sailing is part of SunScope Extra',
|
||
detail: 'Extra adds specialist planning views for higher-commitment trips, including wind, exposure, UV, and wet-weather comfort for water conditions.',
|
||
},
|
||
'profile:alltemps': {
|
||
title: 'Temps is part of SunScope Extra',
|
||
detail: 'Extra unlocks the comparison view for air, soil, concrete, vehicle, and indoor temperatures in one place.',
|
||
},
|
||
'profile:custom': {
|
||
title: 'Custom columns are part of SunScope Extra',
|
||
detail: 'Extra lets you choose exactly which columns appear: mix and match Air, Dew, Soil, UV, UTCI and more to build your perfect view.',
|
||
},
|
||
'variant:festival': {
|
||
title: 'Festival planning is part of SunScope Extra',
|
||
detail: 'Extra adds multi-day comfort, ground condition, exposure, and rain planning for higher-stakes outdoor trips.',
|
||
},
|
||
'variant:wintersports': {
|
||
title: 'Winter Sports is part of SunScope Extra',
|
||
detail: 'Extra adds specialist exposure planning for snow, glare, wind, UV reflection, and cold-weather comfort.',
|
||
},
|
||
'variant:naturist': {
|
||
title: 'Naturist is part of SunScope Extra',
|
||
detail: 'Extra adds specialist skin-exposure planning with UV, wind, humidity, precipitation, and felt-temperature detail.',
|
||
},
|
||
};
|
||
const promptCopy = extraPromptCopy[proPromptSource] || {
|
||
title: `${dayName}'s forecast is part of SunScope Extra`,
|
||
detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.',
|
||
};
|
||
return html`
|
||
<div style=${{
|
||
margin: '12px 0',
|
||
padding: '18px 22px',
|
||
background: '#fdf8ee',
|
||
border: '1.5px solid #c9b08a',
|
||
borderLeft: '4px solid #c8922a',
|
||
borderRadius: '0 4px 4px 0',
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '14px',
|
||
}}>
|
||
<div style=${{ flex: '1 1 320px', minWidth: '260px' }}>
|
||
<div style=${{
|
||
fontFamily: 'Fraunces, serif',
|
||
fontStyle: 'italic',
|
||
fontSize: '19px',
|
||
fontWeight: 700,
|
||
color: '#1e1208',
|
||
marginBottom: '6px',
|
||
lineHeight: 1.25,
|
||
}}>
|
||
🔒 ${promptCopy.title}
|
||
</div>
|
||
<div style=${{
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontSize: '13.5px',
|
||
color: '#4a3420',
|
||
lineHeight: 1.65,
|
||
}}>
|
||
${promptCopy.detail}
|
||
</div>
|
||
<div style=${{
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '11px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.07em',
|
||
color: '#c8922a',
|
||
marginTop: '10px',
|
||
}}>
|
||
£2 / month · cancel any time
|
||
</div>
|
||
</div>
|
||
<div style=${{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px',
|
||
alignItems: 'flex-end',
|
||
}}>
|
||
<a
|
||
href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style=${{
|
||
display: 'inline-block',
|
||
padding: '10px 18px',
|
||
background: '#c8922a',
|
||
color: '#fff',
|
||
textDecoration: 'none',
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '11px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.07em',
|
||
borderRadius: '3px',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
Subscribe — £2/month
|
||
</a>
|
||
<a
|
||
href="/restore.php"
|
||
style=${{
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '10px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.07em',
|
||
color: '#c8922a',
|
||
textDecoration: 'none',
|
||
borderBottom: '1px solid rgba(200,146,42,0.4)',
|
||
paddingBottom: '1px',
|
||
}}
|
||
>
|
||
Already subscribed? Restore access →
|
||
</a>
|
||
<a
|
||
href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style=${{
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '10px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.07em',
|
||
color: '#b09870',
|
||
textDecoration: 'none',
|
||
borderBottom: '1px solid rgba(176,152,112,0.4)',
|
||
paddingBottom: '1px',
|
||
}}
|
||
>
|
||
Manage or cancel subscription →
|
||
</a>
|
||
<button
|
||
onClick=${() => setProPromptDay(null)}
|
||
style=${{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 700,
|
||
fontSize: '10px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.07em',
|
||
color: '#b09870',
|
||
padding: '2px 4px',
|
||
}}
|
||
>
|
||
dismiss
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
})()}
|
||
|
||
${(() => {
|
||
const band = confidenceBand(selectedDay);
|
||
const isOutlook = selectedDay >= 7;
|
||
return html`
|
||
<div style=${{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
margin: '8px 0 4px',
|
||
padding: '6px 10px',
|
||
background: band.tint,
|
||
borderLeft: `3px solid ${band.edge}`,
|
||
borderRadius: '0 4px 4px 0',
|
||
fontSize: '12px',
|
||
color: '#3a2a18',
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontWeight: 600,
|
||
flexWrap: 'wrap',
|
||
}}>
|
||
<span style=${{ letterSpacing: '0.06em', textTransform: 'uppercase', fontWeight: 700 }}>
|
||
Day ${selectedDay + 1} of 14 · Forecast clarity: ${band.label}
|
||
</span>
|
||
${isOutlook && html`
|
||
<span style=${{ opacity: 0.7, fontStyle: 'italic' }}>
|
||
forecast skill is reduced — treat hourly detail as trend, not precision
|
||
</span>`}
|
||
</div>`;
|
||
})()}
|
||
|
||
<div class="filter-profiles" style=${{ borderBottom: isPro ? 'none' : '' }}>
|
||
<span class="filter-profiles-label">Profile:</span>
|
||
${profileButtonOrder.slice(0, 3).map((key) => {
|
||
const profile = FILTER_PROFILES[key];
|
||
const locked = profile.proOnly && !isPro;
|
||
return html`
|
||
<button
|
||
key=${key}
|
||
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
|
||
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
|
||
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
|
||
onClick=${() => {
|
||
if (locked) {
|
||
setProPromptSource(`profile:${key}`);
|
||
setProPromptDay(0);
|
||
return;
|
||
}
|
||
activateProfile(key);
|
||
}}
|
||
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||
})}
|
||
<span class="profile-divider" aria-hidden="true"></span>
|
||
<${CustomSelect}
|
||
key="places"
|
||
value=${placeValue}
|
||
isOn=${activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)}
|
||
noHide=${true}
|
||
hideLabel="🌤️ Places"
|
||
buttonLabel=${placeValue === 'off' ? '🌤️ Places' : `${placeLabel}`}
|
||
options=${placeOptions}
|
||
onChange=${(v) => {
|
||
if (v === 'off') { activateProfile('basic'); return; }
|
||
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
|
||
setProPromptSource(`variant:${v}`);
|
||
setProPromptDay(0);
|
||
return;
|
||
}
|
||
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariantAndSave(v);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
|
||
setIndoorMode('off');
|
||
setIndoorManaged(false);
|
||
}}
|
||
/>
|
||
<${CustomSelect}
|
||
key="activities"
|
||
value=${activityValue}
|
||
isOn=${activeProfile === 'farming' || (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant))}
|
||
noHide=${true}
|
||
hideLabel="🎯 Activities"
|
||
buttonLabel=${activityValue === 'off' ? '🎯 Activities' : ` ${activityLabel}`}
|
||
options=${activityOptions}
|
||
onChange=${(v) => {
|
||
if (v === 'off') { activateProfile('basic'); return; }
|
||
if (v === 'farming') { activateProfile('farming'); return; }
|
||
if (OUTDOORS_VARIANTS[v]?.proOnly && !isPro) {
|
||
setProPromptSource(`variant:${v}`);
|
||
setProPromptDay(0);
|
||
return;
|
||
}
|
||
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch (e) { /* ignore */ }
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariantAndSave(v);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[v].cols });
|
||
setIndoorMode('off');
|
||
setIndoorManaged(false);
|
||
}}
|
||
/>
|
||
<span class="profile-divider" aria-hidden="true"></span>
|
||
${profileButtonOrder.slice(3).map((key) => {
|
||
const profile = FILTER_PROFILES[key];
|
||
const locked = profile.proOnly && !isPro;
|
||
return html`
|
||
<button
|
||
key=${key}
|
||
class=${`profile-btn${activeProfile === key ? ' active' : ''}${locked ? ' locked' : ''}`}
|
||
style=${{ opacity: locked ? 0.6 : 1, cursor: locked ? 'not-allowed' : 'pointer', position: 'relative' }}
|
||
title=${locked ? `🔒 ${profile.label} is part of SunScope Extra` : ''}
|
||
onClick=${() => {
|
||
if (locked) {
|
||
setProPromptSource(`profile:${key}`);
|
||
setProPromptDay(0);
|
||
return;
|
||
}
|
||
activateProfile(key);
|
||
}}
|
||
>${profile.icon} ${profile.label}${locked ? ' 🔒' : ''}</button>`;
|
||
})}
|
||
</div>
|
||
|
||
</${Fragment}>`;
|
||
}
|