Files
sunscope/assets/js/components/DayTabs.js
T
fraxle 05ab4cf76b 3.1.0.1
Why it feels now updates on play
2026-06-27 07:47:04 +01:00

520 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ------------------------------------------------------------------------
// 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.
// edgeBlend pastelises the outer edge (higher = lighter). centerBlend, when
// supplied, blends the centre straight from the raw colour for a lighter core
// (used by the Danger tier to keep a dark edge but a light centre); otherwise
// the centre is just a lightened version of the edge (original behaviour).
function weatherGradientNeutral(r, g, b, edgeBlend = 0.50, centerBlend = null) {
const blend = (c, amt) => Math.round(c + (255 - c) * amt);
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38));
const [er, eg, eb] = [blend(r, edgeBlend), blend(g, edgeBlend), blend(b, edgeBlend)];
const [cr, cg, cb] = centerBlend != null
? [blend(r, centerBlend), blend(g, centerBlend), blend(b, centerBlend)]
: [lighten(er), lighten(eg), lighten(eb)];
const center = `rgb(${cr},${cg},${cb})`;
const edge = `rgb(${er},${eg},${eb})`;
return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`;
}
function weatherGradientActive(r, g, b, edgeBlend = 0.42, centerBlend = null) {
const blend = (c, amt) => Math.round(c + (255 - c) * amt);
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38));
const [er, eg, eb] = [blend(r, edgeBlend), blend(g, edgeBlend), blend(b, edgeBlend)];
const [cr, cg, cb] = centerBlend != null
? [blend(r, centerBlend), blend(g, centerBlend), blend(b, centerBlend)]
: [lighten(er), lighten(eg), lighten(eb)];
const center = `rgb(${cr},${cg},${cb})`;
const edge = `rgb(${er},${eg},${eb})`;
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;
const airVals = d.rows.map(r => r.Ta).filter(v => isFinite(v));
const airHi = airVals.length ? Math.round(Math.max(...airVals)) : null;
const airLo = airVals.length ? Math.round(Math.min(...airVals)) : 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 > 44) ? [136, 0, 0]
: (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];
// Danger days keep a dark, saturated edge with a lighter centre.
const isDanger = dayHi !== null && dayHi > 44;
const wBg = weatherGradient(wR, wG, wB);
const wBgNeutral = isDanger
? weatherGradientNeutral(wR, wG, wB, 0.15, 0.72)
: weatherGradientNeutral(wR, wG, wB);
const wBgActive = isDanger
? weatherGradientActive(wR, wG, wB, 0.12, 0.72)
: weatherGradientActive(wR, wG, wB);
const wText = '#2a1d10';
// Active-tab outline: the day's weather colour, 20% darker.
const wOutline = `rgb(${Math.round(wR * 0.8)},${Math.round(wG * 0.8)},${Math.round(wB * 0.8)})`;
return html`
<button
key=${d.key}
class=${`utci-day-tab ${isActive ? 'active' : ''}${locked ? ' locked' : ''}${isDanger ? ' danger' : ''}`}
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',
filter: isActive ? 'saturate(1.1) brightness(1.04)' : 'none',
outline: isActive ? `2px solid ${wOutline}` : 'none',
outlineOffset: isActive ? '-2px' : '0',
}}
>
${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: 'flex',
alignItems: 'stretch',
justifyContent: 'center',
gap: '6px',
fontFamily: 'Manrope, sans-serif',
fontStyle: 'normal',
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)',
marginTop: '2px',
}}>
<span style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px' }}>
<span style=${{ color: '#c0622a', fontWeight: 800, fontSize: '14px', lineHeight: 1.1 }}>${dayHi}°</span>
${airHi !== null && html`<span style=${{ color: '#c0622a', fontWeight: 600, fontSize: '11px', lineHeight: 1.1 }}>${airHi}°</span>`}
</span>
<span style=${{ width: '1px', background: 'rgba(0,0,0,0.15)', margin: '2px 0', alignSelf: 'stretch' }}></span>
<span style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px' }}>
<span style=${{ color: '#3a6080', fontWeight: 800, fontSize: '14px', lineHeight: 1.1 }}>${dayLo}°</span>
${airLo !== null && html`<span style=${{ color: '#3a6080', fontWeight: 600, fontSize: '11px', lineHeight: 1.1 }}>${airLo}°</span>`}
</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.',
},
'variant:driver': {
title: 'Driver / Trucker is part of SunScope Extra',
detail: 'Extra adds the Driver / Trucker profile with vehicle interior temperature, UV, wind speed and direction, rain probability, and visibility — the conditions that matter for long hours on the road.',
},
'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.',
},
};
// Day-tab locks get the day-specific heading; any other locked feature
// without its own entry above falls back to a feature-agnostic message.
const fallbackCopy = proPromptSource === 'day'
? {
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.',
}
: {
title: 'This is part of SunScope Extra',
detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.',
};
const promptCopy = extraPromptCopy[proPromptSource] || fallbackCopy;
return html`
<${SubscribeModal}
title=${promptCopy.title}
detail=${promptCopy.detail}
onClose=${() => setProPromptDay(null)}
openRestore=${openRestore}
/>`;
})()}
</${Fragment}>`;
}