feat: Playtomic pricing/occupancy pipeline + email i18n + audience restructure

Three workstreams:

1. Playtomic full data extraction & transform pipeline:
   - Expand venue bounding boxes from 4 to 23 regions (global coverage)
   - New staging models for court resources, opening hours, and slot-level
     availability with real prices from the Playtomic API
   - Foundation fact tables for venue capacity and daily occupancy/revenue
   - City-level pricing benchmarks replacing hardcoded country estimates
   - Planner defaults now use 3-tier cascade: city data → country → fallback

2. Transactional email i18n:
   - _t() helper in worker.py with ~70 translation keys (EN + DE)
   - All 8 email handlers translated, lang passed in task payloads

3. Resend audiences restructured to 3 named audiences (free plan limit)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-23 00:54:53 +01:00
parent c25e20f83a
commit 79f7fc6fad
24 changed files with 1318 additions and 324 deletions

View File

@@ -6,6 +6,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Playtomic full data extraction** — expanded venue bounding boxes from 4 regions
(ES, UK, DE, FR) to 23 globally (Italy, Portugal, NL, BE, AT, CH, Nordics, Mexico,
Argentina, Middle East, USA); PAGE_SIZE increased from 20 to 100; availability
extractor throttle reduced from 2s to 1s for ~4.5h runtime at 16K venues
- **Playtomic pricing & occupancy pipeline** — 4 new staging models:
`stg_playtomic_resources` (per-court: indoor/outdoor, surface type, size),
`stg_playtomic_opening_hours` (per-day: open/close times, hours_open),
`stg_playtomic_availability` (per-slot: 60-min bookable windows with real prices);
`stg_playtomic_venues` rewritten to extract all metadata (opening_hours, resources,
VAT rate, currency, timezone, booking settings)
- **Venue capacity & daily availability fact tables** — `fct_venue_capacity` derives
total bookable court-hours from court_count × opening_hours; `fct_daily_availability`
calculates occupancy rate (1 - available/capacity), booked hours, revenue estimate,
and pricing stats (median/peak/offpeak) per venue per day
- **Venue pricing benchmarks** — `venue_pricing_benchmarks.sql` aggregates last-30-day
venue metrics to city/country level: median hourly rate, peak/offpeak rates, P25/P75,
occupancy rate, estimated daily revenue, court count
- **Real data planner defaults** — `planner_defaults.sql` rewritten with 3-tier cascade:
city-level Playtomic data → country median → hardcoded fallback; replaces income-factor
estimation with actual market pricing; includes `data_source` and `data_confidence`
provenance columns
- **Eurostat income integration** (`stg_income.sql`) — staging model reads `ilc_di03`
(median equivalised net income in PPS) from landing zone; grain `(country_code, ref_year)`
- **Income columns in dim_cities and city_market_profile** — `median_income_pps`
and `income_year` passed through from staging to serving layer
- **Transactional email i18n** — all 8 email types now translated via locale
files; `_t()` helper in `worker.py` looks up `email_*` keys from `en.json` /
`de.json`; `_email_wrap()` accepts `lang` parameter for `<html lang>` tag and
translated footer; ~70 new translation keys (EN + DE); all task payloads now
carry `lang` from request context at enqueue time; payloads without `lang`
gracefully default to English
### Changed
- **Resend audiences restructured** — replaced dynamic `waitlist-{blueprint}`
audience naming (up to 4 audiences) with 3 named audiences fitting free plan
limit: `suppliers` (supplier signups), `leads` (planner/quote users),
`newsletter` (auth/content/public catch-all); new `_audience_for_blueprint()`
mapping function in `core.py`
- **dim_venues enhanced** — now includes court_count, indoor/outdoor split,
timezone, VAT rate, and default currency from Playtomic venue metadata
- **city_market_profile enhanced** — includes median hourly rate, occupancy rate,
daily revenue estimate, and price currency from venue pricing benchmarks
- **Planner API route** — col_map updated to match new planner_defaults columns
(`rate_peak`, `rate_off_peak`, `avg_utilisation_pct`, `courts_typical`); adds
`_dataSource` and `_currency` metadata keys
### Changed
- **Extraction: one file per source** — replaced monolithic `execute.py` with per-source
modules (`overpass.py`, `eurostat.py`, `playtomic_tenants.py`, `playtomic_availability.py`);

View File

@@ -28,7 +28,7 @@ logger = setup_logging("padelnomics.extract.playtomic_availability")
EXTRACTOR_NAME = "playtomic_availability"
AVAILABILITY_URL = "https://api.playtomic.io/v1/availability"
THROTTLE_SECONDS = 2
THROTTLE_SECONDS = 1
MAX_VENUES_PER_RUN = 10_000
MAX_RETRIES_PER_VENUE = 2

View File

@@ -24,16 +24,89 @@ EXTRACTOR_NAME = "playtomic_tenants"
PLAYTOMIC_TENANTS_URL = "https://api.playtomic.io/v1/tenants"
THROTTLE_SECONDS = 2
PAGE_SIZE = 20
PAGE_SIZE = 100
MAX_PAGES_PER_BBOX = 500 # safety bound — prevents infinite pagination
MAX_STALE_PAGES = 3 # stop after N consecutive pages with zero new results
# Target markets: Spain, UK/Ireland, Germany, France
# Global padel markets — bounding boxes sized to stay under API's internal result cap.
# Large countries (Spain, Italy, USA) are split into sub-regions.
BBOXES = [
{"min_latitude": 35.95, "min_longitude": -9.39, "max_latitude": 43.79, "max_longitude": 4.33},
# Spain — south (Andalusia, Murcia, Valencia)
{"min_latitude": 35.95, "min_longitude": -9.39, "max_latitude": 39.87, "max_longitude": 4.33},
# Spain — north (Madrid, Catalonia, Basque Country)
{"min_latitude": 39.87, "min_longitude": -9.39, "max_latitude": 43.79, "max_longitude": 4.33},
# UK & Ireland
{"min_latitude": 49.90, "min_longitude": -8.62, "max_latitude": 60.85, "max_longitude": 1.77},
# Germany
{"min_latitude": 47.27, "min_longitude": 5.87, "max_latitude": 55.06, "max_longitude": 15.04},
# France
{"min_latitude": 41.36, "min_longitude": -5.14, "max_latitude": 51.09, "max_longitude": 9.56},
# Italy — south (Rome, Naples, Sicily, Sardinia)
{"min_latitude": 36.35, "min_longitude": 6.62, "max_latitude": 42.00, "max_longitude": 18.51},
# Italy — north (Milan, Turin, Venice, Bologna)
{"min_latitude": 42.00, "min_longitude": 6.62, "max_latitude": 47.09, "max_longitude": 18.51},
# Portugal
{"min_latitude": 37.00, "min_longitude": -9.50, "max_latitude": 42.15, "max_longitude": -6.19},
# Netherlands
{"min_latitude": 50.75, "min_longitude": 3.37, "max_latitude": 53.47, "max_longitude": 7.21},
# Belgium
{"min_latitude": 49.50, "min_longitude": 2.55, "max_latitude": 51.50, "max_longitude": 6.40},
# Austria
{"min_latitude": 46.37, "min_longitude": 9.53, "max_latitude": 49.02, "max_longitude": 17.16},
# Switzerland
{"min_latitude": 45.82, "min_longitude": 5.96, "max_latitude": 47.80, "max_longitude": 10.49},
# Sweden
{"min_latitude": 55.34, "min_longitude": 11.11, "max_latitude": 69.06, "max_longitude": 24.16},
# Denmark
{"min_latitude": 54.56, "min_longitude": 8.09, "max_latitude": 57.75, "max_longitude": 12.69},
# Norway
{"min_latitude": 57.97, "min_longitude": 4.50, "max_latitude": 71.19, "max_longitude": 31.17},
# Finland
{"min_latitude": 59.81, "min_longitude": 20.55, "max_latitude": 70.09, "max_longitude": 31.59},
# Mexico
{
"min_latitude": 14.53,
"min_longitude": -118.37,
"max_latitude": 32.72,
"max_longitude": -86.71,
},
# Argentina
{
"min_latitude": -55.06,
"min_longitude": -73.56,
"max_latitude": -21.78,
"max_longitude": -53.63,
},
# Middle East (UAE, Qatar, Saudi Arabia, Bahrain)
{"min_latitude": 21.00, "min_longitude": 38.00, "max_latitude": 32.00, "max_longitude": 56.50},
# USA — southwest (California, Arizona, Texas west)
{
"min_latitude": 24.50,
"min_longitude": -125.00,
"max_latitude": 37.00,
"max_longitude": -100.00,
},
# USA — southeast (Florida, Texas east, Georgia)
{
"min_latitude": 24.50,
"min_longitude": -100.00,
"max_latitude": 37.00,
"max_longitude": -66.95,
},
# USA — northwest
{
"min_latitude": 37.00,
"min_longitude": -125.00,
"max_latitude": 49.38,
"max_longitude": -100.00,
},
# USA — northeast (New York, Chicago, Boston)
{
"min_latitude": 37.00,
"min_longitude": -100.00,
"max_latitude": 49.38,
"max_longitude": -66.95,
},
]

View File

@@ -40,6 +40,12 @@ eurostat_labels AS (
-- Derive a slug-friendly city name from the code as fallback
LOWER(REPLACE(city_code, country_code, '')) AS city_slug_raw
FROM eurostat_cities
),
-- Country-level median income (latest year per country)
country_income AS (
SELECT country_code, median_income_pps, ref_year AS income_year
FROM staging.stg_income
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
)
SELECT
ec.city_code,
@@ -52,8 +58,12 @@ SELECT
COALESCE(vc.centroid_lon, 0::DOUBLE) AS lon,
ec.population,
ec.ref_year AS population_year,
COALESCE(vc.venue_count, 0) AS padel_venue_count
COALESCE(vc.venue_count, 0) AS padel_venue_count,
ci.median_income_pps,
ci.income_year
FROM eurostat_cities ec
LEFT JOIN venue_counts vc
ON ec.country_code = vc.country_code
AND LOWER(TRIM(vc.city)) LIKE '%' || LOWER(LEFT(ec.city_code, 2)) || '%'
LEFT JOIN country_income ci
ON ec.country_code = ci.country_code

View File

@@ -2,6 +2,8 @@
-- Venues from both sources are unioned; near-duplicates (within ~100m) are
-- collapsed to a single record preferring Playtomic data (richer metadata).
-- Proximity dedup uses haversine approximation: 1 degree lat ≈ 111 km.
--
-- Playtomic venues include court counts, indoor/outdoor split, currency, and timezone.
MODEL (
name foundation.dim_venues,
@@ -10,51 +12,74 @@ MODEL (
grain venue_id
);
WITH all_venues AS (
WITH playtomic_venues AS (
SELECT
'osm:' || osm_id::TEXT AS venue_id,
source,
'pt:' || v.tenant_id AS venue_id,
v.tenant_id,
'playtomic' AS source,
v.lat,
v.lon,
v.country_code,
v.name,
v.city,
v.postcode,
v.tenant_type,
v.timezone,
v.vat_rate,
v.default_currency,
-- Court counts from resources
COUNT(r.resource_id) AS court_count,
COUNT(r.resource_id) FILTER (WHERE r.resource_type = 'indoor') AS indoor_court_count,
COUNT(r.resource_id) FILTER (WHERE r.resource_type = 'outdoor') AS outdoor_court_count,
v.extracted_date
FROM staging.stg_playtomic_venues v
LEFT JOIN staging.stg_playtomic_resources r
ON v.tenant_id = r.tenant_id AND r.is_active = TRUE
WHERE v.country_code IS NOT NULL
GROUP BY
v.tenant_id, v.lat, v.lon, v.country_code, v.name, v.city,
v.postcode, v.tenant_type, v.timezone, v.vat_rate,
v.default_currency, v.extracted_date
),
osm_venues AS (
SELECT
'osm:' || osm_id::TEXT AS venue_id,
NULL AS tenant_id,
'osm' AS source,
lat,
lon,
country_code,
name,
city,
postcode,
NULL AS tenant_type,
NULL AS tenant_type,
NULL AS timezone,
NULL AS vat_rate,
NULL AS default_currency,
NULL AS court_count,
NULL AS indoor_court_count,
NULL AS outdoor_court_count,
extracted_date
FROM staging.stg_padel_courts
WHERE country_code IS NOT NULL
UNION ALL
SELECT
'pt:' || tenant_id AS venue_id,
source,
lat,
lon,
country_code,
name,
city,
postcode,
tenant_type,
extracted_date
FROM staging.stg_playtomic_venues
WHERE country_code IS NOT NULL
),
-- Rank venues so Playtomic records win ties in proximity dedup
all_venues AS (
SELECT * FROM playtomic_venues
UNION ALL
SELECT * FROM osm_venues
),
ranked AS (
SELECT *,
CASE source WHEN 'playtomic' THEN 1 ELSE 2 END AS source_rank
FROM all_venues
)
-- Note: full proximity dedup (haversine clustering) is expensive in SQL.
-- For now, deduplicate on exact (country_code, ROUND(lat,3), ROUND(lon,3))
-- — ≈111m grid cells. Refine with spatial index if volumes grow.
-- Deduplicate on ~111m grid cells, preferring Playtomic
SELECT
MIN(venue_id) OVER (
PARTITION BY country_code, ROUND(lat, 3)::TEXT, ROUND(lon, 3)::TEXT
ORDER BY source_rank
) AS venue_id,
) AS venue_id,
tenant_id,
country_code,
lat,
lon,
@@ -62,11 +87,17 @@ SELECT
MAX(CASE WHEN source = 'playtomic' THEN name END)
OVER (PARTITION BY country_code, ROUND(lat,3)::TEXT, ROUND(lon,3)::TEXT),
name
) AS name,
COALESCE(city, '') AS city,
) AS name,
COALESCE(city, '') AS city,
postcode,
source,
tenant_type,
timezone,
vat_rate,
default_currency,
court_count,
indoor_court_count,
outdoor_court_count,
extracted_date
FROM ranked
QUALIFY ROW_NUMBER() OVER (

View File

@@ -0,0 +1,90 @@
-- Daily venue-level availability, pricing, occupancy, and revenue estimates.
-- Aggregates slot-level data from stg_playtomic_availability into per-venue
-- per-day statistics, then calculates occupancy by comparing available hours
-- against total capacity from fct_venue_capacity.
--
-- Occupancy = 1 - (available_court_hours / capacity_court_hours_per_day)
-- Revenue estimate = booked_court_hours × avg_price_of_available_slots
--
-- Peak hours defined as 17:0021:00 (captures main evening rush across markets).
MODEL (
name foundation.fct_daily_availability,
kind FULL,
cron '@daily',
grain (snapshot_date, tenant_id)
);
WITH slot_agg AS (
SELECT
a.snapshot_date,
a.tenant_id,
-- Slot counts: each row is one 60-min available slot on one court
COUNT(*) AS available_slot_count,
COUNT(DISTINCT a.resource_id) AS courts_with_availability,
-- Available (unbooked) court-hours: slots are on 30-min increments for 60-min bookings
-- Each available start_time represents a 60-min bookable window
ROUND(COUNT(*) * 1.0, 2) AS available_court_hours,
-- Pricing stats (60-min slots only)
ROUND(MEDIAN(a.price_amount), 2) AS median_price,
ROUND(AVG(a.price_amount), 2) AS avg_price,
MIN(a.price_amount) AS min_price,
MAX(a.price_amount) AS max_price,
-- Peak: 17:0021:00
ROUND(MEDIAN(a.price_amount) FILTER (
WHERE a.slot_start_time::TIME >= '17:00:00'
AND a.slot_start_time::TIME < '21:00:00'
), 2) AS median_price_peak,
-- Off-peak: everything outside 17:0021:00
ROUND(MEDIAN(a.price_amount) FILTER (
WHERE a.slot_start_time::TIME < '17:00:00'
OR a.slot_start_time::TIME >= '21:00:00'
), 2) AS median_price_offpeak,
MAX(a.price_currency) AS price_currency,
MAX(a.captured_at_utc) AS captured_at_utc
FROM staging.stg_playtomic_availability a
WHERE a.price_amount IS NOT NULL
AND a.price_amount > 0
GROUP BY a.snapshot_date, a.tenant_id
)
SELECT
sa.snapshot_date,
sa.tenant_id,
cap.country_code,
cap.city,
cap.active_court_count,
cap.capacity_court_hours_per_day,
sa.available_slot_count,
sa.courts_with_availability,
sa.available_court_hours,
-- Occupancy: (capacity - available) / capacity
CASE
WHEN cap.capacity_court_hours_per_day > 0
THEN ROUND(
1.0 - (sa.available_court_hours / cap.capacity_court_hours_per_day),
4
)
ELSE NULL
END AS occupancy_rate,
-- Estimated booked court-hours
ROUND(
GREATEST(cap.capacity_court_hours_per_day - sa.available_court_hours, 0),
2
) AS booked_court_hours,
-- Estimated daily revenue: booked hours × avg price
ROUND(
GREATEST(cap.capacity_court_hours_per_day - sa.available_court_hours, 0)
* sa.avg_price,
2
) AS estimated_revenue_eur,
-- Pricing
sa.median_price,
sa.avg_price,
sa.min_price,
sa.max_price,
sa.median_price_peak,
sa.median_price_offpeak,
sa.price_currency,
sa.captured_at_utc
FROM slot_agg sa
JOIN foundation.fct_venue_capacity cap ON sa.tenant_id = cap.tenant_id

View File

@@ -0,0 +1,45 @@
-- Venue capacity: total bookable court-hours per day and week.
-- Derived from active court count × opening hours.
-- Used as the denominator for occupancy rate in fct_daily_availability.
--
-- One row per venue (Playtomic tenant).
MODEL (
name foundation.fct_venue_capacity,
kind FULL,
cron '@daily',
grain tenant_id
);
WITH weekly_hours AS (
SELECT
tenant_id,
SUM(hours_open) AS hours_open_per_week,
AVG(hours_open) AS avg_hours_open_per_day,
COUNT(*) AS days_open_per_week
FROM staging.stg_playtomic_opening_hours
GROUP BY tenant_id
),
court_counts AS (
SELECT
tenant_id,
COUNT(*) AS active_court_count
FROM staging.stg_playtomic_resources
WHERE is_active = TRUE
GROUP BY tenant_id
)
SELECT
v.tenant_id,
v.country_code,
v.city,
cc.active_court_count,
ROUND(wh.hours_open_per_week, 1) AS hours_open_per_week,
ROUND(wh.avg_hours_open_per_day, 1) AS avg_hours_open_per_day,
wh.days_open_per_week,
-- Total bookable court-hours per day (capacity denominator for occupancy)
ROUND(cc.active_court_count * wh.avg_hours_open_per_day, 1) AS capacity_court_hours_per_day,
-- Total bookable court-hours per week
ROUND(cc.active_court_count * wh.hours_open_per_week, 1) AS capacity_court_hours_per_week
FROM staging.stg_playtomic_venues v
JOIN court_counts cc ON v.tenant_id = cc.tenant_id
JOIN weekly_hours wh ON v.tenant_id = wh.tenant_id

View File

@@ -24,6 +24,8 @@ WITH base AS (
c.population,
c.population_year,
c.padel_venue_count,
c.median_income_pps,
c.income_year,
-- Venue density: padel venues per 100K residents
CASE WHEN c.population > 0
THEN ROUND(c.padel_venue_count::DOUBLE / c.population * 100000, 2)
@@ -51,18 +53,30 @@ scored AS (
FROM base
)
SELECT
city_code,
country_code,
city_name,
city_slug,
lat,
lon,
population,
population_year,
padel_venue_count,
venues_per_100k,
data_confidence,
market_score,
s.city_code,
s.country_code,
s.city_name,
s.city_slug,
s.lat,
s.lon,
s.population,
s.population_year,
s.padel_venue_count,
s.venues_per_100k,
s.data_confidence,
s.market_score,
s.median_income_pps,
s.income_year,
-- Playtomic pricing/occupancy (NULL when no availability data)
vpb.median_hourly_rate,
vpb.median_peak_rate,
vpb.median_offpeak_rate,
vpb.median_occupancy_rate,
vpb.median_daily_revenue_per_venue,
vpb.price_currency,
CURRENT_DATE AS refreshed_date
FROM scored
ORDER BY market_score DESC
FROM scored s
LEFT JOIN serving.venue_pricing_benchmarks vpb
ON s.country_code = vpb.country_code
AND LOWER(TRIM(s.city_name)) = LOWER(TRIM(vpb.city))
ORDER BY s.market_score DESC

View File

@@ -1,11 +1,13 @@
-- Per-city planner defaults for the financial calculator.
-- When a user selects a city in the planner, these values pre-fill the inputs.
-- Consumed by: padelnomics.planner.routes — city_defaults(city_slug) lookup.
-- Consumed by: padelnomics.planner.routes — /api/market-data endpoint.
--
-- Values are derived from market data where available, otherwise fall back to
-- country-level medians, then to global fallbacks from market research report.
-- 3-tier data cascade:
-- 1. City-level: real pricing/occupancy from Playtomic availability snapshots
-- 2. Country-level: median across cities in same country
-- 3. Hardcoded fallback: market research estimates (only when no Playtomic data)
--
-- Units are explicit in column names (EUR, %, h). All monetary values in EUR.
-- Units are explicit in column names. Monetary values in local currency.
MODEL (
name serving.planner_defaults,
@@ -14,59 +16,120 @@ MODEL (
grain city_slug
);
WITH country_medians AS (
-- Country-level fallback values from market research (hardcoded until we
-- have richer pricing data from Playtomic or direct scraping).
SELECT * FROM (VALUES
-- (country_code, hourly_rate_peak_eur, monthly_rent_eur_sqm, capex_court_eur,
-- avg_utilisation_pct, courts_typical)
('DE', 22.0, 14.0, 42000.0, 0.55, 4),
('ES', 16.0, 9.0, 32000.0, 0.62, 6),
('GB', 24.0, 18.0, 48000.0, 0.52, 4),
('FR', 18.0, 12.0, 36000.0, 0.58, 5),
('IT', 15.0, 10.0, 30000.0, 0.60, 6),
('PT', 12.0, 8.0, 28000.0, 0.65, 6),
('AT', 20.0, 13.0, 40000.0, 0.54, 4),
('CH', 28.0, 22.0, 55000.0, 0.50, 4),
('NL', 20.0, 15.0, 40000.0, 0.56, 4),
('BE', 18.0, 13.0, 36000.0, 0.57, 4),
('SE', 22.0, 14.0, 42000.0, 0.50, 4),
('US', 20.0, 12.0, 38000.0, 0.58, 6)
) AS t(country_code, hourly_rate_peak_eur, monthly_rent_eur_sqm, capex_court_eur,
avg_utilisation_pct, courts_typical)
WITH -- Real city-level benchmarks from Playtomic
city_benchmarks AS (
SELECT
country_code,
city,
median_peak_rate,
median_offpeak_rate,
median_occupancy_rate,
median_daily_revenue_per_venue,
median_court_count,
venue_count,
total_venue_days_observed,
price_currency
FROM serving.venue_pricing_benchmarks
),
city_venue_density AS (
-- Country-level medians (fallback when a city has no availability data)
country_benchmarks AS (
SELECT
country_code,
MEDIAN(median_peak_rate) AS median_peak_rate,
MEDIAN(median_offpeak_rate) AS median_offpeak_rate,
MEDIAN(median_occupancy_rate) AS median_occupancy_rate,
MEDIAN(median_court_count) AS median_court_count,
SUM(venue_count) AS total_venues,
MIN(price_currency) AS price_currency
FROM city_benchmarks
GROUP BY country_code
),
-- Hardcoded global fallbacks (only for countries with zero Playtomic coverage)
hardcoded_fallbacks AS (
SELECT * FROM (VALUES
('DE', 22.0, 16.5, 0.55, 4, 'EUR'),
('ES', 16.0, 12.0, 0.62, 6, 'EUR'),
('GB', 24.0, 18.0, 0.52, 4, 'GBP'),
('FR', 18.0, 13.5, 0.58, 5, 'EUR'),
('IT', 15.0, 11.0, 0.60, 6, 'EUR'),
('PT', 12.0, 9.0, 0.65, 6, 'EUR'),
('AT', 20.0, 15.0, 0.54, 4, 'EUR'),
('CH', 28.0, 21.0, 0.50, 4, 'CHF'),
('NL', 20.0, 15.0, 0.56, 4, 'EUR'),
('BE', 18.0, 13.5, 0.57, 4, 'EUR'),
('SE', 22.0, 16.5, 0.50, 4, 'SEK'),
('US', 20.0, 15.0, 0.58, 6, 'USD'),
('MX', 12.0, 9.0, 0.55, 4, 'MXN'),
('AR', 10.0, 7.5, 0.60, 4, 'ARS'),
('DK', 24.0, 18.0, 0.48, 4, 'DKK'),
('NO', 26.0, 19.5, 0.45, 4, 'NOK'),
('FI', 22.0, 16.5, 0.48, 4, 'EUR')
) AS t(country_code, peak_rate, offpeak_rate, occupancy, courts, currency)
),
city_profiles AS (
SELECT
city_slug,
country_code,
city_name,
padel_venue_count,
population,
venues_per_100k,
market_score
market_score,
venues_per_100k
FROM serving.city_market_profile
)
SELECT
cvd.city_slug,
cvd.country_code,
cvd.padel_venue_count,
cvd.population,
cvd.market_score,
-- Hourly rate: adjust country median by market maturity
-- (high-density markets → slightly lower rates from competition)
ROUND(
cm.hourly_rate_peak_eur
* CASE
WHEN cvd.venues_per_100k > 4 THEN 0.90 -- very competitive
WHEN cvd.venues_per_100k > 2 THEN 0.95 -- competitive
WHEN cvd.venues_per_100k < 0.5 THEN 1.10 -- underserved premium
ELSE 1.0
END
, 2) AS hourly_rate_peak_eur,
ROUND(cm.hourly_rate_peak_eur * 0.75, 2) AS hourly_rate_offpeak_eur,
cm.monthly_rent_eur_sqm,
cm.capex_court_eur,
cm.avg_utilisation_pct,
cm.courts_typical,
CURRENT_DATE AS refreshed_date
FROM city_venue_density cvd
LEFT JOIN country_medians cm ON cvd.country_code = cm.country_code
cp.city_slug,
cp.country_code,
cp.city_name,
cp.padel_venue_count,
cp.population,
cp.market_score,
-- Peak rate: city → country → hardcoded
ROUND(COALESCE(
cb.median_peak_rate,
ctb.median_peak_rate,
hf.peak_rate
), 2) AS rate_peak,
-- Off-peak rate
ROUND(COALESCE(
cb.median_offpeak_rate,
ctb.median_offpeak_rate,
hf.offpeak_rate
), 2) AS rate_off_peak,
-- Occupancy (utilisation)
ROUND(COALESCE(
cb.median_occupancy_rate,
ctb.median_occupancy_rate,
hf.occupancy
), 4) AS avg_utilisation_pct,
-- Typical court count
COALESCE(
cb.median_court_count,
ctb.median_court_count,
hf.courts
) AS courts_typical,
-- Revenue estimate (city-level only)
cb.median_daily_revenue_per_venue AS daily_revenue_per_venue,
-- Data provenance
CASE
WHEN cb.venue_count IS NOT NULL THEN 'city_data'
WHEN ctb.total_venues IS NOT NULL THEN 'country_data'
ELSE 'hardcoded'
END AS data_source,
CASE
WHEN cb.total_venue_days_observed >= 100 THEN 1.0
WHEN cb.total_venue_days_observed >= 30 THEN 0.8
WHEN cb.venue_count IS NOT NULL THEN 0.6
WHEN ctb.total_venues IS NOT NULL THEN 0.4
ELSE 0.2
END AS data_confidence,
COALESCE(cb.price_currency, ctb.price_currency, hf.currency, 'EUR') AS price_currency,
CURRENT_DATE AS refreshed_date
FROM city_profiles cp
LEFT JOIN city_benchmarks cb
ON cp.country_code = cb.country_code
AND LOWER(TRIM(cp.city_name)) = LOWER(TRIM(cb.city))
LEFT JOIN country_benchmarks ctb
ON cp.country_code = ctb.country_code
LEFT JOIN hardcoded_fallbacks hf
ON cp.country_code = hf.country_code

View File

@@ -0,0 +1,57 @@
-- Per-city pricing and occupancy benchmarks from Playtomic availability data.
-- Aggregates venue-level daily metrics (last 30 days) into city-level benchmarks.
-- Consumed by: planner defaults (pre-fill), city market profile, SEO articles.
--
-- Minimum data threshold: venues with >= 3 days of observations.
MODEL (
name serving.venue_pricing_benchmarks,
kind FULL,
cron '@daily',
grain (country_code, city)
);
WITH venue_stats AS (
-- Aggregate last 30 days per venue
SELECT
da.tenant_id,
da.country_code,
da.city,
da.price_currency,
AVG(da.occupancy_rate) AS avg_occupancy_rate,
MEDIAN(da.median_price) AS median_hourly_rate,
MEDIAN(da.median_price_peak) AS median_peak_rate,
MEDIAN(da.median_price_offpeak) AS median_offpeak_rate,
AVG(da.estimated_revenue_eur) AS avg_daily_revenue,
MAX(da.active_court_count) AS court_count,
COUNT(DISTINCT da.snapshot_date) AS days_observed
FROM foundation.fct_daily_availability da
WHERE da.snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
AND da.occupancy_rate IS NOT NULL
AND da.occupancy_rate BETWEEN 0 AND 1.5
GROUP BY da.tenant_id, da.country_code, da.city, da.price_currency
HAVING COUNT(DISTINCT da.snapshot_date) >= 3
)
SELECT
country_code,
city,
price_currency,
COUNT(*) AS venue_count,
-- Pricing benchmarks
ROUND(MEDIAN(median_hourly_rate), 2) AS median_hourly_rate,
ROUND(MEDIAN(median_peak_rate), 2) AS median_peak_rate,
ROUND(MEDIAN(median_offpeak_rate), 2) AS median_offpeak_rate,
ROUND(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY median_hourly_rate), 2) AS hourly_rate_p25,
ROUND(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY median_hourly_rate), 2) AS hourly_rate_p75,
-- Occupancy benchmarks
ROUND(MEDIAN(avg_occupancy_rate), 4) AS median_occupancy_rate,
ROUND(AVG(avg_occupancy_rate), 4) AS avg_occupancy_rate,
-- Revenue benchmarks (per venue per day)
ROUND(MEDIAN(avg_daily_revenue), 2) AS median_daily_revenue_per_venue,
-- Court mix
ROUND(MEDIAN(court_count), 0)::INTEGER AS median_court_count,
-- Data quality
SUM(days_observed) AS total_venue_days_observed,
CURRENT_DATE AS refreshed_date
FROM venue_stats
GROUP BY country_code, city, price_currency

View File

@@ -0,0 +1,44 @@
-- Eurostat median equivalised net income in PPS (dataset: ilc_di03).
-- Country-level income data for purchasing power adjustments.
-- One row per (country_code, year) with median income values.
--
-- Source: data/landing/eurostat/{year}/{month}/ilc_di03.json.gz
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2022", "value": 23127}, ...]}
MODEL (
name staging.stg_income,
kind FULL,
cron '@daily',
grain (country_code, ref_year)
);
WITH source AS (
SELECT unnest(rows) AS r
FROM read_json(
@LANDING_DIR || '/eurostat/*/*/ilc_di03.json.gz',
auto_detect = true
)
),
parsed AS (
SELECT
UPPER(TRIM(r.geo_code)) AS geo_code,
CAST(r.ref_year AS INTEGER) AS ref_year,
CAST(r.value AS DOUBLE) AS median_income_pps,
CURRENT_DATE AS extracted_date
FROM source
WHERE r.value IS NOT NULL
)
SELECT
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
CASE geo_code
WHEN 'EL' THEN 'GR'
WHEN 'UK' THEN 'GB'
ELSE geo_code
END AS country_code,
ref_year,
median_income_pps,
extracted_date
FROM parsed
WHERE LENGTH(geo_code) = 2
AND geo_code NOT IN ('EU', 'EA')
AND median_income_pps > 0

View File

@@ -0,0 +1,82 @@
-- Daily availability snapshots from Playtomic — slot-level pricing data.
-- One row per available 60-minute booking slot per court per venue per day.
-- "Available" = the slot was NOT booked at capture time. Missing slots = booked.
--
-- Only 60-min duration slots are kept (canonical hourly rate + occupancy unit).
-- The API returns 60/90/120-min variants per start_time — filtering to 60 avoids
-- double-counting the same time window.
--
-- Price parsed from strings like "14.56 EUR" or "48 GBP".
--
-- Source: data/landing/playtomic/{year}/{month}/availability_{date}.json.gz
-- Format: {date, captured_at_utc, venues: [{tenant_id, slots: [{resource_id, start_date, slots: [...]}]}]}
MODEL (
name staging.stg_playtomic_availability,
kind FULL,
cron '@daily',
grain (snapshot_date, tenant_id, resource_id, slot_start_time)
);
WITH raw_files AS (
SELECT *
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/availability_*.json.gz',
format = 'auto',
columns = {
date: 'VARCHAR',
captured_at_utc: 'VARCHAR',
venues: 'JSON[]'
}
)
),
raw_venues AS (
SELECT
rf.date AS snapshot_date,
rf.captured_at_utc,
venue_json
FROM raw_files rf,
LATERAL UNNEST(rf.venues) AS t(venue_json)
),
-- Each venue has: {tenant_id, slots: [{resource_id, start_date, slots: [...]}]}
raw_resources AS (
SELECT
rv.snapshot_date,
rv.captured_at_utc,
rv.venue_json ->> 'tenant_id' AS tenant_id,
resource_json
FROM raw_venues rv,
LATERAL UNNEST(
from_json(rv.venue_json -> 'slots', '["JSON"]')
) AS t(resource_json)
),
-- Each resource has: {resource_id, start_date, slots: [{start_time, duration, price}]}
raw_slots AS (
SELECT
rr.snapshot_date,
rr.captured_at_utc,
rr.tenant_id,
rr.resource_json ->> 'resource_id' AS resource_id,
slot_json
FROM raw_resources rr,
LATERAL UNNEST(
from_json(rr.resource_json -> 'slots', '["JSON"]')
) AS t(slot_json)
)
SELECT
snapshot_date,
tenant_id,
resource_id,
slot_json ->> 'start_time' AS slot_start_time,
TRY_CAST(slot_json ->> 'duration' AS INTEGER) AS duration_minutes,
-- Parse "14.56 EUR" → 14.56
TRY_CAST(
SPLIT_PART(slot_json ->> 'price', ' ', 1) AS DOUBLE
) AS price_amount,
-- Parse "14.56 EUR" → EUR
SPLIT_PART(slot_json ->> 'price', ' ', 2) AS price_currency,
captured_at_utc
FROM raw_slots
WHERE resource_id IS NOT NULL
AND (slot_json ->> 'start_time') IS NOT NULL
AND TRY_CAST(slot_json ->> 'duration' AS INTEGER) = 60

View File

@@ -0,0 +1,54 @@
-- Venue opening hours by day of week from Playtomic.
-- Unpivots the opening_hours JSON object into one row per (tenant_id, day_of_week).
-- Used downstream to calculate total weekly/daily capacity hours.
--
-- Source: data/landing/playtomic/{year}/{month}/tenants.json.gz
-- Each tenant has opening_hours: {MONDAY: {opening_time, closing_time}, ...}
MODEL (
name staging.stg_playtomic_opening_hours,
kind FULL,
cron '@daily',
grain (tenant_id, day_of_week)
);
WITH venues AS (
SELECT UNNEST(tenants) AS tenant
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/tenants.json.gz',
format = 'auto'
)
),
days AS (
SELECT day_name, day_number FROM (VALUES
('MONDAY', 1), ('TUESDAY', 2), ('WEDNESDAY', 3), ('THURSDAY', 4),
('FRIDAY', 5), ('SATURDAY', 6), ('SUNDAY', 7)
) AS t(day_name, day_number)
),
parsed AS (
SELECT
tenant ->> 'tenant_id' AS tenant_id,
d.day_name AS day_of_week,
d.day_number,
tenant -> 'opening_hours' -> d.day_name ->> 'opening_time' AS opening_time,
tenant -> 'opening_hours' -> d.day_name ->> 'closing_time' AS closing_time
FROM venues
CROSS JOIN days d
WHERE (tenant ->> 'tenant_id') IS NOT NULL
AND (tenant -> 'opening_hours') IS NOT NULL
AND (tenant -> 'opening_hours' -> d.day_name) IS NOT NULL
)
SELECT
tenant_id,
day_of_week,
day_number,
opening_time,
closing_time,
-- Hours open this day (e.g., 09:00 to 23:00 = 14.0h)
ROUND(
(EXTRACT(HOUR FROM closing_time::TIME) + EXTRACT(MINUTE FROM closing_time::TIME) / 60.0)
- (EXTRACT(HOUR FROM opening_time::TIME) + EXTRACT(MINUTE FROM opening_time::TIME) / 60.0)
, 2) AS hours_open
FROM parsed
WHERE opening_time IS NOT NULL
AND closing_time IS NOT NULL

View File

@@ -0,0 +1,46 @@
-- Individual court (resource) records from Playtomic venues.
-- Reads resources array from the landing zone JSON directly (double UNNEST:
-- tenants → resources) to extract court type, size, surface, and booking config.
--
-- Source: data/landing/playtomic/{year}/{month}/tenants.json.gz
-- Each tenant has a resources[] array of court objects.
MODEL (
name staging.stg_playtomic_resources,
kind FULL,
cron '@daily',
grain (tenant_id, resource_id)
);
WITH raw AS (
SELECT UNNEST(tenants) AS tenant
FROM read_json(
@LANDING_DIR || '/playtomic/*/*/tenants.json.gz',
format = 'auto'
)
),
unnested AS (
SELECT
tenant ->> 'tenant_id' AS tenant_id,
UPPER(tenant -> 'address' ->> 'country_code') AS country_code,
UNNEST(from_json(tenant -> 'resources', '["JSON"]')) AS resource_json
FROM raw
WHERE (tenant ->> 'tenant_id') IS NOT NULL
AND (tenant -> 'resources') IS NOT NULL
)
SELECT
tenant_id,
resource_json ->> 'resource_id' AS resource_id,
country_code,
NULLIF(TRIM(resource_json ->> 'name'), '') AS resource_name,
resource_json ->> 'sport_id' AS sport_id,
CASE WHEN LOWER(resource_json ->> 'is_active') IN ('true', '1')
THEN TRUE ELSE FALSE END AS is_active,
LOWER(resource_json -> 'properties' ->> 'resource_type') AS resource_type,
LOWER(resource_json -> 'properties' ->> 'resource_size') AS resource_size,
LOWER(resource_json -> 'properties' ->> 'resource_feature') AS resource_feature,
CASE WHEN LOWER(resource_json -> 'booking_settings' ->> 'is_bookable_online') IN ('true', '1')
THEN TRUE ELSE FALSE END AS is_bookable_online
FROM unnested
WHERE (resource_json ->> 'resource_id') IS NOT NULL
AND (resource_json ->> 'sport_id') = 'PADEL'

View File

@@ -1,8 +1,10 @@
-- Playtomic padel venue records from unauthenticated tenant search API.
-- Reads landing zone JSON directly, unnests tenant array, deduplicates on
-- tenant_id (keeps most recent), and normalizes address fields.
-- Playtomic padel venue records — full metadata extraction.
-- Reads landing zone JSON, unnests tenant array, extracts all venue metadata
-- including address, opening hours, court resources, VAT rate, and facilities.
-- Deduplicates on tenant_id (keeps most recent extraction).
--
-- Source: data/landing/playtomic/{year}/{month}/tenants.json.gz
-- Format: {"tenants": [{tenant_id, tenant_name, address, resources, opening_hours, ...}]}
MODEL (
name staging.stg_playtomic_venues,
@@ -13,18 +15,42 @@ MODEL (
WITH parsed AS (
SELECT
tenant ->> 'tenant_id' AS tenant_id,
tenant ->> 'tenant_name' AS tenant_name,
tenant -> 'address' ->> 'street' AS street,
tenant -> 'address' ->> 'city' AS city,
tenant -> 'address' ->> 'postal_code' AS postal_code,
tenant -> 'address' ->> 'country_code' AS country_code,
TRY_CAST(tenant -> 'address' ->> 'coordinate_lat' AS DOUBLE) AS lat,
TRY_CAST(tenant -> 'address' ->> 'coordinate_lon' AS DOUBLE) AS lon,
tenant ->> 'sport_ids' AS sport_ids_raw,
tenant ->> 'tenant_type' AS tenant_type,
filename AS source_file,
CURRENT_DATE AS extracted_date
-- Identity
tenant ->> 'tenant_id' AS tenant_id,
tenant ->> 'tenant_name' AS tenant_name,
tenant ->> 'slug' AS slug,
tenant ->> 'tenant_type' AS tenant_type,
tenant ->> 'tenant_status' AS tenant_status,
tenant ->> 'playtomic_status' AS playtomic_status,
tenant ->> 'booking_type' AS booking_type,
-- Address
tenant -> 'address' ->> 'street' AS street,
tenant -> 'address' ->> 'city' AS city,
tenant -> 'address' ->> 'postal_code' AS postal_code,
UPPER(tenant -> 'address' ->> 'country_code') AS country_code,
tenant -> 'address' ->> 'timezone' AS timezone,
tenant -> 'address' ->> 'administrative_area' AS administrative_area,
TRY_CAST(tenant -> 'address' -> 'coordinate' ->> 'lat' AS DOUBLE) AS lat,
TRY_CAST(tenant -> 'address' -> 'coordinate' ->> 'lon' AS DOUBLE) AS lon,
-- Commercial
TRY_CAST(tenant ->> 'vat_rate' AS DOUBLE) AS vat_rate,
tenant ->> 'default_currency' AS default_currency,
-- Booking settings (venue-level)
TRY_CAST(tenant -> 'booking_settings' ->> 'booking_ahead_limit' AS INTEGER) AS booking_ahead_limit_minutes,
-- Opening hours and resources stored as JSON for downstream models
tenant -> 'opening_hours' AS opening_hours_json,
tenant -> 'resources' AS resources_json,
-- Metadata
tenant ->> 'created_at' AS created_at,
tenant ->> 'is_playtomic_partner' AS is_playtomic_partner_raw,
filename AS source_file,
CURRENT_DATE AS extracted_date
FROM (
SELECT UNNEST(tenants) AS tenant, filename
FROM read_json(
@@ -37,22 +63,37 @@ WITH parsed AS (
),
deduped AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY extracted_date DESC) AS rn
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY source_file DESC) AS rn
FROM parsed
WHERE tenant_id IS NOT NULL
AND lat IS NOT NULL AND lon IS NOT NULL
AND lat BETWEEN -90 AND 90
AND lat BETWEEN -90 AND 90
AND lon BETWEEN -180 AND 180
)
SELECT
tenant_id,
'playtomic' AS source,
lat, lon,
UPPER(country_code) AS country_code,
NULLIF(TRIM(tenant_name), '') AS name,
NULLIF(TRIM(city), '') AS city,
postal_code AS postcode,
'playtomic' AS source,
NULLIF(TRIM(tenant_name), '') AS name,
slug,
tenant_type,
tenant_status,
playtomic_status,
booking_type,
street,
NULLIF(TRIM(city), '') AS city,
postal_code AS postcode,
country_code,
timezone,
administrative_area,
lat,
lon,
vat_rate,
default_currency,
booking_ahead_limit_minutes,
opening_hours_json,
resources_json,
created_at,
CASE WHEN LOWER(is_playtomic_partner_raw) IN ('true', '1') THEN TRUE ELSE FALSE END AS is_playtomic_partner,
extracted_date
FROM deduped
WHERE rn = 1

View File

@@ -231,7 +231,7 @@ async def login():
# Queue email
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token})
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
await flash(_t["auth_flash_login_sent"], "success")
return redirect(url_for("auth.magic_link_sent", email=email))
@@ -292,8 +292,8 @@ async def signup():
# Queue emails
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token})
await enqueue("send_welcome", {"email": email})
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
await enqueue("send_welcome", {"email": email, "lang": g.lang})
await flash(_t["auth_flash_signup_sent"], "success")
return redirect(url_for("auth.magic_link_sent", email=email))
@@ -397,7 +397,7 @@ async def resend():
await create_auth_token(user["id"], token)
from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token})
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
# Always show success (don't reveal if email exists)
await flash(_t["auth_flash_resend_sent"], "success")

View File

@@ -334,6 +334,21 @@ async def _get_or_create_resend_audience(name: str) -> str | None:
return None
_BLUEPRINT_TO_AUDIENCE = {
"suppliers": "suppliers",
"planner": "leads",
"leads": "leads",
"auth": "newsletter",
"content": "newsletter",
"public": "newsletter",
}
def _audience_for_blueprint(blueprint: str) -> str:
"""Map blueprint name to one of 3 Resend audiences (free plan limit)."""
return _BLUEPRINT_TO_AUDIENCE.get(blueprint, "newsletter")
async def capture_waitlist_email(email: str, intent: str, plan: str = None, email_intent: str = None) -> bool:
"""Insert email into waitlist, enqueue confirmation, add to Resend audience.
@@ -361,12 +376,14 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai
if is_new:
from .worker import enqueue
email_intent_value = email_intent if email_intent is not None else intent
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value})
lang = g.get("lang", "en") if g else "en"
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value, "lang": lang})
# Add to Resend audience (silent fail - not critical)
# 3 named audiences: suppliers, leads, newsletter (free plan limit = 3)
if config.RESEND_API_KEY:
blueprint = request.blueprints[0] if request.blueprints else "default"
audience_name = f"waitlist-{blueprint}"
blueprint = request.blueprints[0] if request.blueprints else "public"
audience_name = _audience_for_blueprint(blueprint)
audience_id = await _get_or_create_resend_audience(audience_name)
if audience_id:
try:

View File

@@ -301,6 +301,7 @@ async def supplier_enquiry(slug: str):
"contact_name": contact_name,
"contact_email": contact_email,
"message": message,
"lang": g.get("lang", "en"),
})
return await render_template(

View File

@@ -535,7 +535,7 @@ async def verify_quote():
# Send welcome email
from ..worker import enqueue
await enqueue("send_welcome", {"email": contact_email})
await enqueue("send_welcome", {"email": contact_email, "lang": g.get("lang", "en")})
return await render_template(
"quote_submitted.html",

View File

@@ -1533,5 +1533,83 @@
"bp_lbl_debt": "Schulden",
"bp_lbl_cumulative": "Kumulativ",
"bp_lbl_disclaimer": "<strong>Haftungsausschluss:</strong> Dieser Businessplan wurde auf Basis benutzerdefinierter Annahmen mit dem Padelnomics-Finanzmodell erstellt. Alle Prognosen sind Sch\u00e4tzungen und stellen keine Finanzberatung dar. Die tats\u00e4chlichen Ergebnisse k\u00f6nnen je nach Marktbedingungen, Umsetzung und anderen Faktoren erheblich abweichen. Konsultiere Finanzberater, bevor du Investitionsentscheidungen triffst. \u00a9 Padelnomics \u2014 padelnomics.io"
"bp_lbl_disclaimer": "<strong>Haftungsausschluss:</strong> Dieser Businessplan wurde auf Basis benutzerdefinierter Annahmen mit dem Padelnomics-Finanzmodell erstellt. Alle Prognosen sind Sch\u00e4tzungen und stellen keine Finanzberatung dar. Die tats\u00e4chlichen Ergebnisse k\u00f6nnen je nach Marktbedingungen, Umsetzung und anderen Faktoren erheblich abweichen. Konsultiere Finanzberater, bevor du Investitionsentscheidungen triffst. \u00a9 Padelnomics \u2014 padelnomics.io",
"email_magic_link_heading": "Bei {app_name} anmelden",
"email_magic_link_body": "Klicke auf den Button unten, um dich anzumelden. Dieser Link l\u00e4uft in {expiry_minutes} Minuten ab.",
"email_magic_link_btn": "Anmelden",
"email_magic_link_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
"email_magic_link_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
"email_magic_link_subject": "Bei {app_name} anmelden",
"email_quote_verify_heading": "Best\u00e4tige deine E-Mail f\u00fcr Anbieter-Angebote",
"email_quote_verify_greeting": "Hallo {first_name},",
"email_quote_verify_body": "Danke f\u00fcr deine Angebotsanfrage{project_desc}. Klicke auf den Button unten, um deine E-Mail zu best\u00e4tigen und deine Anfrage zu aktivieren. Dabei wird auch dein {app_name}-Konto erstellt, damit du dein Projekt verfolgen kannst.",
"email_quote_verify_btn": "Best\u00e4tigen & Angebot aktivieren",
"email_quote_verify_expires": "Dieser Link l\u00e4uft in 60 Minuten ab.",
"email_quote_verify_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
"email_quote_verify_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
"email_quote_verify_subject": "Best\u00e4tige deine E-Mail f\u00fcr Anbieter-Angebote",
"email_welcome_heading": "Willkommen bei {app_name}!",
"email_welcome_body": "Danke f\u00fcr deine Anmeldung. Du kannst jetzt mit der Planung deines Padel-Gesch\u00e4fts loslegen.",
"email_welcome_btn": "Zum Dashboard",
"email_welcome_subject": "Willkommen bei {app_name}",
"email_waitlist_supplier_heading": "Du stehst auf der Anbieter-Warteliste",
"email_waitlist_supplier_body": "Danke f\u00fcr dein Interesse am <strong>{plan_name}</strong>-Plan. Wir bauen die ultimative Anbieter-Plattform f\u00fcr Padel-Unternehmer.",
"email_waitlist_supplier_perks": "Du erf\u00e4hrst als Erster, wenn wir starten. Wir senden dir fr\u00fchen Zugang, exklusive Launch-Preise und Onboarding-Unterst\u00fctzung.",
"email_waitlist_supplier_meanwhile": "In der Zwischenzeit erkunde unsere kostenlosen Ressourcen:",
"email_waitlist_supplier_link_planner": "Finanzplanungstool \u2014 plane deine Padel-Anlage",
"email_waitlist_supplier_link_directory": "Anbieterverzeichnis \u2014 verifizierte Anbieter durchsuchen",
"email_waitlist_supplier_subject": "Du stehst auf der Liste \u2014 {app_name} {plan_name} startet bald",
"email_waitlist_general_heading": "Du stehst auf der Warteliste",
"email_waitlist_general_body": "Danke, dass du dich auf die Warteliste eingetragen hast. Wir bereiten den Start der ultimativen Planungsplattform f\u00fcr Padel-Unternehmer vor.",
"email_waitlist_general_perks_intro": "Du bist unter den Ersten, die Zugang erhalten. Wir senden dir:",
"email_waitlist_general_perk_1": "Fr\u00fchen Zugang zur gesamten Plattform",
"email_waitlist_general_perk_2": "Exklusive Launch-Boni",
"email_waitlist_general_perk_3": "Priorit\u00e4ts-Onboarding und Support",
"email_waitlist_general_outro": "Wir melden uns bald.",
"email_waitlist_general_subject": "Du stehst auf der Liste \u2014 {app_name} startet bald",
"email_lead_forward_heading": "Neues Projekt-Lead",
"email_lead_forward_subheading": "Ein neues Padel-Projekt passt zu deinen Leistungen.",
"email_lead_forward_section_brief": "Projektbeschreibung",
"email_lead_forward_section_contact": "Kontakt",
"email_lead_forward_lbl_facility": "Anlage",
"email_lead_forward_lbl_courts": "Pl\u00e4tze",
"email_lead_forward_lbl_location": "Standort",
"email_lead_forward_lbl_timeline": "Zeitplan",
"email_lead_forward_lbl_phase": "Phase",
"email_lead_forward_lbl_services": "Leistungen",
"email_lead_forward_lbl_additional": "Zus\u00e4tzlich",
"email_lead_forward_lbl_name": "Name",
"email_lead_forward_lbl_email": "E-Mail",
"email_lead_forward_lbl_phone": "Telefon",
"email_lead_forward_lbl_company": "Unternehmen",
"email_lead_forward_lbl_role": "Rolle",
"email_lead_forward_btn": "Im Lead-Feed ansehen",
"email_lead_matched_heading": "Ein Anbieter pr\u00fcft dein Projekt",
"email_lead_matched_greeting": "Hallo {first_name},",
"email_lead_matched_body": "Gute Nachrichten \u2014 ein verifizierter Anbieter wurde mit deinem Padel-Projekt abgeglichen. Er hat deine Projektbeschreibung und wird sich direkt bei dir melden.",
"email_lead_matched_context": "Du hast eine Angebotsanfrage f\u00fcr eine {facility_type}-Anlage mit {court_count} Pl\u00e4tzen in {country} eingereicht.",
"email_lead_matched_btn": "Zum Dashboard",
"email_lead_matched_note": "Du erh\u00e4ltst diese Benachrichtigung jedes Mal, wenn ein neuer Anbieter deine Projektdetails freischaltet.",
"email_lead_matched_subject": "Ein Anbieter pr\u00fcft dein Padel-Projekt",
"email_enquiry_heading": "Neue Anfrage \u00fcber {app_name}",
"email_enquiry_body": "Du hast eine neue Verzeichnisanfrage f\u00fcr <strong>{supplier_name}</strong>.",
"email_enquiry_lbl_from": "Von",
"email_enquiry_lbl_message": "Nachricht",
"email_enquiry_reply": "Antworte direkt an <a href=\"mailto:{contact_email}\">{contact_email}</a>.",
"email_enquiry_subject": "Neue Anfrage \u00fcber {app_name}: {contact_name}",
"email_business_plan_heading": "Dein Businessplan ist fertig",
"email_business_plan_body": "Dein Padel-Businessplan wurde als PDF erstellt und steht zum Download bereit.",
"email_business_plan_btn": "PDF herunterladen",
"email_business_plan_subject": "Dein Padel-Businessplan ist fertig",
"email_footer_tagline": "Die Planungsplattform f\u00fcr Padel-Unternehmer",
"email_footer_copyright": "\u00a9 {year} {app_name}. Du erh\u00e4ltst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast."
}

View File

@@ -1533,5 +1533,83 @@
"bp_lbl_debt": "Debt",
"bp_lbl_cumulative": "Cumulative",
"bp_lbl_disclaimer": "<strong>Disclaimer:</strong> This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. \u00a9 Padelnomics \u2014 padelnomics.io"
"bp_lbl_disclaimer": "<strong>Disclaimer:</strong> This business plan is generated from user-provided assumptions using the Padelnomics financial model. All projections are estimates and do not constitute financial advice. Actual results may vary significantly based on market conditions, execution, and other factors. Consult with financial advisors before making investment decisions. \u00a9 Padelnomics \u2014 padelnomics.io",
"email_magic_link_heading": "Sign in to {app_name}",
"email_magic_link_body": "Click the button below to sign in. This link expires in {expiry_minutes} minutes.",
"email_magic_link_btn": "Sign In",
"email_magic_link_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
"email_magic_link_ignore": "If you didn't request this, you can safely ignore this email.",
"email_magic_link_subject": "Sign in to {app_name}",
"email_quote_verify_heading": "Verify your email to get supplier quotes",
"email_quote_verify_greeting": "Hi {first_name},",
"email_quote_verify_body": "Thanks for requesting quotes{project_desc}. Click the button below to verify your email and activate your quote request. This will also create your {app_name} account so you can track your project.",
"email_quote_verify_btn": "Verify & Activate Quote",
"email_quote_verify_expires": "This link expires in 60 minutes.",
"email_quote_verify_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
"email_quote_verify_ignore": "If you didn't request this, you can safely ignore this email.",
"email_quote_verify_subject": "Verify your email to get supplier quotes",
"email_welcome_heading": "Welcome to {app_name}!",
"email_welcome_body": "Thanks for signing up. You're all set to start planning your padel business.",
"email_welcome_btn": "Go to Dashboard",
"email_welcome_subject": "Welcome to {app_name}",
"email_waitlist_supplier_heading": "You're on the Supplier Waitlist",
"email_waitlist_supplier_body": "Thanks for your interest in the <strong>{plan_name}</strong> plan. We're building the ultimate supplier platform for padel entrepreneurs.",
"email_waitlist_supplier_perks": "You'll be among the first to know when we launch. We'll send you early access, exclusive launch pricing, and onboarding support.",
"email_waitlist_supplier_meanwhile": "In the meantime, explore our free resources:",
"email_waitlist_supplier_link_planner": "Financial Planning Tool \u2014 model your padel facility",
"email_waitlist_supplier_link_directory": "Supplier Directory \u2014 browse verified suppliers",
"email_waitlist_supplier_subject": "You're on the list \u2014 {app_name} {plan_name} is launching soon",
"email_waitlist_general_heading": "You're on the Waitlist",
"email_waitlist_general_body": "Thanks for joining the waitlist. We're preparing to launch the ultimate planning platform for padel entrepreneurs.",
"email_waitlist_general_perks_intro": "You'll be among the first to get access when we open. We'll send you:",
"email_waitlist_general_perk_1": "Early access to the full platform",
"email_waitlist_general_perk_2": "Exclusive launch bonuses",
"email_waitlist_general_perk_3": "Priority onboarding and support",
"email_waitlist_general_outro": "We'll be in touch soon.",
"email_waitlist_general_subject": "You're on the list \u2014 {app_name} is launching soon",
"email_lead_forward_heading": "New Project Lead",
"email_lead_forward_subheading": "A new padel project matches your services.",
"email_lead_forward_section_brief": "Project Brief",
"email_lead_forward_section_contact": "Contact",
"email_lead_forward_lbl_facility": "Facility",
"email_lead_forward_lbl_courts": "Courts",
"email_lead_forward_lbl_location": "Location",
"email_lead_forward_lbl_timeline": "Timeline",
"email_lead_forward_lbl_phase": "Phase",
"email_lead_forward_lbl_services": "Services",
"email_lead_forward_lbl_additional": "Additional",
"email_lead_forward_lbl_name": "Name",
"email_lead_forward_lbl_email": "Email",
"email_lead_forward_lbl_phone": "Phone",
"email_lead_forward_lbl_company": "Company",
"email_lead_forward_lbl_role": "Role",
"email_lead_forward_btn": "View in Lead Feed",
"email_lead_matched_heading": "A supplier is reviewing your project",
"email_lead_matched_greeting": "Hi {first_name},",
"email_lead_matched_body": "Great news \u2014 a verified supplier has been matched with your padel project. They have your project brief and will reach out to you directly.",
"email_lead_matched_context": "You submitted a quote request for a {facility_type} facility with {court_count} courts in {country}.",
"email_lead_matched_btn": "View Your Dashboard",
"email_lead_matched_note": "You'll receive this notification each time a new supplier unlocks your project details.",
"email_lead_matched_subject": "A supplier is reviewing your padel project",
"email_enquiry_heading": "New enquiry via {app_name}",
"email_enquiry_body": "You have a new directory enquiry for <strong>{supplier_name}</strong>.",
"email_enquiry_lbl_from": "From",
"email_enquiry_lbl_message": "Message",
"email_enquiry_reply": "Reply directly to <a href=\"mailto:{contact_email}\">{contact_email}</a> to respond.",
"email_enquiry_subject": "New enquiry via {app_name}: {contact_name}",
"email_business_plan_heading": "Your Business Plan is Ready",
"email_business_plan_body": "Your padel business plan PDF has been generated and is ready for download.",
"email_business_plan_btn": "Download PDF",
"email_business_plan_subject": "Your Padel Business Plan PDF is Ready",
"email_footer_tagline": "The padel business planning platform",
"email_footer_copyright": "\u00a9 {year} {app_name}. You received this email because you have an account or submitted a request."
}

View File

@@ -1,6 +1,7 @@
"""
Planner domain: padel court financial planner + scenario management.
"""
import json
import math
from datetime import datetime
@@ -44,6 +45,7 @@ COUNTRY_PRESETS = {
# SQL Queries
# =============================================================================
async def count_scenarios(user_id: int) -> int:
row = await fetch_one(
"SELECT COUNT(*) as cnt FROM scenarios WHERE user_id = ? AND deleted_at IS NULL",
@@ -70,6 +72,7 @@ async def get_scenarios(user_id: int) -> list[dict]:
# Helpers
# =============================================================================
def form_to_state(form) -> dict:
"""Convert Quart ImmutableMultiDict form data to state dict."""
data: dict = {}
@@ -88,16 +91,37 @@ def form_to_state(form) -> dict:
def augment_d(d: dict, s: dict, lang: str) -> None:
"""Add display-only derived fields to calc result dict (mutates d in-place)."""
t = get_translations(lang)
month_keys = ["jan", "feb", "mar", "apr", "may", "jun",
"jul", "aug", "sep", "oct", "nov", "dec"]
month_keys = [
"jan",
"feb",
"mar",
"apr",
"may",
"jun",
"jul",
"aug",
"sep",
"oct",
"nov",
"dec",
]
d["irr_ok"] = math.isfinite(d.get("irr", 0))
# Chart data — full Chart.js 4.x config objects, embedded as JSON in partials
_PALETTE = [
"#1D4ED8", "#16A34A", "#D97706", "#EF4444", "#8B5CF6",
"#EC4899", "#06B6D4", "#84CC16", "#F97316", "#475569",
"#0EA5E9", "#A78BFA",
"#1D4ED8",
"#16A34A",
"#D97706",
"#EF4444",
"#8B5CF6",
"#EC4899",
"#06B6D4",
"#84CC16",
"#F97316",
"#475569",
"#0EA5E9",
"#A78BFA",
]
_cap_items = sorted(
[i for i in d["capexItems"] if i["amount"] > 0],
@@ -108,17 +132,26 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
"type": "doughnut",
"data": {
"labels": [i["name"] for i in _cap_items],
"datasets": [{
"data": [i["amount"] for i in _cap_items],
"backgroundColor": [_PALETTE[i % len(_PALETTE)] for i in range(len(_cap_items))],
"borderWidth": 0,
}],
"datasets": [
{
"data": [i["amount"] for i in _cap_items],
"backgroundColor": [
_PALETTE[i % len(_PALETTE)] for i in range(len(_cap_items))
],
"borderWidth": 0,
}
],
},
"options": {
"responsive": True,
"maintainAspectRatio": False,
"cutout": "60%",
"plugins": {"legend": {"position": "right", "labels": {"boxWidth": 10, "font": {"size": 12}, "padding": 8}}},
"plugins": {
"legend": {
"position": "right",
"labels": {"boxWidth": 10, "font": {"size": 12}, "padding": 8},
}
},
},
}
@@ -153,35 +186,60 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
"options": {
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": True, "labels": {"boxWidth": 12, "font": {"size": 10}}}},
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
"plugins": {
"legend": {"display": True, "labels": {"boxWidth": 12, "font": {"size": 10}}}
},
"scales": {
"y": {"ticks": {"font": {"size": 10}}},
"x": {"ticks": {"font": {"size": 9}}},
},
},
}
_pl_values = [
round(d["courtRevMonth"]),
-round(d["feeDeduction"]),
round(d["racketRev"] + d["ballMargin"] + d["membershipRev"]
+ d["fbRev"] + d["coachingRev"] + d["retailRev"]),
round(
d["racketRev"]
+ d["ballMargin"]
+ d["membershipRev"]
+ d["fbRev"]
+ d["coachingRev"]
+ d["retailRev"]
),
-round(d["opex"]),
-round(d["monthlyPayment"]),
]
d["pl_chart"] = {
"type": "bar",
"data": {
"labels": [t["chart_court_rev"], t["chart_fees"], t["chart_ancillary"], t["chart_opex"], t["chart_debt"]],
"datasets": [{
"data": _pl_values,
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)" for v in _pl_values],
"borderRadius": 4,
}],
"labels": [
t["chart_court_rev"],
t["chart_fees"],
t["chart_ancillary"],
t["chart_opex"],
t["chart_debt"],
],
"datasets": [
{
"data": _pl_values,
"backgroundColor": [
"rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)"
for v in _pl_values
],
"borderRadius": 4,
}
],
},
"options": {
"indexAxis": "y",
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": False}},
"scales": {"x": {"ticks": {"font": {"size": 9}}}, "y": {"ticks": {"font": {"size": 10}}}},
"scales": {
"x": {"ticks": {"font": {"size": 9}}},
"y": {"ticks": {"font": {"size": 10}}},
},
},
}
@@ -190,17 +248,25 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
"type": "bar",
"data": {
"labels": [f"Y{m['yr']}" if m["m"] % 12 == 1 else "" for m in d["months"]],
"datasets": [{
"data": _cf_values,
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)" for v in _cf_values],
"borderRadius": 2,
}],
"datasets": [
{
"data": _cf_values,
"backgroundColor": [
"rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)"
for v in _cf_values
],
"borderRadius": 2,
}
],
},
"options": {
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": False}},
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
"scales": {
"y": {"ticks": {"font": {"size": 10}}},
"x": {"ticks": {"font": {"size": 9}}},
},
},
}
@@ -208,21 +274,26 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
"type": "line",
"data": {
"labels": [f"M{m['m']}" if m["m"] % 6 == 1 else "" for m in d["months"]],
"datasets": [{
"data": [round(m["cum"]) for m in d["months"]],
"borderColor": "#1D4ED8",
"backgroundColor": "rgba(29,78,216,0.08)",
"fill": True,
"tension": 0.3,
"pointRadius": 0,
"borderWidth": 2,
}],
"datasets": [
{
"data": [round(m["cum"]) for m in d["months"]],
"borderColor": "#1D4ED8",
"backgroundColor": "rgba(29,78,216,0.08)",
"fill": True,
"tension": 0.3,
"pointRadius": 0,
"borderWidth": 2,
}
],
},
"options": {
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": False}},
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
"scales": {
"y": {"ticks": {"font": {"size": 10}}},
"x": {"ticks": {"font": {"size": 9}}},
},
},
}
@@ -231,17 +302,25 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
"type": "bar",
"data": {
"labels": [f"Y{x['year']}" for x in d["dscr"]],
"datasets": [{
"data": _dscr_values,
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 1.2 else "rgba(239,68,68,0.7)" for v in _dscr_values],
"borderRadius": 4,
}],
"datasets": [
{
"data": _dscr_values,
"backgroundColor": [
"rgba(22,163,74,0.7)" if v >= 1.2 else "rgba(239,68,68,0.7)"
for v in _dscr_values
],
"borderRadius": 4,
}
],
},
"options": {
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": False}},
"scales": {"y": {"ticks": {"font": {"size": 10}}, "min": 0}, "x": {"ticks": {"font": {"size": 10}}}},
"scales": {
"y": {"ticks": {"font": {"size": 10}}, "min": 0},
"x": {"ticks": {"font": {"size": 10}}},
},
},
}
@@ -249,17 +328,22 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
"type": "bar",
"data": {
"labels": [t[f"month_{k}"] for k in month_keys],
"datasets": [{
"data": [v * 100 for v in s["season"]],
"backgroundColor": "rgba(29,78,216,0.6)",
"borderRadius": 3,
}],
"datasets": [
{
"data": [v * 100 for v in s["season"]],
"backgroundColor": "rgba(29,78,216,0.6)",
"borderRadius": 3,
}
],
},
"options": {
"responsive": True,
"maintainAspectRatio": False,
"plugins": {"legend": {"display": False}},
"scales": {"y": {"ticks": {"font": {"size": 10}}, "min": 0}, "x": {"ticks": {"font": {"size": 10}}}},
"scales": {
"y": {"ticks": {"font": {"size": 10}}, "min": 0},
"x": {"ticks": {"font": {"size": 10}}},
},
},
}
@@ -273,25 +357,35 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
)
utils = [15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70]
ancillary_per_court = (
s["membershipRevPerCourt"] + s["fbRevPerCourt"]
+ s["coachingRevPerCourt"] + s["retailRevPerCourt"]
s["membershipRevPerCourt"]
+ s["fbRevPerCourt"]
+ s["coachingRevPerCourt"]
+ s["retailRevPerCourt"]
)
sens_rows = []
for u in utils:
booked = d["availHoursMonth"] * (u / 100)
rev = booked * rev_per_hr + d["totalCourts"] * ancillary_per_court * (u / max(s["utilTarget"], 1))
rev = booked * rev_per_hr + d["totalCourts"] * ancillary_per_court * (
u / max(s["utilTarget"], 1)
)
ncf = rev - d["opex"] - d["monthlyPayment"]
annual = ncf * (12 if is_in else 6)
ebitda = rev - d["opex"]
dscr = (ebitda * (12 if is_in else 6)) / d["annualDebtService"] if d["annualDebtService"] > 0 else 999
sens_rows.append({
"util": u,
"rev": round(rev),
"ncf": round(ncf),
"annual": round(annual),
"dscr": min(dscr, 99),
"is_target": u == s["utilTarget"],
})
dscr = (
(ebitda * (12 if is_in else 6)) / d["annualDebtService"]
if d["annualDebtService"] > 0
else 999
)
sens_rows.append(
{
"util": u,
"rev": round(rev),
"ncf": round(ncf),
"annual": round(annual),
"dscr": min(dscr, 99),
"is_target": u == s["utilTarget"],
}
)
d["sens_rows"] = sens_rows
prices = [-20, -10, -5, 0, 5, 10, 15, 20]
@@ -301,18 +395,23 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
booked = d["bookedHoursMonth"]
rev = (
booked * adj_rate * (1 - s["bookingFee"] / 100)
+ booked * ((s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
+ (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"]))
+ booked
* (
(s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
+ (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"])
)
+ d["totalCourts"] * ancillary_per_court
)
ncf = rev - d["opex"] - d["monthlyPayment"]
price_rows.append({
"delta": delta,
"adj_rate": round(adj_rate),
"rev": round(rev),
"ncf": round(ncf),
"is_base": delta == 0,
})
price_rows.append(
{
"delta": delta,
"adj_rate": round(adj_rate),
"rev": round(rev),
"ncf": round(ncf),
"is_base": delta == 0,
}
)
d["price_rows"] = price_rows
@@ -320,6 +419,7 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
# Routes
# =============================================================================
@bp.route("/")
async def index():
scenario_count = 0
@@ -420,14 +520,18 @@ async def save_scenario():
# Add to Resend nurture audience on first scenario save
if is_first_save:
from ..core import config as _config
if _config.RESEND_AUDIENCE_PLANNER and _config.RESEND_API_KEY:
try:
import resend
resend.api_key = _config.RESEND_API_KEY
resend.Contacts.create({
"audience_id": _config.RESEND_AUDIENCE_PLANNER,
"email": g.user["email"],
})
resend.Contacts.create(
{
"audience_id": _config.RESEND_AUDIENCE_PLANNER,
"email": g.user["email"],
}
)
except Exception as e:
print(f"[NURTURE] Failed to add {g.user['email']} to audience: {e}")
@@ -445,7 +549,14 @@ async def get_scenario(scenario_id: int):
)
if not row:
return jsonify({"error": "Not found"}), 404
return jsonify({"id": row["id"], "name": row["name"], "state_json": row["state_json"], "location": row["location"]})
return jsonify(
{
"id": row["id"],
"name": row["name"],
"state_json": row["state_json"],
"location": row["location"],
}
)
@bp.route("/scenarios/<int:scenario_id>", methods=["DELETE"])
@@ -482,6 +593,7 @@ async def set_default(scenario_id: int):
# Business Plan PDF Export
# =============================================================================
@bp.route("/export")
@login_required
@waitlist_gate("export_waitlist.html")
@@ -526,17 +638,19 @@ async def export_checkout():
if not price_id:
return jsonify({"error": "Product not configured. Contact support."}), 500
return jsonify({
"items": [{"priceId": price_id, "quantity": 1}],
"customData": {
"user_id": str(g.user["id"]),
"scenario_id": str(scenario_id),
"language": language,
},
"settings": {
"successUrl": f"{config.BASE_URL}/planner/export/success",
},
})
return jsonify(
{
"items": [{"priceId": price_id, "quantity": 1}],
"customData": {
"user_id": str(g.user["id"]),
"scenario_id": str(scenario_id),
"language": language,
},
"settings": {
"successUrl": f"{config.BASE_URL}/planner/export/success",
},
}
)
@bp.route("/export/success")
@@ -569,6 +683,7 @@ async def export_download(token: str):
# Serve the PDF file
from pathlib import Path
file_path = Path(export["file_path"])
if not file_path.exists():
return jsonify({"error": "PDF file not found."}), 404
@@ -587,6 +702,7 @@ async def export_download(token: str):
# DuckDB analytics integration — market data for planner pre-fill
# =============================================================================
@bp.route("/api/market-data")
async def market_data():
"""Return per-city planner defaults from DuckDB serving layer.
@@ -614,27 +730,26 @@ async def market_data():
# Map DuckDB snake_case columns → DEFAULTS camelCase keys.
# Only include fields that exist in the row and have non-null values.
col_map: dict[str, str] = {
"rate_peak": "ratePeak",
"rate_off_peak": "rateOffPeak",
"court_cost_dbl": "courtCostDbl",
"court_cost_sgl": "courtCostSgl",
"rent_sqm": "rentSqm",
"insurance": "insurance",
"electricity": "electricity",
"maintenance": "maintenance",
"marketing": "marketing",
"rate_peak": "ratePeak",
"rate_off_peak": "rateOffPeak",
"avg_utilisation_pct": "utilTarget",
"courts_typical": "dblCourts",
}
overrides: dict = {}
for col, key in col_map.items():
val = row.get(col)
if val is not None:
overrides[key] = round(float(val))
overrides[key] = round(float(val), 2)
# Include data quality metadata so frontend can show confidence indicator
if row.get("data_confidence") is not None:
overrides["_dataConfidence"] = round(float(row["data_confidence"]), 2)
if row.get("data_source"):
overrides["_dataSource"] = row["data_source"]
if row.get("country_code"):
overrides["_countryCode"] = row["country_code"]
if row.get("price_currency"):
overrides["_currency"] = row["price_currency"]
return jsonify(overrides), 200

View File

@@ -538,15 +538,17 @@ async def unlock_lead(token: str):
# Enqueue lead forward email
from ..worker import enqueue
lang = g.get("lang", "en")
await enqueue("send_lead_forward_email", {
"lead_id": lead_id,
"supplier_id": supplier["id"],
"lang": lang,
})
# Notify entrepreneur on first unlock
lead = result["lead"]
if lead.get("unlock_count", 0) <= 1:
await enqueue("send_lead_matched_notification", {"lead_id": lead_id})
await enqueue("send_lead_matched_notification", {"lead_id": lead_id, "lang": lang})
# Return full details card
full_lead = await fetch_one("SELECT * FROM lead_requests WHERE id = ?", (lead_id,))

View File

@@ -7,17 +7,30 @@ import traceback
from datetime import datetime, timedelta
from .core import EMAIL_ADDRESSES, config, execute, fetch_all, fetch_one, init_db, send_email
from .i18n import get_translations
# Task handlers registry
HANDLERS: dict[str, callable] = {}
def _email_wrap(body: str) -> str:
def _t(key: str, lang: str = "en", **kwargs) -> str:
"""Look up an email translation key, interpolating {placeholders}.
Falls back to English if key is missing for the requested language.
"""
translations = get_translations(lang)
raw = translations.get(key, get_translations("en").get(key, key))
return raw.format(**kwargs) if kwargs else raw
def _email_wrap(body: str, lang: str = "en") -> str:
"""Wrap email body in a branded layout with inline CSS."""
year = datetime.utcnow().year
tagline = _t("email_footer_tagline", lang)
copyright_text = _t("email_footer_copyright", lang, year=year, app_name=config.APP_NAME)
return f"""\
<!DOCTYPE html>
<html lang="en">
<html lang="{lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
@@ -56,10 +69,10 @@ def _email_wrap(body: str) -> str:
<p style="margin:0 0 6px;font-size:12px;color:#94A3B8;text-align:center;">
<a href="{config.BASE_URL}" style="color:#64748B;text-decoration:none;font-weight:500;">{config.APP_NAME}</a>
&nbsp;&middot;&nbsp;
The padel business planning platform
{tagline}
</p>
<p style="margin:0;font-size:11px;color:#CBD5E1;text-align:center;">
&copy; {year} {config.APP_NAME}. You received this email because you have an account or submitted a request.
{copyright_text}
</p>
</td></tr>
@@ -174,6 +187,7 @@ async def handle_send_email(payload: dict) -> None:
@task("send_magic_link")
async def handle_send_magic_link(payload: dict) -> None:
"""Send magic link email."""
lang = payload.get("lang", "en")
link = f"{config.BASE_URL}/auth/verify?token={payload['token']}"
if config.DEBUG:
@@ -183,19 +197,18 @@ async def handle_send_magic_link(payload: dict) -> None:
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 "
f"{config.MAGIC_LINK_EXPIRY_MINUTES} minutes.</p>"
f"{_email_button(link, 'Sign In')}"
f'<p style="font-size:13px;color:#94A3B8;">If the button doesn\'t work, copy and paste this URL into your browser:</p>'
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}</h2>'
f'<p>{_t("email_magic_link_body", lang, expiry_minutes=config.MAGIC_LINK_EXPIRY_MINUTES)}</p>'
f'{_email_button(link, _t("email_magic_link_btn", lang))}'
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_fallback", lang)}</p>'
f'<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{link}</p>'
f'<p style="font-size:13px;color:#94A3B8;">If you didn\'t request this, you can safely ignore this email.</p>'
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_magic_link_ignore", lang)}</p>'
)
await send_email(
to=payload["email"],
subject=f"Sign in to {config.APP_NAME}",
html=_email_wrap(body),
subject=_t("email_magic_link_subject", lang, app_name=config.APP_NAME),
html=_email_wrap(body, lang),
from_addr=EMAIL_ADDRESSES["transactional"],
)
@@ -228,22 +241,20 @@ async def handle_send_quote_verification(payload: dict) -> None:
project_desc = f" for your {' '.join(parts)} project"
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Verify your email to get supplier quotes</h2>'
f"<p>Hi {first_name},</p>"
f"<p>Thanks for requesting quotes{project_desc}. "
f"Click the button below to verify your email and activate your quote request. "
f"This will also create your {config.APP_NAME} account so you can track your project.</p>"
f"{_email_button(link, 'Verify & Activate Quote')}"
f'<p style="font-size:13px;color:#94A3B8;">This link expires in 60 minutes.</p>'
f'<p style="font-size:13px;color:#94A3B8;">If the button doesn\'t work, copy and paste this URL into your browser:</p>'
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_quote_verify_heading", lang)}</h2>'
f'<p>{_t("email_quote_verify_greeting", lang, first_name=first_name)}</p>'
f'<p>{_t("email_quote_verify_body", lang, project_desc=project_desc, app_name=config.APP_NAME)}</p>'
f'{_email_button(link, _t("email_quote_verify_btn", lang))}'
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_expires", lang)}</p>'
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_fallback", lang)}</p>'
f'<p style="font-size:13px;color:#94A3B8;word-break:break-all;">{link}</p>'
f'<p style="font-size:13px;color:#94A3B8;">If you didn\'t request this, you can safely ignore this email.</p>'
f'<p style="font-size:13px;color:#94A3B8;">{_t("email_quote_verify_ignore", lang)}</p>'
)
await send_email(
to=payload["email"],
subject="Verify your email to get supplier quotes",
html=_email_wrap(body),
subject=_t("email_quote_verify_subject", lang),
html=_email_wrap(body, lang),
from_addr=EMAIL_ADDRESSES["transactional"],
)
@@ -251,16 +262,17 @@ async def handle_send_quote_verification(payload: dict) -> None:
@task("send_welcome")
async def handle_send_welcome(payload: dict) -> None:
"""Send welcome email to new user."""
lang = payload.get("lang", "en")
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Welcome to {config.APP_NAME}!</h2>'
f"<p>Thanks for signing up. You're all set to start planning your padel business.</p>"
f'{_email_button(f"{config.BASE_URL}/dashboard", "Go to Dashboard")}'
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_welcome_heading", lang, app_name=config.APP_NAME)}</h2>'
f'<p>{_t("email_welcome_body", lang)}</p>'
f'{_email_button(f"{config.BASE_URL}/dashboard", _t("email_welcome_btn", lang))}'
)
await send_email(
to=payload["email"],
subject=f"Welcome to {config.APP_NAME}",
html=_email_wrap(body),
subject=_t("email_welcome_subject", lang, app_name=config.APP_NAME),
html=_email_wrap(body, lang),
from_addr=EMAIL_ADDRESSES["transactional"],
)
@@ -269,45 +281,40 @@ async def handle_send_welcome(payload: dict) -> None:
async def handle_send_waitlist_confirmation(payload: dict) -> None:
"""Send waitlist confirmation email."""
intent = payload.get("intent", "signup")
lang = payload.get("lang", "en")
email = payload["email"]
if intent.startswith("supplier_"):
# Supplier waitlist
plan_name = intent.replace("supplier_", "").title()
subject = f"You're on the list — {config.APP_NAME} {plan_name} is launching soon"
subject = _t("email_waitlist_supplier_subject", lang, app_name=config.APP_NAME, plan_name=plan_name)
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">You\'re on the Supplier Waitlist</h2>'
f'<p>Thanks for your interest in the <strong>{plan_name}</strong> plan. '
f'We\'re building the ultimate supplier platform for padel entrepreneurs.</p>'
f'<p>You\'ll be among the first to know when we launch. '
f'We\'ll send you early access, exclusive launch pricing, and onboarding support.</p>'
f'<p style="font-size:13px;color:#64748B;">In the meantime, explore our free resources:</p>'
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_waitlist_supplier_heading", lang)}</h2>'
f'<p>{_t("email_waitlist_supplier_body", lang, plan_name=plan_name)}</p>'
f'<p>{_t("email_waitlist_supplier_perks", lang)}</p>'
f'<p style="font-size:13px;color:#64748B;">{_t("email_waitlist_supplier_meanwhile", lang)}</p>'
f'<ul style="font-size:13px;color:#64748B;">'
f'<li><a href="{config.BASE_URL}/planner">Financial Planning Tool</a> — model your padel facility</li>'
f'<li><a href="{config.BASE_URL}/directory">Supplier Directory</a> — browse verified suppliers</li>'
f'<li><a href="{config.BASE_URL}/planner">{_t("email_waitlist_supplier_link_planner", lang)}</a></li>'
f'<li><a href="{config.BASE_URL}/directory">{_t("email_waitlist_supplier_link_directory", lang)}</a></li>'
f'</ul>'
)
else:
# Entrepreneur/demand-side waitlist
subject = f"You're on the list — {config.APP_NAME} is launching soon"
subject = _t("email_waitlist_general_subject", lang, app_name=config.APP_NAME)
body = (
'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">You\'re on the Waitlist</h2>'
'<p>Thanks for joining the waitlist. We\'re preparing to launch the ultimate planning platform '
'for padel entrepreneurs.</p>'
'<p>You\'ll be among the first to get access when we open. '
'We\'ll send you:</p>'
'<ul style="font-size:14px;color:#1E293B;margin:16px 0;">'
'<li>Early access to the full platform</li>'
'<li>Exclusive launch bonuses</li>'
'<li>Priority onboarding and support</li>'
'</ul>'
'<p style="font-size:13px;color:#64748B;">We\'ll be in touch soon.</p>'
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_waitlist_general_heading", lang)}</h2>'
f'<p>{_t("email_waitlist_general_body", lang)}</p>'
f'<p>{_t("email_waitlist_general_perks_intro", lang)}</p>'
f'<ul style="font-size:14px;color:#1E293B;margin:16px 0;">'
f'<li>{_t("email_waitlist_general_perk_1", lang)}</li>'
f'<li>{_t("email_waitlist_general_perk_2", lang)}</li>'
f'<li>{_t("email_waitlist_general_perk_3", lang)}</li>'
f'</ul>'
f'<p style="font-size:13px;color:#64748B;">{_t("email_waitlist_general_outro", lang)}</p>'
)
await send_email(
to=email,
subject=subject,
html=_email_wrap(body),
html=_email_wrap(body, lang),
from_addr=EMAIL_ADDRESSES["transactional"],
)
@@ -331,6 +338,7 @@ async def handle_cleanup_rate_limits(payload: dict) -> None:
@task("send_lead_forward_email")
async def handle_send_lead_forward_email(payload: dict) -> None:
"""Send full project brief to supplier who unlocked/was forwarded a lead."""
lang = payload.get("lang", "en")
lead_id = payload["lead_id"]
supplier_id = payload["supplier_id"]
@@ -346,14 +354,16 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
subject = f"[{heat}] New padel project in {country}{courts} courts, €{budget}"
t = lambda key: _t(key, lang) # noqa: E731
brief_rows = [
("Facility", f"{lead['facility_type'] or '-'} ({lead['build_context'] or '-'})"),
("Courts", f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"),
("Location", f"{lead['location'] or '-'}, {country}"),
("Timeline", f"{lead['timeline'] or '-'} | Budget: €{budget}"),
("Phase", f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"),
("Services", lead["services_needed"] or "-"),
("Additional", lead["additional_info"] or "-"),
(t("email_lead_forward_lbl_facility"), f"{lead['facility_type'] or '-'} ({lead['build_context'] or '-'})"),
(t("email_lead_forward_lbl_courts"), f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"),
(t("email_lead_forward_lbl_location"), f"{lead['location'] or '-'}, {country}"),
(t("email_lead_forward_lbl_timeline"), f"{lead['timeline'] or '-'} | Budget: €{budget}"),
(t("email_lead_forward_lbl_phase"), f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"),
(t("email_lead_forward_lbl_services"), lead["services_needed"] or "-"),
(t("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
]
brief_html = ""
@@ -364,11 +374,11 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
)
contact_rows = [
("Name", lead["contact_name"] or "-"),
("Email", lead["contact_email"] or "-"),
("Phone", lead["contact_phone"] or "-"),
("Company", lead["contact_company"] or "-"),
("Role", lead["stakeholder_type"] or "-"),
(t("email_lead_forward_lbl_name"), lead["contact_name"] or "-"),
(t("email_lead_forward_lbl_email"), lead["contact_email"] or "-"),
(t("email_lead_forward_lbl_phone"), lead["contact_phone"] or "-"),
(t("email_lead_forward_lbl_company"), lead["contact_company"] or "-"),
(t("email_lead_forward_lbl_role"), lead["stakeholder_type"] or "-"),
]
contact_html = ""
@@ -379,13 +389,13 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
)
body = (
f'<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">New Project Lead</h2>'
f'<p style="font-size:13px;color:#64748B;margin:0 0 16px">A new padel project matches your services.</p>'
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">Project Brief</h3>'
f'<h2 style="margin:0 0 4px;color:#0F172A;font-size:18px;">{t("email_lead_forward_heading")}</h2>'
f'<p style="font-size:13px;color:#64748B;margin:0 0 16px">{t("email_lead_forward_subheading")}</p>'
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{t("email_lead_forward_section_brief")}</h3>'
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{brief_html}</table>'
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">Contact</h3>'
f'<h3 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#94A3B8;margin:0 0 8px">{t("email_lead_forward_section_contact")}</h3>'
f'<table cellpadding="0" cellspacing="0" style="margin-bottom:20px">{contact_html}</table>'
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", "View in Lead Feed")}'
f'{_email_button(f"{config.BASE_URL}/suppliers/leads", t("email_lead_forward_btn"))}'
)
# Send to supplier contact email or general contact
@@ -397,12 +407,11 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
await send_email(
to=to_email,
subject=subject,
html=_email_wrap(body),
html=_email_wrap(body, lang),
from_addr=EMAIL_ADDRESSES["leads"],
)
# Update email_sent_at on lead_forward
from datetime import datetime
now = datetime.utcnow().isoformat()
await execute(
"UPDATE lead_forwards SET email_sent_at = ? WHERE lead_id = ? AND supplier_id = ?",
@@ -413,6 +422,7 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
@task("send_lead_matched_notification")
async def handle_send_lead_matched_notification(payload: dict) -> None:
"""Notify the entrepreneur that a supplier has been matched to their project."""
lang = payload.get("lang", "en")
lead_id = payload["lead_id"]
lead = await fetch_one("SELECT * FROM lead_requests WHERE id = ?", (lead_id,))
if not lead or not lead["contact_email"]:
@@ -421,22 +431,18 @@ async def handle_send_lead_matched_notification(payload: dict) -> None:
first_name = (lead["contact_name"] or "").split()[0] if lead.get("contact_name") else "there"
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">A supplier is reviewing your project</h2>'
f'<p>Hi {first_name},</p>'
f'<p>Great news — a verified supplier has been matched with your padel project. '
f'They have your project brief and will reach out to you directly.</p>'
f'<p style="font-size:13px;color:#64748B;">You submitted a quote request for a '
f'{lead["facility_type"] or "padel"} facility with {lead["court_count"] or "?"} courts '
f'in {lead["country"] or "your area"}.</p>'
f'{_email_button(f"{config.BASE_URL}/dashboard", "View Your Dashboard")}'
f'<p style="font-size:12px;color:#94A3B8;">You\'ll receive this notification each time '
f'a new supplier unlocks your project details.</p>'
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_lead_matched_heading", lang)}</h2>'
f'<p>{_t("email_lead_matched_greeting", lang, first_name=first_name)}</p>'
f'<p>{_t("email_lead_matched_body", lang)}</p>'
f'<p style="font-size:13px;color:#64748B;">{_t("email_lead_matched_context", lang, facility_type=lead["facility_type"] or "padel", court_count=lead["court_count"] or "?", country=lead["country"] or "your area")}</p>'
f'{_email_button(f"{config.BASE_URL}/dashboard", _t("email_lead_matched_btn", lang))}'
f'<p style="font-size:12px;color:#94A3B8;">{_t("email_lead_matched_note", lang)}</p>'
)
await send_email(
to=lead["contact_email"],
subject="A supplier is reviewing your padel project",
html=_email_wrap(body),
subject=_t("email_lead_matched_subject", lang),
html=_email_wrap(body, lang),
from_addr=EMAIL_ADDRESSES["leads"],
)
@@ -444,6 +450,7 @@ async def handle_send_lead_matched_notification(payload: dict) -> None:
@task("send_supplier_enquiry_email")
async def handle_send_supplier_enquiry_email(payload: dict) -> None:
"""Relay a directory enquiry form submission to the supplier's contact email."""
lang = payload.get("lang", "en")
supplier_email = payload.get("supplier_email", "")
if not supplier_email:
return
@@ -455,22 +462,21 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None:
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">'
f'New enquiry via {config.APP_NAME}</h2>'
f'<p>You have a new directory enquiry for <strong>{supplier_name}</strong>.</p>'
f'{_t("email_enquiry_heading", lang, app_name=config.APP_NAME)}</h2>'
f'<p>{_t("email_enquiry_body", lang, supplier_name=supplier_name)}</p>'
f'<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">'
f'<tr><td style="padding:6px 0;color:#64748B;width:120px">From</td>'
f'<tr><td style="padding:6px 0;color:#64748B;width:120px">{_t("email_enquiry_lbl_from", lang)}</td>'
f'<td style="padding:6px 0"><strong>{contact_name}</strong> &lt;{contact_email}&gt;</td></tr>'
f'<tr><td style="padding:6px 0;color:#64748B;vertical-align:top">Message</td>'
f'<tr><td style="padding:6px 0;color:#64748B;vertical-align:top">{_t("email_enquiry_lbl_message", lang)}</td>'
f'<td style="padding:6px 0;white-space:pre-wrap">{message}</td></tr>'
f'</table>'
f'<p style="font-size:13px;color:#64748B;">Reply directly to <a href="mailto:{contact_email}">'
f'{contact_email}</a> to respond.</p>'
f'<p style="font-size:13px;color:#64748B;">{_t("email_enquiry_reply", lang, contact_email=contact_email)}</p>'
)
await send_email(
to=supplier_email,
subject=f"New enquiry via {config.APP_NAME}: {contact_name}",
html=_email_wrap(body),
subject=_t("email_enquiry_subject", lang, app_name=config.APP_NAME, contact_name=contact_name),
html=_email_wrap(body, lang),
from_addr=EMAIL_ADDRESSES["transactional"],
)
@@ -533,14 +539,14 @@ async def handle_generate_business_plan(payload: dict) -> None:
user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,))
if user:
body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Your Business Plan is Ready</h2>'
f"<p>Your padel business plan PDF has been generated and is ready for download.</p>"
f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", "Download PDF")}'
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_business_plan_heading", language)}</h2>'
f'<p>{_t("email_business_plan_body", language)}</p>'
f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", _t("email_business_plan_btn", language))}'
)
await send_email(
to=user["email"],
subject="Your Padel Business Plan PDF is Ready",
html=_email_wrap(body),
subject=_t("email_business_plan_subject", language),
html=_email_wrap(body, language),
from_addr=EMAIL_ADDRESSES["transactional"],
)