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:
47
CHANGELOG.md
47
CHANGELOG.md
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,10 +12,40 @@ MODEL (
|
||||
grain venue_id
|
||||
);
|
||||
|
||||
WITH all_venues AS (
|
||||
WITH playtomic_venues AS (
|
||||
SELECT
|
||||
'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,
|
||||
source,
|
||||
NULL AS tenant_id,
|
||||
'osm' AS source,
|
||||
lat,
|
||||
lon,
|
||||
country_code,
|
||||
@@ -21,40 +53,33 @@ WITH all_venues AS (
|
||||
city,
|
||||
postcode,
|
||||
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,
|
||||
tenant_id,
|
||||
country_code,
|
||||
lat,
|
||||
lon,
|
||||
@@ -67,6 +92,12 @@ SELECT
|
||||
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 (
|
||||
|
||||
@@ -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:00–21: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:00–21: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:00–21: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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
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_venue_density cvd
|
||||
LEFT JOIN country_medians cm ON cvd.country_code = cm.country_code
|
||||
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
|
||||
|
||||
@@ -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
|
||||
44
transform/sqlmesh_padelnomics/models/staging/stg_income.sql
Normal file
44
transform/sqlmesh_padelnomics/models/staging/stg_income.sql
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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,16 +15,40 @@ MODEL (
|
||||
|
||||
WITH parsed AS (
|
||||
SELECT
|
||||
-- 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,
|
||||
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,
|
||||
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 (
|
||||
@@ -37,7 +63,7 @@ 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
|
||||
@@ -47,12 +73,27 @@ deduped AS (
|
||||
SELECT
|
||||
tenant_id,
|
||||
'playtomic' AS source,
|
||||
lat, lon,
|
||||
UPPER(country_code) AS country_code,
|
||||
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,
|
||||
tenant_type,
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": [{
|
||||
"datasets": [
|
||||
{
|
||||
"data": [i["amount"] for i in _cap_items],
|
||||
"backgroundColor": [_PALETTE[i % len(_PALETTE)] for i in range(len(_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": [{
|
||||
"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],
|
||||
"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": [{
|
||||
"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],
|
||||
"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,7 +274,8 @@ 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": [{
|
||||
"datasets": [
|
||||
{
|
||||
"data": [round(m["cum"]) for m in d["months"]],
|
||||
"borderColor": "#1D4ED8",
|
||||
"backgroundColor": "rgba(29,78,216,0.08)",
|
||||
@@ -216,13 +283,17 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"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": [{
|
||||
"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],
|
||||
"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": [{
|
||||
"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({
|
||||
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({
|
||||
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({
|
||||
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,7 +638,8 @@ async def export_checkout():
|
||||
if not price_id:
|
||||
return jsonify({"error": "Product not configured. Contact support."}), 500
|
||||
|
||||
return jsonify({
|
||||
return jsonify(
|
||||
{
|
||||
"items": [{"priceId": price_id, "quantity": 1}],
|
||||
"customData": {
|
||||
"user_id": str(g.user["id"]),
|
||||
@@ -536,7 +649,8 @@ async def export_checkout():
|
||||
"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.
|
||||
@@ -616,25 +732,24 @@ async def market_data():
|
||||
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",
|
||||
"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
|
||||
|
||||
@@ -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,))
|
||||
|
||||
@@ -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>
|
||||
·
|
||||
The padel business planning platform
|
||||
{tagline}
|
||||
</p>
|
||||
<p style="margin:0;font-size:11px;color:#CBD5E1;text-align:center;">
|
||||
© {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> <{contact_email}></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"],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user