Skip to main content

logicaffeine_web/ui/pages/contact/
mod.rs

1//! The contact page: what LOGOS is (open source, under MIT), how to install it, the form that
2//! reaches the team, and the FAQ.
3//!
4//! What the page says is data in [`content`]; the Learn counts it quotes are read by [`claims`];
5//! the form's rules are [`lead`] and its browser half is `form`. `/contact#<section>` opens at
6//! that section (the route models the fragment, so boot normalization keeps it), and `/pricing`,
7//! the page's old address, redirects here.
8//!
9//! # Route
10//!
11//! Accessed via [`Route::Contact`].
12
13pub mod claims;
14pub mod content;
15mod form;
16pub mod lead;
17
18use dioxus::prelude::*;
19
20use crate::ui::components::footer::Footer;
21use crate::ui::components::main_nav::{ActivePage, MainNav};
22use crate::ui::fragment::scroll_to_section;
23use crate::ui::router::Route;
24use crate::ui::seo::{breadcrumb_schema, faq_schema, organization_contact_schema, pages as seo_pages, BreadcrumbItem, JsonLdMultiple, PageHead};
25use content::{Faq, CONTACT_HEADING, INSTALL_UNIX, INSTALL_WINDOWS, OPEN_SOURCE_LINE, TEAM_EMAIL};
26use form::LeadForm;
27
28const CONTACT_STYLE: &str = r#"
29.contact {
30  min-height: 100vh;
31  color: var(--text-primary);
32  font-family: var(--font-sans);
33}
34
35.contact-main {
36  max-width: 1120px;
37  margin: 0 auto;
38  padding: 0 var(--spacing-xl) 96px;
39}
40
41.contact [id] { scroll-margin-top: 104px; }
42
43.contact h1,
44.contact h2,
45.contact h3 { color: var(--text-primary); }
46
47.contact h1 {
48  font-size: var(--font-display-lg);
49  line-height: 1.06;
50  letter-spacing: -0.025em;
51  font-weight: 800;
52  max-width: 13ch;
53}
54
55.contact h2 {
56  font-size: var(--font-heading-lg);
57  line-height: 1.2;
58  letter-spacing: -0.01em;
59  font-weight: 750;
60}
61
62.contact h3 {
63  font-size: var(--font-heading-sm);
64  line-height: 1.3;
65  font-weight: 700;
66}
67
68.contact p,
69.contact li,
70.contact dd,
71.contact td {
72  font-size: var(--font-body-md);
73  line-height: 1.6;
74  color: var(--text-secondary);
75}
76
77.contact a:focus-visible,
78.contact button:focus-visible,
79.contact input:focus-visible {
80  outline: 2px solid var(--accent-primary);
81  outline-offset: 3px;
82}
83
84.contact-text-link {
85  color: var(--accent-primary);
86  text-decoration: underline;
87  text-underline-offset: 3px;
88  text-decoration-thickness: 1px;
89}
90
91.contact-text-link:hover { text-decoration-thickness: 2px; }
92
93/* Calls to act */
94.contact-actions {
95  display: flex;
96  flex-wrap: wrap;
97  gap: var(--spacing-md);
98}
99
100.contact-btn {
101  display: inline-flex;
102  align-items: center;
103  justify-content: center;
104  min-height: 44px;
105  padding: 0 20px;
106  border-radius: var(--radius-md);
107  border: 1px solid rgba(255, 255, 255, 0.18);
108  background: rgba(255, 255, 255, 0.04);
109  color: var(--text-primary);
110  font: inherit;
111  font-size: var(--font-body-md);
112  font-weight: 650;
113  cursor: pointer;
114  transition: background 0.15s ease, border-color 0.15s ease;
115}
116
117.contact-btn:hover {
118  background: rgba(255, 255, 255, 0.09);
119  border-color: rgba(255, 255, 255, 0.3);
120}
121
122.contact-btn.primary {
123  background: var(--accent-primary);
124  border-color: var(--accent-primary);
125  color: var(--bg-gradient-start);
126}
127
128.contact-btn.primary:hover {
129  background: rgba(var(--accent-primary-rgb), 0.82);
130}
131
132.contact-btn:disabled {
133  cursor: not-allowed;
134  opacity: 0.6;
135}
136
137/* Hero */
138.contact-hero {
139  display: grid;
140  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
141  gap: 56px;
142  align-items: end;
143  padding: 72px 0 64px;
144  animation: contact-arrive 0.45s ease-out both;
145}
146
147@keyframes contact-arrive {
148  from { opacity: 0; transform: translateY(8px); }
149  to { opacity: 1; transform: none; }
150}
151
152.contact-lede {
153  margin: 20px 0 28px;
154  max-width: 34ch;
155}
156
157.contact .contact-lede {
158  font-size: var(--font-body-lg);
159  color: var(--text-primary);
160}
161
162.contact-install {
163  border: 1px solid rgba(255, 255, 255, 0.1);
164  border-radius: var(--radius-lg);
165  background: rgba(0, 0, 0, 0.35);
166}
167
168.contact .contact-install-title {
169  padding: 14px 18px;
170  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
171  font-size: var(--font-body-md);
172  font-weight: 650;
173  letter-spacing: 0;
174}
175
176.contact-command {
177  padding: 14px 18px 16px;
178}
179
180.contact-command + .contact-command {
181  border-top: 1px solid rgba(255, 255, 255, 0.06);
182}
183
184.contact-command-head {
185  display: flex;
186  align-items: center;
187  justify-content: space-between;
188  gap: var(--spacing-md);
189  margin-bottom: 8px;
190}
191
192.contact .contact-command-label {
193  font-size: var(--font-caption-md);
194  color: var(--text-tertiary);
195}
196
197.contact-command-text {
198  display: block;
199  font-family: var(--font-mono);
200  font-size: var(--font-caption-md);
201  line-height: 1.55;
202  color: var(--text-primary);
203  white-space: pre-wrap;
204  overflow-wrap: anywhere;
205}
206
207.contact-copy {
208  flex: none;
209  min-width: 76px;
210}
211
212.contact-copy.copied {
213  border-color: var(--color-success);
214  color: var(--color-success);
215}
216
217.contact-visually-hidden {
218  position: absolute;
219  width: 1px;
220  height: 1px;
221  overflow: hidden;
222  clip: rect(0 0 0 0);
223  clip-path: inset(50%);
224  white-space: nowrap;
225}
226
227/* Sections */
228.contact-section {
229  padding-top: 88px;
230}
231
232.contact-section-head {
233  max-width: 64ch;
234  margin-bottom: 32px;
235}
236
237.contact-section-head p { margin-top: 10px; }
238
239/* For vibe coders */
240.contact .contact-vibe-lead {
241  margin: 14px 0 24px;
242  max-width: 44ch;
243  font-size: var(--font-body-lg);
244  color: var(--text-primary);
245}
246
247/* Plans */
248.contact .contact-plan-price {
249  margin-top: 6px;
250  font-size: var(--font-heading-md);
251  font-weight: 750;
252  color: var(--text-primary);
253}
254
255/* Use cases */
256/* Contact */
257.contact-contact {
258  display: grid;
259  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
260  gap: 48px;
261}
262
263.contact-contact-intro p { margin-top: 12px; }
264
265.contact .contact-next { color: var(--text-tertiary); }
266
267.lead-form {
268  position: relative;
269  padding: 28px;
270  border: 1px solid rgba(255, 255, 255, 0.12);
271  border-radius: var(--radius-xl);
272  background: rgba(255, 255, 255, 0.03);
273  display: grid;
274  gap: 20px;
275}
276
277.lead-field { display: grid; gap: 8px; }
278
279.lead-field label {
280  font-size: var(--font-body-sm);
281  font-weight: 650;
282  color: var(--text-primary);
283}
284
285.lead-field .lead-need {
286  font-weight: 400;
287  color: var(--text-tertiary);
288}
289
290.lead-field input {
291  width: 100%;
292  height: 48px;
293  padding: 0 14px;
294  border-radius: var(--radius-md);
295  border: 1px solid rgba(255, 255, 255, 0.18);
296  background: rgba(0, 0, 0, 0.35);
297  color: var(--text-primary);
298  font: inherit;
299  font-size: var(--font-body-md);
300}
301
302.lead-field input[aria-invalid="true"] { border-color: var(--color-error); }
303
304.contact .lead-hint {
305  font-size: var(--font-caption-md);
306  color: var(--text-tertiary);
307}
308
309.contact .lead-error {
310  font-size: var(--font-caption-md);
311  color: var(--color-error);
312}
313
314.lead-trap {
315  position: absolute;
316  left: -10000px;
317  width: 1px;
318  height: 1px;
319  overflow: hidden;
320}
321
322.lead-submit { justify-self: start; }
323
324.contact .lead-privacy,
325.contact .lead-fallback { font-size: var(--font-caption-md); }
326
327.lead-privacy a,
328.lead-fallback a,
329.lead-status a {
330  color: var(--accent-primary);
331  text-decoration: underline;
332  text-underline-offset: 3px;
333}
334
335.lead-status {
336  padding: 16px 18px;
337  border-radius: var(--radius-md);
338  border: 1px solid rgba(255, 255, 255, 0.14);
339}
340
341/* Empty until the form has something to say: out of the flow, so it leaves no gap, but still in the
342   page, so a reader hears it arrive. Turnstile's box likewise, until the widget needs the visitor. */
343.lead-status:empty,
344.lead-turnstile:empty {
345  position: absolute;
346  padding: 0;
347  border: 0;
348}
349
350.lead-status.sent { border-color: var(--color-success); }
351.lead-status.failed { border-color: var(--color-error); }
352.lead-status:focus { outline: none; }
353.lead-status:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
354
355.contact .lead-status-title {
356  font-weight: 650;
357  color: var(--text-primary);
358}
359
360.lead-status p + p { margin-top: 8px; }
361
362/* FAQ */
363.contact-faq {
364  display: grid;
365  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
366  gap: 28px 48px;
367}
368
369.contact-faq dt {
370  font-size: var(--font-body-lg);
371  font-weight: 700;
372  line-height: 1.4;
373  color: var(--text-primary);
374}
375
376.contact-faq dd { margin: 8px 0 0; }
377
378.contact-faq dd a {
379  color: var(--accent-primary);
380  text-decoration: underline;
381  text-underline-offset: 3px;
382}
383
384.contact .contact-faq-note {
385  margin-top: 28px;
386  font-size: var(--font-caption-md);
387  color: var(--text-tertiary);
388}
389
390@media (max-width: 900px) {
391  .contact-hero,
392  .contact-vibe,
393  .contact-plans,
394  .contact-contact,
395  .contact-faq {
396    grid-template-columns: minmax(0, 1fr);
397  }
398
399  .contact-hero { gap: 40px; padding: 48px 0 40px; }
400  .contact-vibe,
401  .contact-contact { gap: 28px; }
402}
403
404@media (max-width: 640px) {
405  .contact-main { padding: 0 var(--spacing-lg) 72px; }
406  .contact-command,
407  .contact .contact-install-title { padding-left: var(--spacing-md); padding-right: var(--spacing-md); }
408  .contact h1 { font-size: var(--font-display-md); }
409  .contact-section { padding-top: 64px; }
410  .contact-cases { grid-template-columns: minmax(0, 1fr); }
411  .contact .contact-try-label { flex-basis: 100%; }
412  .contact-vibe-file,
413  .contact-vibe-output { padding-left: var(--spacing-lg); padding-right: var(--spacing-lg); }
414  .contact-vibe-code {
415    padding: var(--spacing-lg);
416    font-size: var(--font-caption-md);
417  }
418  .contact-plan,
419  .contact-case,
420  .lead-form { padding: 20px; }
421  .contact-actions .contact-btn,
422  .lead-submit { width: 100%; }
423  .contact .contact-license-table td:last-child { width: auto; }
424}
425
426@media (prefers-reduced-motion: reduce) {
427  .contact *,
428  .contact *::before,
429  .contact *::after {
430    animation: none !important;
431    transition: none !important;
432  }
433}
434"#;
435
436#[component]
437pub fn Contact(section: String) -> Element {
438    // `/contact#<section>` opens at that section, on arrival and on any later link to one.
439    use_effect(use_reactive!(|section| {
440        if !section.is_empty() {
441            scroll_to_section(section);
442        }
443    }));
444
445    let faq_pairs: Vec<(&str, &str)> = content::faq().iter().map(|f| (f.question.as_str(), f.answer.as_str())).collect();
446    let schemas = vec![
447        organization_contact_schema(TEAM_EMAIL, "/contact#contact"),
448        faq_schema(&faq_pairs),
449        breadcrumb_schema(&[BreadcrumbItem { name: "Home", path: "/" }, BreadcrumbItem { name: "Contact", path: "/contact" }]),
450    ];
451
452    rsx! {
453        PageHead {
454            title: seo_pages::CONTACT.title,
455            description: seo_pages::CONTACT.description,
456            canonical_path: seo_pages::CONTACT.canonical_path,
457        }
458        style { "{CONTACT_STYLE}" }
459        JsonLdMultiple { schemas }
460
461        div { class: "contact",
462            MainNav { active: ActivePage::Contact, subtitle: Some("Contact") }
463            main { class: "contact-main",
464                Hero {}
465                ContactSection {}
466                Questions {}
467            }
468            Footer {}
469        }
470    }
471}
472
473/// A link to a section of this page. Following it also scrolls there, so a second click on a
474/// link to the section the address already names still brings it into view (the route does not
475/// change then, so the page's own scroll on a new section never runs).
476#[component]
477fn SectionLink(
478    #[props(into)] id: String,
479    #[props(into)] class: Option<String>,
480    #[props(into)] aria_label: Option<String>,
481    children: Element,
482) -> Element {
483    let target = id.clone();
484    rsx! {
485        Link {
486            to: Route::Contact { section: id },
487            class,
488            aria_label,
489            onclick: move |_| scroll_to_section(target.clone()),
490            {children}
491        }
492    }
493}
494
495#[component]
496fn Hero() -> Element {
497    rsx! {
498        section { id: "top", class: "contact-hero",
499            div {
500                h1 { "Contact the Team" }
501                p { class: "contact-lede", "{OPEN_SOURCE_LINE}" }
502                div { class: "contact-actions",
503                    SectionLink { id: "contact", class: "contact-btn primary", "{CONTACT_HEADING}" }
504                    Link { to: Route::Studio { file: None }, class: "contact-btn", "Try it in the browser" }
505                }
506            }
507            div { id: "install", class: "contact-install",
508                h2 { class: "contact-install-title", "Install in one line" }
509                InstallCommand { platform: "macOS and Linux", command: INSTALL_UNIX }
510                InstallCommand { platform: "Windows (PowerShell)", command: INSTALL_WINDOWS }
511            }
512        }
513    }
514}
515
516/// One install command with its Copy button. The button copies the constant, never the
517/// wrapped text on screen.
518#[component]
519fn InstallCommand(platform: &'static str, command: &'static str) -> Element {
520    let mut copied = use_signal(|| false);
521    rsx! {
522        div { class: "contact-command",
523            div { class: "contact-command-head",
524                p { class: "contact-command-label", "{platform}" }
525                button {
526                    r#type: "button",
527                    class: if copied() { "contact-btn contact-copy copied" } else { "contact-btn contact-copy" },
528                    aria_label: "Copy the {platform} install command",
529                    onclick: move |_| {
530                        copy_to_clipboard(command);
531                        copied.set(true);
532                    },
533                    if copied() { "Copied" } else { "Copy" }
534                }
535            }
536            code { class: "contact-command-text", "{command}" }
537            span { class: "contact-visually-hidden", aria_live: "polite",
538                if copied() { "Copied the {platform} install command" }
539            }
540        }
541    }
542}
543
544fn copy_to_clipboard(text: &str) {
545    #[cfg(target_arch = "wasm32")]
546    if let Some(window) = web_sys::window() {
547        let _ = window.navigator().clipboard().write_text(text);
548    }
549    #[cfg(not(target_arch = "wasm32"))]
550    let _ = text;
551}
552
553#[component]
554fn ContactSection() -> Element {
555    rsx! {
556        section { id: "contact", class: "contact-section contact-contact",
557            div { class: "contact-contact-intro",
558                h2 { id: "contact-heading", "{CONTACT_HEADING}" }
559                p {
560                    "Leave your email, and a phone number if you'd like a call. The team will get back to you."
561                }
562                p { class: "contact-next",
563                    "What happens next: an engineer replies, with a few questions about how you plan to use LOGOS."
564                }
565            }
566            LeadForm {}
567        }
568    }
569}
570
571#[component]
572fn Questions() -> Element {
573    rsx! {
574        section { id: "faq", class: "contact-section",
575            div { class: "contact-section-head",
576                h2 { "FAQ" }
577            }
578            dl { class: "contact-faq",
579                for item in content::faq() {
580                    div { class: "contact-faq-item",
581                        dt { "{item.question}" }
582                        dd { FaqAnswer { item: item } }
583                    }
584                }
585            }
586        }
587    }
588}
589
590/// An answer, with its link run (if any) shown as a link.
591#[component]
592fn FaqAnswer(item: &'static Faq) -> Element {
593    let Some(link) = &item.link else {
594        return rsx! { "{item.answer}" };
595    };
596    let (before, after) = item.answer.split_once(link.text).unwrap_or((item.answer.as_str(), ""));
597    rsx! {
598        "{before}"
599        a { href: link.href, target: "_blank", rel: "noopener noreferrer", "{link.text}" }
600        "{after}"
601    }
602}