Skip to main content

logicaffeine_web/ui/pages/
success.rs

1//! Payment success and license activation page.
2//!
3//! Post-checkout page that retrieves the license key from Stripe's checkout
4//! session and saves it to the browser's localStorage. Features:
5//!
6//! - Automatic license retrieval from checkout session
7//! - License key display with copy-to-clipboard
8//! - Automatic persistence to localStorage
9//! - Links to Studio and subscription management
10//!
11//! # Route
12//!
13//! Accessed via [`Route::Success`] with
14//! a `session_id` query parameter from Stripe.
15
16use dioxus::prelude::*;
17use crate::ui::router::Route;
18use crate::ui::state::{LicenseState, LicensePlan};
19use crate::ui::components::main_nav::{MainNav, ActivePage};
20use crate::ui::seo::PageHead;
21
22const LICENSE_API_URL: &str = "https://api.logicaffeine.com/session";
23
24const SUCCESS_STYLE: &str = r#"
25.success-container {
26    min-height: 100vh;
27    display: flex;
28    flex-direction: column;
29    align-items: center;
30    justify-content: center;
31    padding: 60px 20px;
32    text-align: center;
33    max-width: 600px;
34    margin: 0 auto;
35}
36
37.success-icon {
38    width: 80px;
39    height: 80px;
40    background: linear-gradient(135deg, #00d4ff 0%, #7b2cbf 100%);
41    border-radius: 50%;
42    display: flex;
43    align-items: center;
44    justify-content: center;
45    margin-bottom: 32px;
46    font-size: 40px;
47}
48
49.success-title {
50    font-size: 36px;
51    font-weight: 700;
52    color: #fff;
53    margin-bottom: 16px;
54}
55
56.success-message {
57    color: #aaa;
58    font-size: 18px;
59    line-height: 1.6;
60    margin-bottom: 32px;
61}
62
63.license-box {
64    background: rgba(0, 212, 255, 0.1);
65    border: 1px solid rgba(0, 212, 255, 0.3);
66    border-radius: 12px;
67    padding: 24px;
68    margin-bottom: 32px;
69    width: 100%;
70    max-width: 400px;
71}
72
73.license-label {
74    color: #00d4ff;
75    font-size: 14px;
76    font-weight: 600;
77    margin-bottom: 12px;
78    text-transform: uppercase;
79    letter-spacing: 1px;
80}
81
82.license-key {
83    background: rgba(0, 0, 0, 0.3);
84    border: 1px solid rgba(255, 255, 255, 0.1);
85    border-radius: 8px;
86    padding: 16px;
87    font-family: monospace;
88    font-size: 14px;
89    color: #fff;
90    word-break: break-all;
91    margin-bottom: 12px;
92}
93
94.copy-btn {
95    background: linear-gradient(135deg, #00d4ff 0%, #7b2cbf 100%);
96    color: white;
97    border: none;
98    padding: 10px 20px;
99    border-radius: 8px;
100    font-size: 14px;
101    font-weight: 600;
102    cursor: pointer;
103    transition: all 0.2s ease;
104}
105
106.copy-btn:hover {
107    transform: translateY(-1px);
108    box-shadow: 0 4px 12px rgba(0, 212, 255, 0.3);
109}
110
111.license-saved {
112    color: #4ade80;
113    font-size: 13px;
114    margin-top: 12px;
115}
116
117.success-actions {
118    display: flex;
119    flex-direction: column;
120    gap: 16px;
121    width: 100%;
122    max-width: 320px;
123}
124
125.btn-primary {
126    display: block;
127    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
128    color: white;
129    padding: 16px 32px;
130    border-radius: 12px;
131    font-size: 16px;
132    font-weight: 600;
133    text-decoration: none;
134    text-align: center;
135    transition: all 0.2s ease;
136}
137
138.btn-primary:hover {
139    transform: translateY(-2px);
140    box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
141}
142
143.btn-secondary {
144    display: block;
145    background: transparent;
146    color: #667eea;
147    padding: 16px 32px;
148    border: 1px solid #667eea;
149    border-radius: 12px;
150    font-size: 16px;
151    font-weight: 500;
152    text-decoration: none;
153    text-align: center;
154    transition: all 0.2s ease;
155}
156
157.btn-secondary:hover {
158    background: rgba(102, 126, 234, 0.1);
159}
160
161.success-note {
162    margin-top: 40px;
163    padding: 20px;
164    background: rgba(255, 255, 255, 0.05);
165    border-radius: 12px;
166    border: 1px solid rgba(255, 255, 255, 0.1);
167}
168
169.success-note p {
170    color: #888;
171    font-size: 14px;
172    margin: 0;
173}
174
175.success-note a {
176    color: #00d4ff;
177    text-decoration: none;
178}
179
180.success-note a:hover {
181    text-decoration: underline;
182}
183
184.loading-spinner {
185    width: 40px;
186    height: 40px;
187    border: 3px solid rgba(0, 212, 255, 0.3);
188    border-top: 3px solid #00d4ff;
189    border-radius: 50%;
190    animation: spin 1s linear infinite;
191    margin: 20px auto;
192}
193
194@keyframes spin {
195    0% { transform: rotate(0deg); }
196    100% { transform: rotate(360deg); }
197}
198
199.error-message {
200    color: #ef4444;
201    background: rgba(239, 68, 68, 0.1);
202    border: 1px solid rgba(239, 68, 68, 0.3);
203    border-radius: 8px;
204    padding: 16px;
205    margin-bottom: 24px;
206}
207"#;
208
209const STRIPE_CUSTOMER_PORTAL: &str = "https://billing.stripe.com/p/login/8x200l3VN98D7qa1SMe3e00";
210
211fn save_license_to_storage(license_key: &str, plan: &str) {
212    if let Some(window) = web_sys::window() {
213        if let Ok(Some(storage)) = window.local_storage() {
214            let _ = storage.set_item("logos_license_key", license_key);
215            let _ = storage.set_item("logos_license_plan", plan);
216            let timestamp = js_sys::Date::now().to_string();
217            let _ = storage.set_item("logos_license_validated_at", &timestamp);
218        }
219    }
220}
221
222fn copy_to_clipboard(text: &str) {
223    if let Some(window) = web_sys::window() {
224        let clipboard = window.navigator().clipboard();
225        let _ = clipboard.write_text(text);
226    }
227}
228
229#[derive(Clone, PartialEq)]
230enum LicenseStatus {
231    Loading,
232    Success { subscription_id: String, plan: String },
233    Error(String),
234    NoSession,
235}
236
237async fn fetch_license_from_session(session_id: String) -> LicenseStatus {
238    use gloo_net::http::Request;
239
240    let body = serde_json::json!({ "sessionId": session_id });
241
242    let response = Request::post(LICENSE_API_URL)
243        .header("Content-Type", "application/json")
244        .body(body.to_string())
245        .unwrap()
246        .send()
247        .await;
248
249    match response {
250        Ok(resp) => {
251            if resp.ok() {
252                match resp.json::<serde_json::Value>().await {
253                    Ok(data) => {
254                        let subscription_id = data["subscriptionId"]
255                            .as_str()
256                            .unwrap_or("")
257                            .to_string();
258                        let plan = data["plan"]
259                            .as_str()
260                            .unwrap_or("unknown")
261                            .to_string();
262                        LicenseStatus::Success { subscription_id, plan }
263                    }
264                    Err(_) => LicenseStatus::Error("Failed to parse response".to_string()),
265                }
266            } else {
267                LicenseStatus::Error("License lookup failed".to_string())
268            }
269        }
270        Err(e) => LicenseStatus::Error(format!("Network error: {}", e)),
271    }
272}
273
274#[component]
275pub fn Success(session_id: Option<String>) -> Element {
276    let mut license_status = use_signal(|| LicenseStatus::Loading);
277    let mut copied = use_signal(|| false);
278    let mut saved = use_signal(|| false);
279    let license_state = use_context::<LicenseState>();
280
281    use_effect(move || {
282        // Sessionless visits serialize as `/success?` — keep the bar canonical.
283        #[cfg(target_arch = "wasm32")]
284        if session_id.is_none() {
285            crate::ui::router::replace_bar_url("/success");
286        }
287
288        let mut license_state = license_state.clone();
289        let session_id = session_id.clone();
290        spawn(async move {
291            if let Some(session_id) = session_id {
292                let result = fetch_license_from_session(session_id).await;
293                if let LicenseStatus::Success { ref subscription_id, ref plan } = result {
294                    save_license_to_storage(subscription_id, plan);
295                    license_state.set_license(
296                        subscription_id.clone(),
297                        LicensePlan::from_str(plan),
298                    );
299                    saved.set(true);
300                }
301                license_status.set(result);
302            } else {
303                license_status.set(LicenseStatus::NoSession);
304            }
305        });
306    });
307
308    let on_copy = move |_| {
309        if let LicenseStatus::Success { ref subscription_id, .. } = *license_status.read() {
310            copy_to_clipboard(subscription_id);
311            copied.set(true);
312        }
313    };
314
315    let (has_license, license_key) = match &*license_status.read() {
316        LicenseStatus::Success { subscription_id, .. } => (true, subscription_id.clone()),
317        _ => (false, String::new()),
318    };
319
320    let is_loading = matches!(*license_status.read(), LicenseStatus::Loading);
321
322    rsx! {
323        PageHead {
324            title: "Payment Success - LOGICAFFEINE",
325            description: "Your payment was successful. License activation and key retrieval.",
326            canonical_path: "/success",
327        }
328        // Session-specific post-checkout page: never indexed, never in the sitemap.
329        document::Meta { name: "robots", content: "noindex" }
330        style { "{SUCCESS_STYLE}" }
331
332        MainNav { active: ActivePage::Pricing, subtitle: Some("Payment Complete"), show_nav_links: false }
333
334        div { class: "success-container",
335            div { class: "success-icon", "✓" }
336
337            h1 { class: "success-title", "Thank You!" }
338
339            p { class: "success-message",
340                "Your payment was successful. Welcome to logicaffeine! "
341                "You now have access to use LOGOS for commercial purposes."
342            }
343
344            if is_loading {
345                div { class: "loading-spinner" }
346                p { class: "success-message", "Retrieving your license..." }
347            }
348
349            match &*license_status.read() {
350                LicenseStatus::Error(msg) => rsx! {
351                    div { class: "error-message", "{msg}" }
352                },
353                LicenseStatus::NoSession => rsx! {
354                    div { class: "error-message", "No checkout session found. Please try again." }
355                },
356                _ => rsx! {}
357            }
358
359            if has_license {
360                div { class: "license-box",
361                    div { class: "license-label", "Your License Key" }
362                    div { class: "license-key", "{license_key}" }
363                    button {
364                        class: "copy-btn",
365                        onclick: on_copy,
366                        if *copied.read() { "Copied!" } else { "Copy to Clipboard" }
367                    }
368                    if *saved.read() {
369                        div { class: "license-saved",
370                            "✓ License saved to your browser"
371                        }
372                    }
373                }
374            }
375
376            div { class: "success-actions",
377                Link {
378                    class: "btn-primary",
379                    to: Route::Studio { file: None },
380                    "Open Studio"
381                }
382
383                a {
384                    class: "btn-secondary",
385                    href: STRIPE_CUSTOMER_PORTAL,
386                    target: "_blank",
387                    "Manage Subscription"
388                }
389            }
390
391            div { class: "success-note",
392                p {
393                    "Save your license key somewhere safe. "
394                    "Need help? Contact us at "
395                    a { href: "mailto:[email protected]", "[email protected]" }
396                    "."
397                }
398            }
399        }
400    }
401}