polish nav, planner UX, country pills, and dev magic link

- Nav: Zillow-style centered logo, solid blue Sign In button
- Planner: center app at 72rem, center wizard steps/header/preview
- Country pills: UK/USA labels, remove Other, show permits slider
  inline under country so the effect is transparent and adjustable
- Reset button: inline confirm (red "Sure? Reset") instead of alert
- Worker: print magic link to console when DEBUG=true for local dev

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-17 14:36:26 +01:00
parent cefdb7ce3a
commit e0563d62ff
6 changed files with 147 additions and 31 deletions

View File

@@ -57,6 +57,84 @@
/* ── Component classes ── */
@layer components {
/* ── Navigation (Zillow-style: links | logo | links) ── */
.nav-bar {
position: sticky;
top: 0;
z-index: 50;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid #E2E8F0;
}
.nav-inner {
max-width: 72rem;
margin: 0 auto;
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
}
.nav-logo {
flex-shrink: 0;
line-height: 0;
}
.nav-logo img {
height: 32px;
width: auto;
}
.nav-links {
display: flex;
align-items: center;
gap: 1.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.nav-links a {
color: #475569;
text-decoration: none;
transition: color 0.15s;
}
.nav-links a:hover {
color: #1D4ED8;
}
.nav-links--left { flex: 1; justify-content: flex-start; }
.nav-links--right { flex: 1; justify-content: flex-end; }
a.nav-auth-btn,
button.nav-auth-btn {
display: inline-flex;
align-items: center;
padding: 6px 16px;
border: none;
border-radius: 10px;
font-size: 0.8125rem;
font-weight: 600;
color: #fff;
background: #1D4ED8;
cursor: pointer;
text-decoration: none;
box-shadow: 0 2px 8px rgba(29,78,216,0.25);
transition: background 0.15s;
}
a.nav-auth-btn:hover,
button.nav-auth-btn:hover {
background: #1E40AF;
color: #fff;
}
.nav-badge {
@apply bg-electric/10 text-electric px-2 py-0.5 text-xs font-semibold rounded-full;
}
.nav-form {
margin: 0;
padding: 0;
display: inline;
}
@media (max-width: 768px) {
.nav-links { display: none; }
.nav-inner { justify-content: center; }
}
/* Page container */
.container-page {
@apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8;

View File

@@ -32,6 +32,8 @@
color: var(--txt);
background: var(--bg);
min-height: 100vh;
max-width: 72rem;
margin: 0 auto;
}
/* Scrollbar */
@@ -349,6 +351,7 @@
transition: all 0.15s;
}
.btn-reset:hover { color: var(--head); border-color: var(--border-2); }
.btn-reset--confirm { color: #DC2626; border-color: #FCA5A5; background: #FEF2F2; }
/* ── Pill Select ── */
.pill-group {
@@ -715,6 +718,9 @@
justify-content: space-between;
margin-bottom: 1.25rem;
gap: 1rem;
max-width: 560px;
margin-left: auto;
margin-right: auto;
}
.wizard-dots {
display: flex;
@@ -775,6 +781,8 @@
.wizard-step {
display: none;
max-width: 560px;
margin-left: auto;
margin-right: auto;
}
.wizard-step.active {
display: block;
@@ -802,6 +810,9 @@
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
margin-top: 1.5rem;
max-width: 560px;
margin-left: auto;
margin-right: auto;
max-width: 560px;
}
.wiz-preview__item {
flex: 1;
@@ -830,6 +841,8 @@
justify-content: space-between;
margin-top: 12px;
max-width: 560px;
margin-left: auto;
margin-right: auto;
gap: 0.75rem;
}
.wiz-btn--back {

View File

@@ -86,7 +86,7 @@ const COUNTRY_PRESETS = {
NL: { permitsCompliance: 10000 },
SE: { permitsCompliance: 8000 },
UK: { permitsCompliance: 10000 },
OTHER: { permitsCompliance: 12000 },
US: { permitsCompliance: 15000 },
};
// Track which keys the user has manually adjusted
const _userAdjusted = new Set();
@@ -102,11 +102,11 @@ function bindPills(){
});
// Apply country presets when country changes
if(k==='country'){
const preset = COUNTRY_PRESETS[v] || COUNTRY_PRESETS.OTHER;
const preset = COUNTRY_PRESETS[v] || COUNTRY_PRESETS.DE;
for(const [pk,pv] of Object.entries(preset)){
if(!_userAdjusted.has(pk)){ S[pk]=pv; }
}
rebuildCapexInputs(); bindSliders(); bindPills();
buildCountryPill(); rebuildCapexInputs(); bindSliders(); bindPills();
}
// Rebuild inputs if lighting options depend on venue
if(k==='glassType'||k==='lightingType'){
@@ -257,8 +257,9 @@ function buildCountryPill(){
$('#inp-country').innerHTML = pillSelect('country','Country',[
{v:'DE',l:'Germany'},{v:'ES',l:'Spain'},{v:'IT',l:'Italy'},
{v:'FR',l:'France'},{v:'NL',l:'Netherlands'},{v:'SE',l:'Sweden'},
{v:'UK',l:'United Kingdom'},{v:'OTHER',l:'Other'},
],'Affects regulatory cost defaults');
{v:'UK',l:'UK'},{v:'US',l:'USA'},
]) + slider('permitsCompliance','Permits & Compliance',0,50000,1000,fE,
'Building permits, noise studies, change-of-use, fire safety, and regulatory compliance. Adjusts automatically when you pick a country — feel free to override.');
}
function rebuildCapexInputs(){
@@ -286,14 +287,12 @@ function rebuildCapexInputs(){
h+=slider('floorPrep','Floor Preparation',0,100000,1000,fE,'Floor leveling, sealing, and preparation for court installation in an existing rented building.')+
slider('hvacUpgrade','HVAC Upgrade',0,200000,1000,fE,'Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.')+
slider('lightingUpgrade','Lighting Upgrade',0,100000,1000,fE,'Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.')+
slider('fitout','Fit-Out & Reception',0,300000,1000,fE,'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.')+
slider('permitsCompliance','Permits & Compliance',0,50000,1000,fE,'Building permits, change-of-use application, fire safety review, noise assessment');
slider('fitout','Fit-Out & Reception',0,300000,1000,fE,'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.');
} else if(!isIn){
h+=slider('outdoorFoundation','Concrete (\u20AC/m\u00B2)',0,150,1,fE,'Concrete pad per m\u00B2 for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.')+
slider('outdoorSiteWork','Site Work',0,60000,500,fE,'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.')+
slider('outdoorLighting','Lighting per Court',0,20000,500,fE,'Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.')+
slider('outdoorFencing','Fencing',0,40000,500,fE,'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.')+
slider('permitsCompliance','Permits & Compliance',0,50000,1000,fE,'Building permits, noise assessment, environmental compliance');
slider('outdoorFencing','Fencing',0,40000,500,fE,'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.');
if(isBuy) h+=slider('landPriceSqm','Land Price (\u20AC/m\u00B2)',0,500,5,fE,'Land purchase price per m\u00B2. Varies by location, zoning, and accessibility.');
}
h+=slider('workingCapital','Working Capital',0,200000,1000,fE,'Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer \u2014 underfunding is a common startup failure.')+
@@ -701,8 +700,25 @@ function loadScenario(id){
});
}
let _resetPending = false;
let _resetTimer = null;
function resetToDefaults(){
if(!confirm('Reset all assumptions to defaults?')) return;
const btn = document.getElementById('resetDefaultsBtn');
if(!_resetPending){
_resetPending = true;
btn.textContent = 'Sure? Reset';
btn.classList.add('btn-reset--confirm');
_resetTimer = setTimeout(()=>{
_resetPending = false;
btn.textContent = 'Reset to Defaults';
btn.classList.remove('btn-reset--confirm');
}, 3000);
return;
}
clearTimeout(_resetTimer);
_resetPending = false;
btn.textContent = 'Reset to Defaults';
btn.classList.remove('btn-reset--confirm');
Object.assign(S, JSON.parse(JSON.stringify(DEFAULTS)));
_userAdjusted.clear();
buildInputs();
@@ -804,7 +820,7 @@ function renderWizNav(){
const COUNTRY_NAMES = {
DE:'Germany',ES:'Spain',IT:'Italy',FR:'France',NL:'Netherlands',
SE:'Sweden',UK:'United Kingdom',OTHER:'Other'
SE:'Sweden',UK:'United Kingdom',US:'United States'
};
function populateWizAutoFill(){

View File

@@ -19,33 +19,36 @@
{% block head %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="sticky top-0 z-50 bg-white/80 backdrop-blur border-b border-light-gray">
<div class="container-page flex items-center justify-between py-3">
<div class="flex items-center gap-6">
<a href="{{ url_for('dashboard.index') if user else url_for('public.landing') }}" class="flex items-center no-underline">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}" class="h-8 w-auto">
</a>
<div class="hidden md:flex items-center gap-4 text-sm">
<!-- Navigation — Zillow-style: [demand links] [logo] [supply links + auth] -->
<nav class="nav-bar">
<div class="nav-inner">
<!-- Left: demand / buy side -->
<div class="nav-links nav-links--left">
<a href="{{ url_for('planner.index') }}">Planner</a>
<a href="{{ url_for('leads.quote_request') }}">Get Quotes</a>
</div>
<!-- Center: logo -->
<a href="{{ url_for('dashboard.index') if user else url_for('public.landing') }}" class="nav-logo">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}">
</a>
<!-- Right: supply side + auth -->
<div class="nav-links nav-links--right">
<a href="{{ url_for('directory.index') }}">Directory</a>
<span class="text-slate-300">|</span>
<a href="{{ url_for('public.suppliers') }}">For Suppliers</a>
<a href="{{ url_for('public.landing') }}#faq">Help</a>
</div>
</div>
<div class="flex items-center gap-3 text-sm">
{% if user %}
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
{% if session.get('is_admin') %}
<a href="{{ url_for('admin.index') }}"><span class="badge">Admin</span></a>
<a href="{{ url_for('admin.index') }}" class="nav-badge">Admin</a>
{% endif %}
<form method="post" action="{{ url_for('auth.logout') }}" class="m-0">
<form method="post" action="{{ url_for('auth.logout') }}" class="nav-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Sign Out</button>
<button type="submit" class="nav-auth-btn">Sign Out</button>
</form>
{% else %}
<a href="{{ url_for('auth.login') }}" class="btn-outline btn-sm">Sign In</a>
<a href="{{ url_for('auth.login') }}" class="nav-auth-btn">Sign In</a>
{% endif %}
</div>
</div>

View File

@@ -146,6 +146,12 @@ async def handle_send_magic_link(payload: dict) -> None:
"""Send magic link email."""
link = f"{config.BASE_URL}/auth/verify?token={payload['token']}"
if config.DEBUG:
print(f"\n{'='*60}")
print(f" MAGIC LINK for {payload['email']}")
print(f" {link}")
print(f"{'='*60}\n")
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Sign in to {config.APP_NAME}</h2>'
f"<p>Click the button below to sign in. This link expires in "

View File

@@ -1033,7 +1033,7 @@ plausible_state = st.fixed_dictionaries({
"budgetTarget": st.integers(0, 5000000),
"glassType": st.sampled_from(["standard", "panoramic"]),
"lightingType": st.sampled_from(["led_standard", "led_competition", "natural"]),
"country": st.sampled_from(["DE", "ES", "IT", "FR", "NL", "SE", "UK", "OTHER"]),
"country": st.sampled_from(["DE", "ES", "IT", "FR", "NL", "SE", "UK", "US"]),
"permitsCompliance": st.integers(0, 50000),
})