fh-matui 0.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fh_matui/__init__.py +1 -0
- fh_matui/_modidx.py +183 -0
- fh_matui/app_pages.py +291 -0
- fh_matui/components.py +1200 -0
- fh_matui/core.py +230 -0
- fh_matui/datatable.py +718 -0
- fh_matui/foundations.py +59 -0
- fh_matui/web_pages.py +852 -0
- fh_matui-0.9.dist-info/METADATA +101 -0
- fh_matui-0.9.dist-info/RECORD +14 -0
- fh_matui-0.9.dist-info/WHEEL +5 -0
- fh_matui-0.9.dist-info/entry_points.txt +2 -0
- fh_matui-0.9.dist-info/licenses/LICENSE +201 -0
- fh_matui-0.9.dist-info/top_level.txt +1 -0
fh_matui/web_pages.py
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
"""Pre-built landing page components for marketing websites and public-facing pages"""
|
|
2
|
+
|
|
3
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/04_web_pages.ipynb.
|
|
4
|
+
|
|
5
|
+
# %% auto 0
|
|
6
|
+
__all__ = ['SECTION_STYLE', 'SECTION_MARGIN', 'STANDARD_FOOTER_COLUMNS', 'FeatureShowcase', 'FeaturesGrid', 'PricingSection',
|
|
7
|
+
'FAQSection', 'PageFooter', 'LandingNavBar', 'LandingPageSimple', 'MarkdownSection', 'ContentPage']
|
|
8
|
+
|
|
9
|
+
# %% ../nbs/04_web_pages.ipynb 3
|
|
10
|
+
import importlib
|
|
11
|
+
import markdown
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from .components import *
|
|
16
|
+
from .core import *
|
|
17
|
+
from fasthtml.common import *
|
|
18
|
+
from .app_pages import *
|
|
19
|
+
from .foundations import *
|
|
20
|
+
|
|
21
|
+
from fastcore.utils import partial
|
|
22
|
+
from fasthtml.common import *
|
|
23
|
+
from fasthtml.jupyter import FastHTML, fast_app, JupyUvi, HTMX
|
|
24
|
+
from fastlite import *
|
|
25
|
+
import fasthtml.components as fc
|
|
26
|
+
from fasthtml.common import A, Button as FhButton, I, Span
|
|
27
|
+
import socket
|
|
28
|
+
import time
|
|
29
|
+
import subprocess
|
|
30
|
+
|
|
31
|
+
# %% ../nbs/04_web_pages.ipynb 10
|
|
32
|
+
def FeatureShowcase(
|
|
33
|
+
features: list, # List of dicts: {title, description, image_src?, icon?, image_alt?}
|
|
34
|
+
title: str = None, # Optional section title
|
|
35
|
+
subtitle: str = None, # Optional section subtitle
|
|
36
|
+
cls: str = "",
|
|
37
|
+
):
|
|
38
|
+
"""Alternating text/image feature showcase with card styling.
|
|
39
|
+
|
|
40
|
+
Each feature row alternates: text-left/media-right, then text-right/media-left.
|
|
41
|
+
On mobile, stacks vertically. Supports either image_src or icon for media.
|
|
42
|
+
Each row is wrapped in an Article card with elevation for visual distinction.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
features: List of feature dicts with keys:
|
|
46
|
+
- title: Feature title (required)
|
|
47
|
+
- description: Feature description (required)
|
|
48
|
+
- image_src: URL/path to image (optional)
|
|
49
|
+
- icon: Material icon name (optional, used if no image_src)
|
|
50
|
+
- image_alt: Alt text for image (optional)
|
|
51
|
+
title: Section heading
|
|
52
|
+
subtitle: Section subheading
|
|
53
|
+
cls: Additional CSS classes
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
FeatureShowcase(
|
|
57
|
+
title="Why Choose Us",
|
|
58
|
+
features=[
|
|
59
|
+
{'title': 'Fast', 'description': 'Lightning quick', 'icon': 'bolt'},
|
|
60
|
+
{'title': 'Secure', 'description': 'Bank-level security', 'image_src': '/img/secure.png'},
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
"""
|
|
64
|
+
rows = []
|
|
65
|
+
|
|
66
|
+
for idx, feature in enumerate(features):
|
|
67
|
+
# Text column
|
|
68
|
+
text_col = Div(
|
|
69
|
+
H3(feature['title'], cls="no-margin"),
|
|
70
|
+
P(feature['description'], cls="secondary-text large-text"),
|
|
71
|
+
cls="s12 m6 l6 padding",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Media column - image or icon
|
|
75
|
+
if feature.get('image_src'):
|
|
76
|
+
media_content = Img(
|
|
77
|
+
src=feature['image_src'],
|
|
78
|
+
alt=feature.get('image_alt', feature['title']),
|
|
79
|
+
cls="responsive round",
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
# Fallback to icon with large display
|
|
83
|
+
icon_name = feature.get('icon', 'star')
|
|
84
|
+
media_content = Icon(icon_name, size="extra", cls="primary-text")
|
|
85
|
+
|
|
86
|
+
media_col = Div(
|
|
87
|
+
Div(media_content, cls="center-align"),
|
|
88
|
+
cls="s12 m6 l6 padding",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Alternate layout: even rows = text-left, odd rows = text-right
|
|
92
|
+
if idx % 2 == 0:
|
|
93
|
+
row_content = Div(text_col, media_col, cls="grid middle-align")
|
|
94
|
+
else:
|
|
95
|
+
row_content = Div(media_col, text_col, cls="grid middle-align")
|
|
96
|
+
|
|
97
|
+
# Wrap each row in Article card with elevation styling
|
|
98
|
+
row = Article(
|
|
99
|
+
row_content,
|
|
100
|
+
cls="surface-container round border large-padding medium-margin",
|
|
101
|
+
)
|
|
102
|
+
rows.append(row)
|
|
103
|
+
|
|
104
|
+
# Build section
|
|
105
|
+
content = []
|
|
106
|
+
if title:
|
|
107
|
+
content.append(H2(title, cls="center-align"))
|
|
108
|
+
if subtitle:
|
|
109
|
+
content.append(P(subtitle, cls="center-align secondary-text large-text bottom-margin"))
|
|
110
|
+
content.extend(rows)
|
|
111
|
+
|
|
112
|
+
return Section(*content, cls=f"responsive {cls}".strip())
|
|
113
|
+
|
|
114
|
+
# %% ../nbs/04_web_pages.ipynb 12
|
|
115
|
+
def FeaturesGrid(
|
|
116
|
+
features: list, # List of dicts with 'icon', 'title', 'description'
|
|
117
|
+
title: str = None, # Optional section title
|
|
118
|
+
subtitle: str = None, # Optional section subtitle
|
|
119
|
+
cols: int = 3, # Number of columns (1-4)
|
|
120
|
+
cls: str = "" # Additional classes
|
|
121
|
+
):
|
|
122
|
+
"""Grid of feature cards using BeerCSS grid helpers."""
|
|
123
|
+
import fh_matui.components as _cmp
|
|
124
|
+
|
|
125
|
+
GridCell = getattr(_cmp, 'GridCell', None)
|
|
126
|
+
ResponsiveGrid = getattr(_cmp, 'ResponsiveGrid', None)
|
|
127
|
+
|
|
128
|
+
if GridCell is None or ResponsiveGrid is None:
|
|
129
|
+
# Fallback: keep previews working even if the notebook kernel has a stale module import.
|
|
130
|
+
def GridCell(*c, span=(), cls='', **kwargs):
|
|
131
|
+
cell_cls = []
|
|
132
|
+
cell_cls.extend(normalize_tokens(span))
|
|
133
|
+
cell_cls.extend(normalize_tokens(cls))
|
|
134
|
+
cell_cls = [t for t in cell_cls if t]
|
|
135
|
+
return Div(*c, cls=stringify(dedupe_preserve_order(cell_cls)), **kwargs)
|
|
136
|
+
|
|
137
|
+
def ResponsiveGrid(*cells, space='medium-space', cls: str = '', **kwargs):
|
|
138
|
+
cls_tokens = normalize_tokens(cls)
|
|
139
|
+
grid_cls = ['grid']
|
|
140
|
+
if space and space not in cls_tokens:
|
|
141
|
+
grid_cls.extend(normalize_tokens(space))
|
|
142
|
+
grid_cls.extend(cls_tokens)
|
|
143
|
+
grid_cls = [t for t in grid_cls if t]
|
|
144
|
+
return Div(*cells, cls=stringify(dedupe_preserve_order(grid_cls)), **kwargs)
|
|
145
|
+
|
|
146
|
+
cols = max(1, min(int(cols), 4))
|
|
147
|
+
col_width = 12 // cols # 12-col grid
|
|
148
|
+
|
|
149
|
+
s_span = "s12"
|
|
150
|
+
m_span = "m12" if cols == 1 else "m6"
|
|
151
|
+
l_span = f"l{col_width}"
|
|
152
|
+
cell_span = f"{s_span} {m_span} {l_span}".strip()
|
|
153
|
+
|
|
154
|
+
cells = [
|
|
155
|
+
GridCell(
|
|
156
|
+
Card(
|
|
157
|
+
Div(Icon(f["icon"], size="medium", cls="primary-text"), cls="center-align"),
|
|
158
|
+
H5(f["title"], cls="center-align small-margin"),
|
|
159
|
+
P(f["description"], cls="secondary-text center-align"),
|
|
160
|
+
cls="center-align padding",
|
|
161
|
+
),
|
|
162
|
+
span=cell_span,
|
|
163
|
+
)
|
|
164
|
+
for f in features
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
content = []
|
|
168
|
+
if title:
|
|
169
|
+
content.append(H1(title, cls="center-align bottom-margin"))
|
|
170
|
+
if subtitle:
|
|
171
|
+
content.append(P(subtitle, cls="center-align secondary-text large-margin large-text"))
|
|
172
|
+
|
|
173
|
+
content.append(ResponsiveGrid(*cells, space="medium-space"))
|
|
174
|
+
# Use responsive for centered max-width layout
|
|
175
|
+
return Section(*content, cls=f"responsive padding {cls}".strip())
|
|
176
|
+
|
|
177
|
+
# %% ../nbs/04_web_pages.ipynb 16
|
|
178
|
+
def _pricing_toggle_js():
|
|
179
|
+
"""Returns Script + Style for pricing toggle functionality."""
|
|
180
|
+
css = """
|
|
181
|
+
@keyframes subtle-pulse {
|
|
182
|
+
0%, 100% { transform: scale(1); }
|
|
183
|
+
50% { transform: scale(1.05); }
|
|
184
|
+
}
|
|
185
|
+
.savings-chip { display: none; }
|
|
186
|
+
.savings-chip.active {
|
|
187
|
+
display: inline-flex;
|
|
188
|
+
animation: subtle-pulse 0.3s ease-out;
|
|
189
|
+
}
|
|
190
|
+
.pricing-monthly { display: block; }
|
|
191
|
+
.pricing-yearly { display: none; }
|
|
192
|
+
.pricing-yearly.active { display: block; }
|
|
193
|
+
.pricing-monthly.active { display: block; }
|
|
194
|
+
.pricing-yearly:not(.active) { display: none; }
|
|
195
|
+
.pricing-monthly:not(.active) { display: none; }
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
js = """
|
|
199
|
+
function togglePricing(period) {
|
|
200
|
+
const monthly = document.querySelectorAll('.pricing-monthly');
|
|
201
|
+
const yearly = document.querySelectorAll('.pricing-yearly');
|
|
202
|
+
const chip = document.querySelector('.savings-chip');
|
|
203
|
+
const btnMonthly = document.querySelector('.toggle-monthly');
|
|
204
|
+
const btnYearly = document.querySelector('.toggle-yearly');
|
|
205
|
+
|
|
206
|
+
if (period === 'yearly') {
|
|
207
|
+
monthly.forEach(el => el.classList.remove('active'));
|
|
208
|
+
yearly.forEach(el => el.classList.add('active'));
|
|
209
|
+
chip?.classList.add('active');
|
|
210
|
+
btnMonthly?.classList.remove('fill');
|
|
211
|
+
btnMonthly?.classList.add('border');
|
|
212
|
+
btnYearly?.classList.add('fill');
|
|
213
|
+
btnYearly?.classList.remove('border');
|
|
214
|
+
} else {
|
|
215
|
+
yearly.forEach(el => el.classList.remove('active'));
|
|
216
|
+
monthly.forEach(el => el.classList.add('active'));
|
|
217
|
+
chip?.classList.remove('active');
|
|
218
|
+
btnYearly?.classList.remove('fill');
|
|
219
|
+
btnYearly?.classList.add('border');
|
|
220
|
+
btnMonthly?.classList.add('fill');
|
|
221
|
+
btnMonthly?.classList.remove('border');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
"""
|
|
225
|
+
return [Style(css), Script(js)]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _calc_savings_pct(monthly_price: float, yearly_price: float) -> int:
|
|
229
|
+
"""Calculate savings percentage for yearly vs monthly billing."""
|
|
230
|
+
if monthly_price <= 0:
|
|
231
|
+
return 0
|
|
232
|
+
annual_monthly = monthly_price * 12
|
|
233
|
+
savings = round(100 - (yearly_price / annual_monthly) * 100)
|
|
234
|
+
return max(0, savings)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _pricing_card(
|
|
238
|
+
plan: dict, # {name, monthly_price, yearly_price, features, cta_text, cta_href, highlight?}
|
|
239
|
+
span_cls: str = "", # Grid span classes (optional)
|
|
240
|
+
):
|
|
241
|
+
"""Render a single pricing card with dual price display."""
|
|
242
|
+
name = plan["name"]
|
|
243
|
+
monthly = plan["monthly_price"]
|
|
244
|
+
yearly = plan["yearly_price"]
|
|
245
|
+
features = plan["features"]
|
|
246
|
+
cta_text = plan["cta_text"]
|
|
247
|
+
cta_href = plan["cta_href"]
|
|
248
|
+
highlight = plan.get("highlight", False)
|
|
249
|
+
|
|
250
|
+
# Feature list with checkmarks (centered)
|
|
251
|
+
feature_items = [
|
|
252
|
+
Li(Icon("check", cls="primary-text"), " ", f)
|
|
253
|
+
for f in features
|
|
254
|
+
]
|
|
255
|
+
feature_list = Div(
|
|
256
|
+
Ul(*feature_items, style="list-style: none; padding-left: 0; text-align: left; display: inline-block;"),
|
|
257
|
+
cls="center-align",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Format prices
|
|
261
|
+
monthly_fmt = f"${monthly:.2f}" if isinstance(monthly, (int, float)) else monthly
|
|
262
|
+
yearly_fmt = f"${yearly:.2f}" if isinstance(yearly, (int, float)) else yearly
|
|
263
|
+
|
|
264
|
+
# Price display: monthly shown by default, yearly hidden
|
|
265
|
+
price_display = Div(
|
|
266
|
+
Div(
|
|
267
|
+
H2(monthly_fmt, cls="center-align no-margin"),
|
|
268
|
+
P("per month", cls="center-align secondary-text"),
|
|
269
|
+
cls="pricing-monthly active",
|
|
270
|
+
),
|
|
271
|
+
Div(
|
|
272
|
+
H2(yearly_fmt, cls="center-align no-margin"),
|
|
273
|
+
P("per year", cls="center-align secondary-text"),
|
|
274
|
+
cls="pricing-yearly",
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Card styling - highlight adds primary-container background
|
|
279
|
+
card_cls = "padding round"
|
|
280
|
+
if highlight:
|
|
281
|
+
card_cls += " primary-container"
|
|
282
|
+
|
|
283
|
+
card = Card(
|
|
284
|
+
H3(name, cls="center-align"),
|
|
285
|
+
price_display,
|
|
286
|
+
feature_list,
|
|
287
|
+
Button(cta_text, href=cta_href, cls="responsive"),
|
|
288
|
+
cls=card_cls,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Only wrap in Div with span class if provided (for grid layout)
|
|
292
|
+
if span_cls:
|
|
293
|
+
return Div(card, cls=span_cls)
|
|
294
|
+
return card
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def PricingSection(
|
|
298
|
+
title: str, # Section title
|
|
299
|
+
plans: list, # List of plan dicts: {name, monthly_price, yearly_price, features, cta_text, cta_href, highlight?}
|
|
300
|
+
cls: str = "", # Additional classes
|
|
301
|
+
):
|
|
302
|
+
"""Multi-tier pricing section with monthly/yearly toggle.
|
|
303
|
+
|
|
304
|
+
Supports 1-3 pricing tiers with automatic responsive layout.
|
|
305
|
+
User provides prices; component handles toggle, savings display, and grid.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
title: Section heading (e.g., "Simple Pricing")
|
|
309
|
+
plans: List of plan dicts, each containing:
|
|
310
|
+
- name: Plan name (e.g., "Starter", "Pro", "Enterprise")
|
|
311
|
+
- monthly_price: Monthly price as float (e.g., 9.99)
|
|
312
|
+
- yearly_price: Yearly price as float (e.g., 99.99)
|
|
313
|
+
- features: List of feature strings
|
|
314
|
+
- cta_text: Button text (e.g., "Get Started")
|
|
315
|
+
- cta_href: Button link
|
|
316
|
+
- highlight: Optional bool to emphasize this tier (default False)
|
|
317
|
+
cls: Additional CSS classes
|
|
318
|
+
|
|
319
|
+
Example:
|
|
320
|
+
PricingSection(
|
|
321
|
+
title="Choose Your Plan",
|
|
322
|
+
plans=[
|
|
323
|
+
{"name": "Starter", "monthly_price": 9.99, "yearly_price": 99.99,
|
|
324
|
+
"features": ["5 users", "Basic support"], "cta_text": "Start Free", "cta_href": "/signup"},
|
|
325
|
+
{"name": "Pro", "monthly_price": 29.99, "yearly_price": 299.99,
|
|
326
|
+
"features": ["Unlimited users", "Priority support"], "cta_text": "Get Pro", "cta_href": "/signup", "highlight": True},
|
|
327
|
+
]
|
|
328
|
+
)
|
|
329
|
+
"""
|
|
330
|
+
# Calculate max savings across all plans for the chip
|
|
331
|
+
max_savings = 0
|
|
332
|
+
for plan in plans:
|
|
333
|
+
savings = _calc_savings_pct(plan["monthly_price"], plan["yearly_price"])
|
|
334
|
+
max_savings = max(max_savings, savings)
|
|
335
|
+
|
|
336
|
+
# Toggle bar: Monthly | Yearly + Savings chip
|
|
337
|
+
savings_chip = Span(
|
|
338
|
+
f"Save {max_savings}%",
|
|
339
|
+
cls="chip small green white-text savings-chip",
|
|
340
|
+
) if max_savings > 0 else ""
|
|
341
|
+
|
|
342
|
+
toggle = Div(
|
|
343
|
+
Nav(
|
|
344
|
+
FhButton(
|
|
345
|
+
"Monthly",
|
|
346
|
+
cls="toggle-monthly left-round fill small",
|
|
347
|
+
onclick="togglePricing('monthly')",
|
|
348
|
+
),
|
|
349
|
+
FhButton(
|
|
350
|
+
"Yearly",
|
|
351
|
+
cls="toggle-yearly right-round border small",
|
|
352
|
+
onclick="togglePricing('yearly')",
|
|
353
|
+
),
|
|
354
|
+
cls="",
|
|
355
|
+
),
|
|
356
|
+
savings_chip,
|
|
357
|
+
cls="row center-align middle-align small-space bottom-margin",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Determine layout based on number of plans
|
|
361
|
+
num_plans = len(plans)
|
|
362
|
+
|
|
363
|
+
if num_plans == 1:
|
|
364
|
+
# Single card: use flexbox centering, no grid
|
|
365
|
+
card = _pricing_card(plans[0], span_cls="")
|
|
366
|
+
grid = Div(
|
|
367
|
+
Div(card, cls="medium-width"),
|
|
368
|
+
cls="row center-align",
|
|
369
|
+
)
|
|
370
|
+
elif num_plans == 2:
|
|
371
|
+
# Two cards: use grid with s12 m6
|
|
372
|
+
cards = [_pricing_card(plan, span_cls="s12 m6") for plan in plans]
|
|
373
|
+
grid = Div(*cards, cls="grid medium-space")
|
|
374
|
+
else:
|
|
375
|
+
# Three or more cards: use grid with s12 m6 l4
|
|
376
|
+
cards = [_pricing_card(plan, span_cls="s12 m6 l4") for plan in plans]
|
|
377
|
+
grid = Div(*cards, cls="grid medium-space")
|
|
378
|
+
|
|
379
|
+
# Assemble section - use responsive for centered max-width layout
|
|
380
|
+
return Section(
|
|
381
|
+
*_pricing_toggle_js(),
|
|
382
|
+
H2(title, cls="center-align bottom-margin"),
|
|
383
|
+
toggle,
|
|
384
|
+
grid,
|
|
385
|
+
cls=f"responsive padding {cls}".strip(),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# %% ../nbs/04_web_pages.ipynb 20
|
|
389
|
+
def FAQSection(title: str, faqs: list, cls: str = ""):
|
|
390
|
+
"""FAQ section using FAQItem from components.
|
|
391
|
+
|
|
392
|
+
Wraps multiple FAQItem components with a title and consistent spacing.
|
|
393
|
+
Uses FAQItem from fh_matui.components for the actual collapsible Q&A.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
title: Section heading
|
|
397
|
+
faqs: List of dicts with 'question' and 'answer' keys
|
|
398
|
+
cls: Additional CSS classes
|
|
399
|
+
"""
|
|
400
|
+
from fh_matui.components import FAQItem
|
|
401
|
+
|
|
402
|
+
items = [
|
|
403
|
+
Div(FAQItem(faq['question'], faq['answer']), cls="small-margin")
|
|
404
|
+
for faq in faqs
|
|
405
|
+
]
|
|
406
|
+
|
|
407
|
+
return Section(
|
|
408
|
+
H2(title, cls="center-align bottom-margin"),
|
|
409
|
+
Div(*items, cls="column"),
|
|
410
|
+
cls=f"responsive column {cls}".strip(),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# %% ../nbs/04_web_pages.ipynb 23
|
|
414
|
+
# CSS for decorative sine wave border on footer
|
|
415
|
+
_FOOTER_WAVE_CSS = """
|
|
416
|
+
.footer-wave {
|
|
417
|
+
position: relative;
|
|
418
|
+
}
|
|
419
|
+
.footer-wave::before {
|
|
420
|
+
content: '';
|
|
421
|
+
position: absolute;
|
|
422
|
+
top: 0;
|
|
423
|
+
left: 0;
|
|
424
|
+
right: 0;
|
|
425
|
+
height: 12px;
|
|
426
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 12' preserveAspectRatio='none'%3E%3Cpath d='M0,6 Q25,0 50,6 T100,6 T150,6 T200,6 T250,6 T300,6 T350,6 T400,6 T450,6 T500,6 T550,6 T600,6 T650,6 T700,6 T750,6 T800,6 T850,6 T900,6 T950,6 T1000,6 T1050,6 T1100,6 T1150,6 T1200,6' fill='none' stroke='%23888' stroke-width='1'/%3E%3C/svg%3E");
|
|
427
|
+
background-size: 100% 100%;
|
|
428
|
+
background-repeat: no-repeat;
|
|
429
|
+
opacity: 0.5;
|
|
430
|
+
}
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
def PageFooter(
|
|
434
|
+
columns: list, # List of dicts with 'title' and 'links' (list of dicts with 'text' and 'href')
|
|
435
|
+
copyright: str, # Copyright text
|
|
436
|
+
social_links: list = None, # List of dicts with 'icon' and 'href'
|
|
437
|
+
logo: str = None, # Optional logo text
|
|
438
|
+
wave_border: bool = True, # Show decorative wave border at top
|
|
439
|
+
cls: str = "" # Additional classes
|
|
440
|
+
):
|
|
441
|
+
"""Footer with multiple columns, social links, and copyright.
|
|
442
|
+
|
|
443
|
+
Features a decorative sine wave border at the top for elegant visual separation.
|
|
444
|
+
Blends with page background (no container fill) for seamless appearance.
|
|
445
|
+
"""
|
|
446
|
+
footer_cols = []
|
|
447
|
+
|
|
448
|
+
# Logo column if provided
|
|
449
|
+
if logo:
|
|
450
|
+
footer_cols.append(
|
|
451
|
+
Div(
|
|
452
|
+
H6(logo, cls='no-margin bold'),
|
|
453
|
+
cls='padding'
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Link columns - vertically stacked
|
|
458
|
+
for col in columns:
|
|
459
|
+
links = [A(link['text'], href=link['href'], cls='grey-text') for link in col.get('links', [])]
|
|
460
|
+
footer_cols.append(
|
|
461
|
+
Div(
|
|
462
|
+
H6(col['title'], cls='no-margin bold'),
|
|
463
|
+
DivVStacked(*links, cls='small-space'),
|
|
464
|
+
cls='padding'
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# Footer row with full spacing between columns
|
|
469
|
+
footer_row = DivFullySpaced(*footer_cols)
|
|
470
|
+
|
|
471
|
+
# Bottom section with copyright and social links
|
|
472
|
+
bottom_left = Div(P(copyright, cls='small-text grey-text no-margin'))
|
|
473
|
+
|
|
474
|
+
bottom_right = Div()
|
|
475
|
+
if social_links:
|
|
476
|
+
social_icons = [A(Icon(s['icon']), href=s['href'], cls='grey-text') for s in social_links]
|
|
477
|
+
bottom_right = Div(*social_icons, cls='row small-space')
|
|
478
|
+
|
|
479
|
+
bottom_section = DivFullySpaced(bottom_left, bottom_right)
|
|
480
|
+
|
|
481
|
+
# Footer blends with page (no surface-container), wave provides visual separation
|
|
482
|
+
footer_cls = f'footer-wave {cls}'.strip() if wave_border else cls
|
|
483
|
+
|
|
484
|
+
# Include wave CSS if enabled
|
|
485
|
+
wave_style = Style(_FOOTER_WAVE_CSS) if wave_border else ""
|
|
486
|
+
|
|
487
|
+
return ft_hx('footer')(
|
|
488
|
+
wave_style,
|
|
489
|
+
Div(
|
|
490
|
+
footer_row,
|
|
491
|
+
Hr(),
|
|
492
|
+
bottom_section,
|
|
493
|
+
cls="responsive padding", # Content centered
|
|
494
|
+
),
|
|
495
|
+
cls=footer_cls,
|
|
496
|
+
style="padding-top: 1.5rem;" if wave_border else "", # Space for wave
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# %% ../nbs/04_web_pages.ipynb 25
|
|
500
|
+
def LandingNavBar(
|
|
501
|
+
brand_name: str, # Brand name for the navbar
|
|
502
|
+
links: list = None, # List of dicts with 'text' and 'href'
|
|
503
|
+
actions: list = None, # List of action buttons (Buttons/Links)
|
|
504
|
+
sticky: bool = True, # Whether navbar sticks to top
|
|
505
|
+
cls: str = "" # Additional classes
|
|
506
|
+
):
|
|
507
|
+
"""
|
|
508
|
+
Landing page navigation bar with frosted glass effect.
|
|
509
|
+
|
|
510
|
+
Uses BeerCSS blur classes for translucent glass appearance.
|
|
511
|
+
Sticky by default to stay visible while scrolling.
|
|
512
|
+
Content is centered with max-width using 'responsive' class.
|
|
513
|
+
Brand name links to home page.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
brand_name: Brand name/logo text
|
|
517
|
+
links: List of navigation links [{'text': 'Features', 'href': '#features'}, ...]
|
|
518
|
+
actions: List of action elements (Buttons, etc.) for CTA
|
|
519
|
+
sticky: Whether navbar sticks to top while scrolling (default: True)
|
|
520
|
+
cls: Additional CSS classes
|
|
521
|
+
"""
|
|
522
|
+
nav_items = []
|
|
523
|
+
|
|
524
|
+
# Brand - links to home page
|
|
525
|
+
nav_items.append(A(H5(brand_name, cls="no-margin bold"), href="/"))
|
|
526
|
+
|
|
527
|
+
# Spacer to push links right
|
|
528
|
+
nav_items.append(Div(cls="max"))
|
|
529
|
+
|
|
530
|
+
# Navigation links
|
|
531
|
+
if links:
|
|
532
|
+
for link in links:
|
|
533
|
+
nav_items.append(A(link['text'], href=link['href'], cls="padding"))
|
|
534
|
+
|
|
535
|
+
# Action buttons
|
|
536
|
+
if actions:
|
|
537
|
+
nav_items.extend(actions)
|
|
538
|
+
|
|
539
|
+
# Inner content: responsive centers it, row for horizontal layout
|
|
540
|
+
inner = Div(*nav_items, cls="responsive row middle-align")
|
|
541
|
+
|
|
542
|
+
# Sticky positioning + BeerCSS blur/glass classes
|
|
543
|
+
sticky_cls = "fixed top left right" if sticky else ""
|
|
544
|
+
toolbar_cls = f"blur large-blur surface-container-low {sticky_cls} {cls}".strip()
|
|
545
|
+
|
|
546
|
+
return Header(inner, cls=toolbar_cls)
|
|
547
|
+
|
|
548
|
+
# %% ../nbs/04_web_pages.ipynb 27
|
|
549
|
+
# Section spacing: padding for internal space, margin for breathing room between sections
|
|
550
|
+
SECTION_STYLE = "large-padding" # Internal padding
|
|
551
|
+
SECTION_MARGIN = "extra-margin bottom-margin" # Vertical spacing between sections
|
|
552
|
+
|
|
553
|
+
# Standard footer columns - consistent across all pages (3 items each for balanced width)
|
|
554
|
+
STANDARD_FOOTER_COLUMNS = [
|
|
555
|
+
{"title": "Features", "links": [
|
|
556
|
+
{"text": "Tracking", "href": "#features"},
|
|
557
|
+
{"text": "Budgeting", "href": "#features"},
|
|
558
|
+
{"text": "Planning", "href": "#features"},
|
|
559
|
+
]},
|
|
560
|
+
{"title": "Support", "links": [
|
|
561
|
+
{"text": "Help Center", "href": "/help"},
|
|
562
|
+
{"text": "Contact", "href": "/contact"},
|
|
563
|
+
{"text": "FAQ", "href": "#faq"},
|
|
564
|
+
]},
|
|
565
|
+
{"title": "Legal", "links": [
|
|
566
|
+
{"text": "Terms of Use", "href": "/terms"},
|
|
567
|
+
{"text": "Privacy Policy", "href": "/privacy"},
|
|
568
|
+
{"text": "Security", "href": "/security"},
|
|
569
|
+
]},
|
|
570
|
+
]
|
|
571
|
+
|
|
572
|
+
# CSS for smooth scrolling and sticky navbar offset (5rem = ~4rem navbar + 1rem breathing room)
|
|
573
|
+
_LANDING_PAGE_CSS = """
|
|
574
|
+
html {
|
|
575
|
+
scroll-behavior: smooth;
|
|
576
|
+
}
|
|
577
|
+
/* Offset anchor targets to account for sticky navbar */
|
|
578
|
+
#benefits, #features, #pricing, #faq {
|
|
579
|
+
scroll-margin-top: 5rem;
|
|
580
|
+
}
|
|
581
|
+
/* Centered mini-wave separator between sections */
|
|
582
|
+
.section-wave {
|
|
583
|
+
display: flex;
|
|
584
|
+
justify-content: center;
|
|
585
|
+
padding: 1.5rem 0;
|
|
586
|
+
}
|
|
587
|
+
.section-wave::before {
|
|
588
|
+
content: '';
|
|
589
|
+
width: 150px;
|
|
590
|
+
height: 12px;
|
|
591
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 150 12' preserveAspectRatio='none'%3E%3Cpath d='M0,6 Q12.5,0 25,6 T50,6 T75,6 T100,6 T125,6 T150,6' fill='none' stroke='%23888' stroke-width='1.5'/%3E%3C/svg%3E");
|
|
592
|
+
background-size: 100% 100%;
|
|
593
|
+
background-repeat: no-repeat;
|
|
594
|
+
opacity: 0.4;
|
|
595
|
+
}
|
|
596
|
+
"""
|
|
597
|
+
|
|
598
|
+
# Internal helper - centered wave separator between sections
|
|
599
|
+
def _SectionWave(): return Div(cls="section-wave")
|
|
600
|
+
|
|
601
|
+
def LandingPageSimple(
|
|
602
|
+
# Required
|
|
603
|
+
brand_name: str,
|
|
604
|
+
hero_title: str,
|
|
605
|
+
hero_subtitle: str,
|
|
606
|
+
hero_primary_cta: dict, # {'text': 'Get Started', 'href': '/signup'}
|
|
607
|
+
footer_copyright: str, # Required copyright text
|
|
608
|
+
# Optional Hero
|
|
609
|
+
hero_secondary_cta: dict = None, # Optional secondary CTA
|
|
610
|
+
# Optional Benefits (FeatureShowcase - alternating rows)
|
|
611
|
+
benefits: list = None, # List of dicts: {title, description, icon?, image_src?}
|
|
612
|
+
benefits_title: str = None,
|
|
613
|
+
benefits_subtitle: str = None,
|
|
614
|
+
# Optional Features (FeaturesGrid - icon cards)
|
|
615
|
+
features: list = None, # List of dicts: {icon, title, description}
|
|
616
|
+
features_title: str = None,
|
|
617
|
+
features_subtitle: str = None,
|
|
618
|
+
# Optional Pricing
|
|
619
|
+
pricing_plans: list = None, # List of plan dicts for PricingSection
|
|
620
|
+
pricing_title: str = "Pricing",
|
|
621
|
+
# Optional FAQ
|
|
622
|
+
faqs: list = None, # List of FAQ dicts
|
|
623
|
+
faq_title: str = "Frequently Asked Questions",
|
|
624
|
+
# Optional Footer extras
|
|
625
|
+
footer_columns: list = None,
|
|
626
|
+
footer_social_links: list = None,
|
|
627
|
+
# Nav customization
|
|
628
|
+
nav_links: list = None, # Override default nav links
|
|
629
|
+
nav_actions: list = None, # CTA buttons in navbar
|
|
630
|
+
cls: str = "",
|
|
631
|
+
):
|
|
632
|
+
"""Landing page component - supply your content, get a complete page.
|
|
633
|
+
|
|
634
|
+
This is a reusable template component. Pass your brand info, hero content,
|
|
635
|
+
features, pricing, and FAQs - it assembles a complete marketing landing page.
|
|
636
|
+
|
|
637
|
+
Includes smooth scrolling and proper anchor offset to prevent sticky navbar
|
|
638
|
+
from hiding section headings when navigating via anchor links.
|
|
639
|
+
|
|
640
|
+
Layout approach: Main uses "max" for full-width backgrounds.
|
|
641
|
+
Each section handles its own content centering with "responsive" wrapper.
|
|
642
|
+
This allows gradients/backgrounds to extend edge-to-edge naturally.
|
|
643
|
+
|
|
644
|
+
Section order: Hero → Benefits → Features → Pricing → FAQ → Footer
|
|
645
|
+
|
|
646
|
+
Required: brand_name, hero_title, hero_subtitle, hero_primary_cta, footer_copyright
|
|
647
|
+
Optional: Benefits, Features, Pricing, FAQ (pass None to skip any section)
|
|
648
|
+
"""
|
|
649
|
+
# Static nav links - convention: all sections available
|
|
650
|
+
default_nav_links = [
|
|
651
|
+
{"text": "Benefits", "href": "#benefits"},
|
|
652
|
+
{"text": "Features", "href": "#features"},
|
|
653
|
+
{"text": "Pricing", "href": "#pricing"},
|
|
654
|
+
{"text": "FAQ", "href": "#faq"},
|
|
655
|
+
]
|
|
656
|
+
navbar = LandingNavBar(
|
|
657
|
+
brand_name=brand_name,
|
|
658
|
+
links=nav_links or default_nav_links,
|
|
659
|
+
actions=nav_actions,
|
|
660
|
+
sticky=True,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
main_sections = []
|
|
664
|
+
|
|
665
|
+
# 1. Hero (required) - full-width, content centered internally
|
|
666
|
+
hero = HeroSection(
|
|
667
|
+
title=hero_title,
|
|
668
|
+
subtitle=hero_subtitle,
|
|
669
|
+
primary_cta_text=hero_primary_cta['text'],
|
|
670
|
+
primary_cta_href=hero_primary_cta['href'],
|
|
671
|
+
secondary_cta_text=hero_secondary_cta.get('text') if hero_secondary_cta else None,
|
|
672
|
+
secondary_cta_href=hero_secondary_cta.get('href') if hero_secondary_cta else None,
|
|
673
|
+
cls=SECTION_MARGIN,
|
|
674
|
+
)
|
|
675
|
+
main_sections.append(hero)
|
|
676
|
+
|
|
677
|
+
# 2. Benefits - FeatureShowcase (optional) - has responsive internally
|
|
678
|
+
if benefits:
|
|
679
|
+
main_sections.append(_SectionWave())
|
|
680
|
+
benefits_section = FeatureShowcase(
|
|
681
|
+
features=benefits,
|
|
682
|
+
title=benefits_title,
|
|
683
|
+
subtitle=benefits_subtitle,
|
|
684
|
+
cls=SECTION_STYLE,
|
|
685
|
+
)
|
|
686
|
+
main_sections.append(Div(benefits_section, id="benefits", cls=SECTION_MARGIN))
|
|
687
|
+
|
|
688
|
+
# 3. Features - FeaturesGrid (optional) - has responsive internally
|
|
689
|
+
if features:
|
|
690
|
+
main_sections.append(_SectionWave())
|
|
691
|
+
features_section = FeaturesGrid(
|
|
692
|
+
features=features,
|
|
693
|
+
title=features_title,
|
|
694
|
+
subtitle=features_subtitle,
|
|
695
|
+
cols=3,
|
|
696
|
+
cls=SECTION_STYLE,
|
|
697
|
+
)
|
|
698
|
+
main_sections.append(Div(features_section, id="features", cls=SECTION_MARGIN))
|
|
699
|
+
|
|
700
|
+
# 4. Pricing (optional) - has responsive internally
|
|
701
|
+
if pricing_plans:
|
|
702
|
+
main_sections.append(_SectionWave())
|
|
703
|
+
pricing_section = PricingSection(
|
|
704
|
+
title=pricing_title,
|
|
705
|
+
plans=pricing_plans,
|
|
706
|
+
cls=SECTION_STYLE,
|
|
707
|
+
)
|
|
708
|
+
main_sections.append(Div(pricing_section, id="pricing", cls=SECTION_MARGIN))
|
|
709
|
+
|
|
710
|
+
# 5. FAQ (optional) - has responsive internally
|
|
711
|
+
if faqs:
|
|
712
|
+
main_sections.append(_SectionWave())
|
|
713
|
+
faq_section = FAQSection(
|
|
714
|
+
title=faq_title,
|
|
715
|
+
faqs=faqs,
|
|
716
|
+
cls=SECTION_STYLE,
|
|
717
|
+
)
|
|
718
|
+
main_sections.append(Div(faq_section, id="faq", cls=SECTION_MARGIN))
|
|
719
|
+
|
|
720
|
+
# Footer (required) - use standard columns if none provided
|
|
721
|
+
footer_el = PageFooter(
|
|
722
|
+
columns=footer_columns or STANDARD_FOOTER_COLUMNS,
|
|
723
|
+
copyright=footer_copyright,
|
|
724
|
+
social_links=footer_social_links,
|
|
725
|
+
logo=brand_name,
|
|
726
|
+
cls="large-padding",
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
# Main uses "max" for full-width - sections extend edge-to-edge
|
|
730
|
+
# Each section handles its own content centering with responsive
|
|
731
|
+
main_content = Main(*main_sections, cls="max")
|
|
732
|
+
|
|
733
|
+
# Include CSS for smooth scrolling and anchor offset
|
|
734
|
+
return Div(
|
|
735
|
+
Style(_LANDING_PAGE_CSS),
|
|
736
|
+
navbar,
|
|
737
|
+
main_content,
|
|
738
|
+
footer_el,
|
|
739
|
+
cls=f"column {cls}".strip()
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
# %% ../nbs/04_web_pages.ipynb 30
|
|
743
|
+
def MarkdownSection(
|
|
744
|
+
content: str, # Markdown text to render
|
|
745
|
+
title: str = None, # Optional section title (rendered as H5)
|
|
746
|
+
cls: str = "", # Additional classes
|
|
747
|
+
):
|
|
748
|
+
"""Renders markdown content server-side for SEO compatibility.
|
|
749
|
+
|
|
750
|
+
Uses python-markdown to convert markdown to HTML on the server.
|
|
751
|
+
Search engines see fully rendered HTML (no JavaScript required).
|
|
752
|
+
Parent ContentPage uses 'min' class to center the page layout.
|
|
753
|
+
|
|
754
|
+
Great for text-heavy pages like Privacy Policy, Terms, About, Blog posts, etc.
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
content: Markdown text string (can include headers, lists, links, code blocks, tables)
|
|
758
|
+
title: Optional page title (rendered before markdown content)
|
|
759
|
+
cls: Additional CSS classes
|
|
760
|
+
|
|
761
|
+
Example:
|
|
762
|
+
MarkdownSection(
|
|
763
|
+
title="Privacy Policy",
|
|
764
|
+
content='''
|
|
765
|
+
## Introduction
|
|
766
|
+
We value your privacy...
|
|
767
|
+
|
|
768
|
+
## Data Collection
|
|
769
|
+
- We collect minimal data
|
|
770
|
+
- We never sell your data
|
|
771
|
+
'''
|
|
772
|
+
)
|
|
773
|
+
"""
|
|
774
|
+
import markdown
|
|
775
|
+
|
|
776
|
+
# Server-side render markdown to HTML (SEO-friendly)
|
|
777
|
+
html_content = markdown.markdown(
|
|
778
|
+
content,
|
|
779
|
+
extensions=['tables', 'fenced_code', 'nl2br']
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
elements = []
|
|
783
|
+
if title:
|
|
784
|
+
elements.append(H5(title, cls="center-align"))
|
|
785
|
+
|
|
786
|
+
# NotStr tells FastHTML to render raw HTML without escaping
|
|
787
|
+
elements.append(NotStr(html_content))
|
|
788
|
+
|
|
789
|
+
return Article(*elements, cls=f"large-padding {cls}".strip())
|
|
790
|
+
|
|
791
|
+
# %% ../nbs/04_web_pages.ipynb 31
|
|
792
|
+
def ContentPage(
|
|
793
|
+
brand_name: str, # Brand name for navbar
|
|
794
|
+
footer_copyright: str, # Copyright text for footer
|
|
795
|
+
*sections, # Content sections (MarkdownSection, custom Divs, etc.)
|
|
796
|
+
nav_links: list = None, # Override default nav links
|
|
797
|
+
nav_actions: list = None, # CTA buttons in navbar
|
|
798
|
+
footer_columns: list = None, # Footer link columns (uses STANDARD_FOOTER_COLUMNS by default)
|
|
799
|
+
footer_social_links: list = None, # Footer social icons
|
|
800
|
+
cls: str = "",
|
|
801
|
+
):
|
|
802
|
+
"""Generic content page with navbar, flexible content area, and footer.
|
|
803
|
+
|
|
804
|
+
A shell template for text-heavy pages like Privacy Policy, Terms of Service,
|
|
805
|
+
Security, About, Blog posts, etc. Developer passes any number of content sections.
|
|
806
|
+
|
|
807
|
+
Layout: Navbar (sticky) -> Content sections (centered with 'min') -> Footer
|
|
808
|
+
|
|
809
|
+
Uses STANDARD_FOOTER_COLUMNS by default for consistent footer across all pages.
|
|
810
|
+
|
|
811
|
+
Args:
|
|
812
|
+
brand_name: Brand name for navbar
|
|
813
|
+
footer_copyright: Copyright text
|
|
814
|
+
*sections: One or more content sections (MarkdownSection, custom Divs, etc.)
|
|
815
|
+
nav_links: Navigation links [{'text': 'Home', 'href': '/'}, ...]
|
|
816
|
+
nav_actions: Action buttons for navbar
|
|
817
|
+
footer_columns: Footer link columns (defaults to STANDARD_FOOTER_COLUMNS)
|
|
818
|
+
footer_social_links: Social media icons for footer
|
|
819
|
+
cls: Additional CSS classes
|
|
820
|
+
|
|
821
|
+
Example:
|
|
822
|
+
ContentPage(
|
|
823
|
+
brand_name="MyBrand",
|
|
824
|
+
footer_copyright="2026 MyBrand",
|
|
825
|
+
MarkdownSection(title="Privacy Policy", content="...markdown..."),
|
|
826
|
+
nav_links=[{'text': 'Home', 'href': '/'}],
|
|
827
|
+
)
|
|
828
|
+
"""
|
|
829
|
+
# Default nav links for content pages - just Home, no anchor links
|
|
830
|
+
default_nav_links = [
|
|
831
|
+
{"text": "Home", "href": "/"},
|
|
832
|
+
]
|
|
833
|
+
|
|
834
|
+
navbar = LandingNavBar(
|
|
835
|
+
brand_name=brand_name,
|
|
836
|
+
links=nav_links or default_nav_links,
|
|
837
|
+
actions=nav_actions,
|
|
838
|
+
sticky=True,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
footer_el = PageFooter(
|
|
842
|
+
columns=footer_columns or STANDARD_FOOTER_COLUMNS,
|
|
843
|
+
copyright=footer_copyright,
|
|
844
|
+
social_links=footer_social_links,
|
|
845
|
+
logo=brand_name,
|
|
846
|
+
cls="large-padding",
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
# Main content area - use "min" to center content (unlike landing page which uses "max")
|
|
850
|
+
main_content = Main(*sections, cls="min large-padding")
|
|
851
|
+
|
|
852
|
+
return Div(navbar, main_content, footer_el, cls=f"column {cls}".strip())
|