logicaffeine_web/ui/pages/contact/
mod.rs1pub 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 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#[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#[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#[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}