475 lines
24 KiB
JavaScript
475 lines
24 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
|
||
// openRestore - opens the "Already subscribed?" restore modal
|
||
// 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
|
||
// workOptions - dropdown options for Work selector
|
||
// activityValue - current activity dropdown value
|
||
// activityLabel - current activity dropdown label
|
||
// placeValue - current place dropdown value
|
||
// placeLabel - current place dropdown label
|
||
// workValue - current work dropdown value
|
||
// workLabel - current work 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 { useRef, useEffect, useState } from '../../vendor/preact-hooks.js';
|
||
import htm from '../../vendor/htm.js';
|
||
import { confidenceBand } from '../utils.js';
|
||
import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys } from '../config.js';
|
||
import { CloudIcon, PrecipIcon, CustomSelect } from '../components.js';
|
||
import { SubscribeModal } from './SubscribeModal.js';
|
||
|
||
const html = htm.bind(h);
|
||
|
||
// Converts a base RGB colour into the same 135° gradient used by the thermal
|
||
// bands legend: 50% white blend to pastelise, then a subtle light→dark sweep.
|
||
function weatherGradient(r, g, b) {
|
||
const blend = (c) => Math.round(c + (255 - c) * 0.82);
|
||
const [mr, mg, mb] = [blend(r), blend(g), blend(b)];
|
||
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.30));
|
||
const darken = (c) => Math.round(c * 0.96);
|
||
const light = `rgb(${lighten(mr)},${lighten(mg)},${lighten(mb)})`;
|
||
const mid = `rgb(${mr},${mg},${mb})`;
|
||
const dark = `rgb(${darken(mr)},${darken(mg)},${darken(mb)})`;
|
||
return `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
|
||
}
|
||
|
||
// Active-tab variant: a more solid (less pastelised) version of the weather
|
||
// colour, drawn as a radial gradient whose strong colour sits at the outer
|
||
// edge and softens toward a lighter centre — so the hue reads as radiating
|
||
// inward from the outside of the tab.
|
||
function weatherGradientNeutral(r, g, b) {
|
||
const blend = (c) => Math.round(c + (255 - c) * 0.58);
|
||
const [mr, mg, mb] = [blend(r), blend(g), blend(b)];
|
||
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38));
|
||
const center = `rgb(${lighten(mr)},${lighten(mg)},${lighten(mb)})`;
|
||
const edge = `rgb(${mr},${mg},${mb})`;
|
||
return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`;
|
||
}
|
||
|
||
function weatherGradientActive(r, g, b) {
|
||
const blend = (c) => Math.round(c + (255 - c) * 0.42);
|
||
const [mr, mg, mb] = [blend(r), blend(g), blend(b)];
|
||
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38));
|
||
const center = `rgb(${lighten(mr)},${lighten(mg)},${lighten(mb)})`;
|
||
const edge = `rgb(${mr},${mg},${mb})`;
|
||
return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`;
|
||
}
|
||
|
||
export function DayTabs({
|
||
days,
|
||
selectedDay, setSelectedDay,
|
||
isPro,
|
||
openRestore,
|
||
proPromptDay, setProPromptDay,
|
||
proPromptSource, setProPromptSource,
|
||
activeProfile, activateProfile, activeCols,
|
||
visibleCols,
|
||
activityOptions, placeOptions, workOptions,
|
||
activityValue, activityLabel,
|
||
placeValue, placeLabel,
|
||
workValue, workLabel,
|
||
outdoorsVariant, setOutdoorsVariantAndSave,
|
||
setActiveProfile, setVisibleCols,
|
||
setIndoorMode, setIndoorManaged, setBuildingType,
|
||
dayTabsRef, canScrollLeft, canScrollRight, scrollDayTabs,
|
||
}) {
|
||
const profileScrollRef = useRef(null);
|
||
const profileWrapRef = useRef(null);
|
||
|
||
// Active tab: common - places - activities - work.
|
||
// Auto-derived from the current profile on mount and on change.
|
||
const getTab = () => {
|
||
if (activeProfile === 'outdoors' && placeVariantKeys.includes(outdoorsVariant)) return 'places';
|
||
if (activeProfile === 'outdoors' && activityVariantKeys.includes(outdoorsVariant)) return 'activities';
|
||
if (activeProfile === 'farming' || (activeProfile === 'outdoors' && workVariantKeys.includes(outdoorsVariant))) return 'work';
|
||
return 'common';
|
||
};
|
||
const [activeTab, setActiveTab] = useState(getTab);
|
||
useEffect(() => { setActiveTab(getTab()); }, [activeProfile, outdoorsVariant]);
|
||
|
||
useEffect(() => {
|
||
const el = profileScrollRef.current;
|
||
const wrap = profileWrapRef.current;
|
||
if (!el) return;
|
||
let isDown = false, startX = 0, startScroll = 0, hasDragged = false;
|
||
|
||
const updateFades = () => {
|
||
if (!wrap) return;
|
||
wrap.classList.toggle('fade-left', el.scrollLeft > 1);
|
||
wrap.classList.toggle('fade-right', el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||
};
|
||
|
||
const onMouseDown = (e) => {
|
||
if (!el.contains(e.target) || e.button !== 0) return;
|
||
isDown = true; hasDragged = false;
|
||
startX = e.clientX; startScroll = el.scrollLeft;
|
||
document.body.style.userSelect = 'none';
|
||
document.body.style.webkitUserSelect = 'none';
|
||
};
|
||
const onMouseMove = (e) => {
|
||
if (!isDown) return;
|
||
const dx = e.clientX - startX;
|
||
if (Math.abs(dx) > 5) {
|
||
hasDragged = true;
|
||
el.style.cursor = 'grabbing';
|
||
el.scrollLeft = startScroll - dx;
|
||
}
|
||
};
|
||
const onMouseUp = () => {
|
||
if (!isDown) return;
|
||
isDown = false;
|
||
el.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
document.body.style.webkitUserSelect = '';
|
||
};
|
||
const onClickCapture = (e) => {
|
||
if (hasDragged) { e.stopPropagation(); e.preventDefault(); hasDragged = false; }
|
||
};
|
||
|
||
el.addEventListener('scroll', updateFades);
|
||
updateFades(); // set initial fade state
|
||
|
||
document.addEventListener('mousedown', onMouseDown);
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
el.addEventListener('click', onClickCapture, true);
|
||
return () => {
|
||
el.removeEventListener('scroll', updateFades);
|
||
document.removeEventListener('mousedown', onMouseDown);
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
el.removeEventListener('click', onClickCapture, true);
|
||
};
|
||
}, []);
|
||
|
||
return html`
|
||
<${Fragment}>
|
||
|
||
<div class="filter-profiles">
|
||
<div class="profile-tabs">
|
||
${[['common','Common'],['places','Places'],['activities','Activities'],['work','Work']].map(([key, label]) => html`
|
||
<button
|
||
key=${key}
|
||
class=${`profile-tab${activeTab === key ? ' active' : ''}`}
|
||
onClick=${() => setActiveTab(key)}
|
||
>${label}</button>
|
||
`)}
|
||
</div>
|
||
<div class="profile-scroll-wrap" ref=${profileWrapRef}>
|
||
<span class="profile-scroll-chevron left" aria-hidden="true">‹</span>
|
||
<span class="profile-scroll-chevron right" aria-hidden="true">›</span>
|
||
<div class="profile-scroll" ref=${profileScrollRef}>
|
||
|
||
${activeTab === 'common' && profileButtonOrder.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=${{ cursor: 'pointer', position: 'relative' }}
|
||
title=${locked ? `Profile ${profile.label} is part of SunScope Extra` : ''}
|
||
onClick=${() => {
|
||
if (locked) { setProPromptSource(`profile:${key}`); setProPromptDay(0); return; }
|
||
activateProfile(key);
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: profile.scene, filter: locked ? 'grayscale(100%)' : 'none' }}>${profile.scene.includes('url(') ? null : profile.icon}</span>
|
||
<span class="profile-btn-label">${locked ? '🔒 ' : ''}${profile.label}</span>
|
||
${locked && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>`;
|
||
})}
|
||
|
||
${activeTab === 'places' && placeOptions.map((opt) => html`
|
||
<button
|
||
key=${opt.value}
|
||
class=${`profile-btn${placeValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
|
||
style=${{ cursor: 'pointer', position: 'relative' }}
|
||
onClick=${() => {
|
||
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
|
||
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariantAndSave(opt.value);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
|
||
setIndoorMode('off');
|
||
setIndoorManaged(false);
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
|
||
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
|
||
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>
|
||
`)}
|
||
|
||
${activeTab === 'activities' && activityOptions.map((opt) => html`
|
||
<button
|
||
key=${opt.value}
|
||
class=${`profile-btn${activityValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
|
||
style=${{ cursor: 'pointer', position: 'relative' }}
|
||
onClick=${() => {
|
||
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
|
||
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariantAndSave(opt.value);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
|
||
setIndoorMode('off');
|
||
setIndoorManaged(false);
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
|
||
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
|
||
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>
|
||
`)}
|
||
|
||
${activeTab === 'work' && workOptions.map((opt) => html`
|
||
<button
|
||
key=${opt.value}
|
||
class=${`profile-btn${workValue === opt.value ? ' active' : ''}${opt.disabled ? ' locked' : ''}`}
|
||
style=${{ cursor: 'pointer', position: 'relative' }}
|
||
onClick=${() => {
|
||
if (opt.disabled) { setProPromptSource(`variant:${opt.value}`); setProPromptDay(0); return; }
|
||
if (opt.value === 'farming') { activateProfile('farming'); return; }
|
||
try { localStorage.setItem('sunscope_profile', 'outdoors'); } catch(e) {}
|
||
setActiveProfile('outdoors');
|
||
setOutdoorsVariantAndSave(opt.value);
|
||
setVisibleCols({ ...OUTDOORS_VARIANTS[opt.value].cols });
|
||
if (opt.value === 'office') { setBuildingType('office'); setIndoorMode('on'); setIndoorManaged(false); }
|
||
else { setIndoorMode('off'); setIndoorManaged(false); }
|
||
}}
|
||
>
|
||
<span class="profile-btn-scene" style=${{ background: opt.scene, filter: opt.disabled ? 'grayscale(100%)' : 'none' }}></span>
|
||
<span class="profile-btn-label">${opt.disabled ? '🔒 ' : ''}${opt.name}</span>
|
||
${opt.disabled && html`<span class="profile-lock-badge">🔒</span>`}
|
||
</button>
|
||
`)}
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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 core daylight rows (elev > 10°) where
|
||
// available, falling back to any above-horizon rows, then all rows.
|
||
const coreRows = d.rows.filter(r => r.elev > 10);
|
||
const aboveRows = d.rows.filter(r => r.elev > 0);
|
||
const repRows = coreRows.length > 0 ? coreRows : aboveRows.length > 0 ? aboveRows : 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';
|
||
// Use solar-noon row (highest elevation) as the representative row
|
||
const noonRow = repRows.reduce((best, r) => (r.elev > (best?.elev ?? -Infinity) ? r : best), null);
|
||
const repElev = noonRow ? noonRow.elev : 45;
|
||
const repDt = noonRow ? noonRow.dt : dDate;
|
||
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
|
||
|
||
// Base RGB for each weather condition — pastelised by weatherGradient()
|
||
const [wR, wG, wB] = (daySnow >= 0.1) ? [100, 160, 230]
|
||
: (dayHi > 38) ? [210, 60, 30]
|
||
: showPrecip ? [ 50, 120, 200]
|
||
: (dayHi > 32) ? [220, 110, 30]
|
||
: (dayHi > 26) ? [220, 180, 30]
|
||
: modalCloudCat === 'overcast' ? [100, 110, 130]
|
||
: (dayHi !== null && dayHi < 9) ? [ 60, 110, 180]
|
||
: modalCloudCat === 'scattered' ? [160, 150, 130]
|
||
: modalCloudCat === 'wispy' ? [200, 180, 130]
|
||
: [220, 190, 50];
|
||
const wBg = weatherGradient(wR, wG, wB);
|
||
const wBgNeutral = weatherGradientNeutral(wR, wG, wB);
|
||
const wBgActive = weatherGradientActive(wR, wG, wB);
|
||
const wText = '#2a1d10';
|
||
|
||
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: isActive ? wBgActive : wBgNeutral,
|
||
color: wText,
|
||
borderTop: '1px solid #c9b08a',
|
||
borderRight: 'none',
|
||
borderBottom: '1px solid #c9b08a',
|
||
borderLeft: i === 0 ? '1px solid #c9b08a' : 'none',
|
||
opacity: locked ? 0.5 : 1,
|
||
cursor: locked ? 'not-allowed' : 'pointer',
|
||
position: 'relative',
|
||
}}
|
||
>
|
||
${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: '6px auto 0px', lineHeight: 1 }}>
|
||
${showPrecip
|
||
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${30} />`
|
||
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`}
|
||
</span>
|
||
${dayHi !== null && html`
|
||
<span style=${{
|
||
display: 'block',
|
||
fontFamily: 'Manrope, sans-serif',
|
||
fontStyle: 'normal',
|
||
fontSize: '13px',
|
||
marginTop: '0px',
|
||
letterSpacing: 0,
|
||
textTransform: 'none',
|
||
textShadow: '0 0 14px rgba(255,255,255,0.85), 0 0 11px rgba(255,255,255,0.7), 0 0 8px rgba(255,255,255,0.6), 0 0 5px rgba(255,255,255,0.5)',
|
||
}}>
|
||
<span style=${{ color: '#c0622a', fontWeight: 800 }}>${dayHi}°</span>
|
||
<span style=${{ opacity: 0.5 }}> / </span>
|
||
<span style=${{ color: '#3a6080', fontWeight: 800 }}>${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 = {
|
||
// Profile buttons
|
||
'profile:alltemps': {
|
||
title: 'Temps is part of SunScope Extra',
|
||
detail: 'Extra unlocks the temperature comparison view — air, soil, concrete, vehicle interior, and indoor estimates side by side in one place.',
|
||
},
|
||
'profile:showall': {
|
||
title: 'Show All is part of SunScope Extra',
|
||
detail: 'Extra unlocks the full column set — every data point SunScope tracks, visible at once for deep-dive analysis.',
|
||
},
|
||
'profile:custom': {
|
||
title: 'Custom columns are part of SunScope Extra',
|
||
detail: 'Extra lets you hand-pick exactly which columns appear. Mix and match air, dew, soil, UV, UTCI and more to build your perfect view.',
|
||
},
|
||
// Places
|
||
'variant:construction': {
|
||
title: 'Construction is part of SunScope Extra',
|
||
detail: 'Extra adds the Construction profile with felt temperature, wind, rain probability, visibility, and air quality — the key factors for safe site working.',
|
||
},
|
||
// Activities
|
||
'variant:hiking': {
|
||
title: 'Hiking is part of SunScope Extra',
|
||
detail: 'Extra adds the Hiking profile with felt temperature, UV index, burn time, wind direction, and visibility — essential for longer days on exposed terrain.',
|
||
},
|
||
'variant:photography': {
|
||
title: 'Photography is part of SunScope Extra',
|
||
detail: 'Extra adds the Photography profile with direct and diffuse radiation, sun elevation, cloud cover, and visibility — the conditions that make or break a shoot.',
|
||
},
|
||
'variant:sailing': {
|
||
title: 'Sailing is part of SunScope Extra',
|
||
detail: 'Extra adds the Sailing profile with wind speed and direction, dew point, UV, burn time, sun elevation, and visibility for on-water planning.',
|
||
},
|
||
'variant:wintersports': {
|
||
title: 'Winter Sports is part of SunScope Extra',
|
||
detail: 'Extra adds the Winter Sports profile with UV-A and UV-B, burn time, sun elevation, wind direction, and visibility for snow and slope conditions.',
|
||
},
|
||
'variant:naturist': {
|
||
title: 'Naturist is part of SunScope Extra',
|
||
detail: 'Extra adds the Naturist profile with full skin-exposure detail — UV, burn time, felt temperature, dew point, humidity, and air quality.',
|
||
},
|
||
// Work
|
||
'variant:market': {
|
||
title: 'Market Trading is part of SunScope Extra',
|
||
detail: 'Extra adds the Market Trading profile with felt temperature, wind direction, rain probability, and visibility — the key factors for planning stall days.',
|
||
},
|
||
'variant:windowcleaning': {
|
||
title: 'Window Cleaning is part of SunScope Extra',
|
||
detail: 'Extra adds the Window Cleaning profile focused on wind speed and direction, rain, and felt temperature — the conditions that determine whether work is safe and worthwhile.',
|
||
},
|
||
'variant:office': {
|
||
title: 'Office is part of SunScope Extra',
|
||
detail: 'Extra adds the Office profile with solar gain, managed indoor temperature, humidity, air quality, and rain probability — useful for commute planning and building comfort.',
|
||
},
|
||
'export': {
|
||
title: 'Export day data is part of SunScope Extra',
|
||
detail: 'Extra lets you download a full hourly spreadsheet for any day — all 41 columns including felt temperature, UV, soil, wind, and air quality, styled and ready to use in Excel or LibreOffice.',
|
||
},
|
||
};
|
||
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`
|
||
<${SubscribeModal}
|
||
title=${promptCopy.title}
|
||
detail=${promptCopy.detail}
|
||
onClose=${() => setProPromptDay(null)}
|
||
openRestore=${openRestore}
|
||
/>`;
|
||
})()}
|
||
|
||
|
||
</${Fragment}>`;
|
||
}
|