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:
@@ -57,6 +57,84 @@
|
|||||||
|
|
||||||
/* ── Component classes ── */
|
/* ── Component classes ── */
|
||||||
@layer components {
|
@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 */
|
/* Page container */
|
||||||
.container-page {
|
.container-page {
|
||||||
@apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8;
|
@apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
color: var(--txt);
|
color: var(--txt);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
max-width: 72rem;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
@@ -349,6 +351,7 @@
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.btn-reset:hover { color: var(--head); border-color: var(--border-2); }
|
.btn-reset:hover { color: var(--head); border-color: var(--border-2); }
|
||||||
|
.btn-reset--confirm { color: #DC2626; border-color: #FCA5A5; background: #FEF2F2; }
|
||||||
|
|
||||||
/* ── Pill Select ── */
|
/* ── Pill Select ── */
|
||||||
.pill-group {
|
.pill-group {
|
||||||
@@ -715,6 +718,9 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
max-width: 560px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
.wizard-dots {
|
.wizard-dots {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -775,6 +781,8 @@
|
|||||||
.wizard-step {
|
.wizard-step {
|
||||||
display: none;
|
display: none;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
.wizard-step.active {
|
.wizard-step.active {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -802,6 +810,9 @@
|
|||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
max-width: 560px;
|
||||||
}
|
}
|
||||||
.wiz-preview__item {
|
.wiz-preview__item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -830,6 +841,8 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.wiz-btn--back {
|
.wiz-btn--back {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ const COUNTRY_PRESETS = {
|
|||||||
NL: { permitsCompliance: 10000 },
|
NL: { permitsCompliance: 10000 },
|
||||||
SE: { permitsCompliance: 8000 },
|
SE: { permitsCompliance: 8000 },
|
||||||
UK: { permitsCompliance: 10000 },
|
UK: { permitsCompliance: 10000 },
|
||||||
OTHER: { permitsCompliance: 12000 },
|
US: { permitsCompliance: 15000 },
|
||||||
};
|
};
|
||||||
// Track which keys the user has manually adjusted
|
// Track which keys the user has manually adjusted
|
||||||
const _userAdjusted = new Set();
|
const _userAdjusted = new Set();
|
||||||
@@ -102,11 +102,11 @@ function bindPills(){
|
|||||||
});
|
});
|
||||||
// Apply country presets when country changes
|
// Apply country presets when country changes
|
||||||
if(k==='country'){
|
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)){
|
for(const [pk,pv] of Object.entries(preset)){
|
||||||
if(!_userAdjusted.has(pk)){ S[pk]=pv; }
|
if(!_userAdjusted.has(pk)){ S[pk]=pv; }
|
||||||
}
|
}
|
||||||
rebuildCapexInputs(); bindSliders(); bindPills();
|
buildCountryPill(); rebuildCapexInputs(); bindSliders(); bindPills();
|
||||||
}
|
}
|
||||||
// Rebuild inputs if lighting options depend on venue
|
// Rebuild inputs if lighting options depend on venue
|
||||||
if(k==='glassType'||k==='lightingType'){
|
if(k==='glassType'||k==='lightingType'){
|
||||||
@@ -257,8 +257,9 @@ function buildCountryPill(){
|
|||||||
$('#inp-country').innerHTML = pillSelect('country','Country',[
|
$('#inp-country').innerHTML = pillSelect('country','Country',[
|
||||||
{v:'DE',l:'Germany'},{v:'ES',l:'Spain'},{v:'IT',l:'Italy'},
|
{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:'FR',l:'France'},{v:'NL',l:'Netherlands'},{v:'SE',l:'Sweden'},
|
||||||
{v:'UK',l:'United Kingdom'},{v:'OTHER',l:'Other'},
|
{v:'UK',l:'UK'},{v:'US',l:'USA'},
|
||||||
],'Affects regulatory cost defaults');
|
]) + 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(){
|
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.')+
|
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('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('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('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');
|
|
||||||
} else if(!isIn){
|
} 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.')+
|
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('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('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('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');
|
|
||||||
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.');
|
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.')+
|
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(){
|
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)));
|
Object.assign(S, JSON.parse(JSON.stringify(DEFAULTS)));
|
||||||
_userAdjusted.clear();
|
_userAdjusted.clear();
|
||||||
buildInputs();
|
buildInputs();
|
||||||
@@ -804,7 +820,7 @@ function renderWizNav(){
|
|||||||
|
|
||||||
const COUNTRY_NAMES = {
|
const COUNTRY_NAMES = {
|
||||||
DE:'Germany',ES:'Spain',IT:'Italy',FR:'France',NL:'Netherlands',
|
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(){
|
function populateWizAutoFill(){
|
||||||
|
|||||||
@@ -19,33 +19,36 @@
|
|||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Navigation -->
|
<!-- Navigation — Zillow-style: [demand links] [logo] [supply links + auth] -->
|
||||||
<nav class="sticky top-0 z-50 bg-white/80 backdrop-blur border-b border-light-gray">
|
<nav class="nav-bar">
|
||||||
<div class="container-page flex items-center justify-between py-3">
|
<div class="nav-inner">
|
||||||
<div class="flex items-center gap-6">
|
<!-- Left: demand / buy side -->
|
||||||
<a href="{{ url_for('dashboard.index') if user else url_for('public.landing') }}" class="flex items-center no-underline">
|
<div class="nav-links nav-links--left">
|
||||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}" class="h-8 w-auto">
|
<a href="{{ url_for('planner.index') }}">Planner</a>
|
||||||
</a>
|
<a href="{{ url_for('leads.quote_request') }}">Get Quotes</a>
|
||||||
<div class="hidden md:flex items-center gap-4 text-sm">
|
|
||||||
<a href="{{ url_for('planner.index') }}">Planner</a>
|
|
||||||
<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>
|
||||||
<div class="flex items-center gap-3 text-sm">
|
|
||||||
|
<!-- 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>
|
||||||
|
<a href="{{ url_for('public.suppliers') }}">For Suppliers</a>
|
||||||
|
<a href="{{ url_for('public.landing') }}#faq">Help</a>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||||
{% if session.get('is_admin') %}
|
{% 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 %}
|
{% 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() }}">
|
<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>
|
</form>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -146,6 +146,12 @@ async def handle_send_magic_link(payload: dict) -> None:
|
|||||||
"""Send magic link email."""
|
"""Send magic link email."""
|
||||||
link = f"{config.BASE_URL}/auth/verify?token={payload['token']}"
|
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 = (
|
body = (
|
||||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Sign in to {config.APP_NAME}</h2>'
|
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 "
|
f"<p>Click the button below to sign in. This link expires in "
|
||||||
|
|||||||
@@ -1033,7 +1033,7 @@ plausible_state = st.fixed_dictionaries({
|
|||||||
"budgetTarget": st.integers(0, 5000000),
|
"budgetTarget": st.integers(0, 5000000),
|
||||||
"glassType": st.sampled_from(["standard", "panoramic"]),
|
"glassType": st.sampled_from(["standard", "panoramic"]),
|
||||||
"lightingType": st.sampled_from(["led_standard", "led_competition", "natural"]),
|
"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),
|
"permitsCompliance": st.integers(0, 50000),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user