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