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/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())