import { createSignal, onMount, createEffect, Show } from "solid-js"; import { useSearchParams, query, createAsync } from "@solidjs/router"; import { A } from "@solidjs/router"; import { action, redirect } from "@solidjs/router"; import { PageHead } from "~/components/PageHead"; import { api } from "~/lib/api"; import { getClientCookie } from "~/lib/cookies.client"; import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import RevealDropDown from "~/components/RevealDropDown"; import Input from "~/components/ui/Input"; import { Button } from "~/components/ui/Button"; import { useCountdown } from "~/lib/useCountdown"; import type { UserProfile } from "~/types/user"; import { getCookie, setCookie } from "vinxi/http"; import { z } from "zod"; import { env as clientEnv } from "~/env/client"; import { fetchWithTimeout, checkResponse, fetchWithRetry, NetworkError, TimeoutError, APIError, verifyTurnstileToken } from "~/server/fetch-utils"; import { NETWORK_CONFIG, COOLDOWN_TIMERS, VALIDATION_CONFIG, COUNTDOWN_CONFIG, TURNSTILE_CONFIG } from "~/config"; const getContactData = query(async () => { "use server"; const contactExp = getCookie("contactRequestSent"); let remainingTime = 0; if (contactExp) { const expires = new Date(contactExp); remainingTime = Math.max(0, (expires.getTime() - Date.now()) / 1000); } return { remainingTime }; }, "contact-data"); const sendContactEmail = action(async (formData: FormData) => { "use server"; const name = formData.get("name") as string; const email = formData.get("email") as string; const message = formData.get("message") as string; const turnstileToken = formData.get("cf-turnstile-response") as string; const schema = z.object({ name: z.string().min(1, "Name is required"), email: z.string().email("Valid email is required"), message: z .string() .min(1, "Message is required") .max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH, "Message too long") }); try { schema.parse({ name, email, message }); } catch (err: any) { return redirect( `/contact?error=${encodeURIComponent(err.errors[0]?.message || "Invalid input")}` ); } // Verify Cloudflare Turnstile token const turnstileValid = await verifyTurnstileToken( turnstileToken, env.TURNSTILE_SECRET_KEY, TURNSTILE_CONFIG.VERIFY_URL, TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS ); if (!turnstileValid) { return redirect( "/contact?error=Security verification failed. Please refresh and try again." ); } const contactExp = getCookie("contactRequestSent"); if (contactExp) { const expires = new Date(contactExp); const remaining = expires.getTime() - Date.now(); if (remaining > 0) { return redirect( "/contact?error=Please wait before sending another message" ); } } const apiKey = env.SENDINBLUE_KEY; const apiUrl = "https://api.sendinblue.com/v3/smtp/email"; const sendinblueData = { sender: { name: "freno.me", email: "michael@freno.me" }, to: [{ email: "michael@freno.me" }], htmlContent: `
Request Name: ${name}
Request Email: ${email}
Request Message: ${message}
`, subject: "freno.me Contact Request" }; try { await fetchWithRetry( async () => { const response = await fetchWithTimeout(apiUrl, { method: "POST", headers: { accept: "application/json", "api-key": apiKey, "content-type": "application/json" }, body: JSON.stringify(sendinblueData), timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS }); await checkResponse(response); return response; }, { maxRetries: NETWORK_CONFIG.MAX_RETRIES, retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS } ); const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS); setCookie("contactRequestSent", exp.toUTCString(), { expires: exp, path: "/" }); return redirect("/contact?success=true"); } catch (error) { let errorMessage = "Failed to send message. You can reach me at michael@freno.me"; if (error instanceof TimeoutError) { errorMessage = "Email service timed out. Please try again or contact michael@freno.me"; } else if (error instanceof NetworkError) { errorMessage = "Network error. Please try again or contact michael@freno.me"; } else if (error instanceof APIError) { errorMessage = "Email service error. You can reach me at michael@freno.me"; } return redirect(`/contact?error=${encodeURIComponent(errorMessage)}`); } }); export default function ContactPage() { const [searchParams] = useSearchParams(); const viewer = () => searchParams.viewer ?? "default"; // Load server data using createAsync const contactData = createAsync(() => getContactData(), { deferStream: true }); const [emailSent, setEmailSent] = createSignal( searchParams.success === "true" ); const [error, setError] = createSignal( searchParams.error ? decodeURIComponent(String(searchParams.error)) : "" ); const [loading, setLoading] = createSignal(false); const [user, setUser] = createSignal(null); const [jsEnabled, setJsEnabled] = createSignal(false); const [turnstileToken, setTurnstileToken] = createSignal(""); const [turnstileWidgetId, setTurnstileWidgetId] = createSignal( null ); const { remainingTime, startCountdown, setRemainingTime } = useCountdown(); onMount(() => { setJsEnabled(true); // Load Cloudflare Turnstile script with explicit rendering const script = document.createElement("script"); script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; script.async = true; script.defer = true; script.onload = () => { if (typeof window !== "undefined" && (window as any).turnstile) { const container = document.getElementById("turnstile-widget-1"); if (container) { const id = (window as any).turnstile.render(container, { sitekey: clientEnv.VITE_TURNSTILE_SITE_KEY, theme: "dark", callback: (token: string) => { setTurnstileToken(token); }, "expired-callback": () => { setTurnstileToken(""); } }); setTurnstileWidgetId(id); } } }; document.head.appendChild(script); api.user.getProfile .query() .then((userData) => { if (userData) { setUser(userData); } }) .catch(() => {}); }); createEffect(() => { // Try server data first (more accurate) const serverData = contactData(); if (serverData?.remainingTime && serverData.remainingTime > 0) { const expirationTime = new Date( Date.now() + serverData.remainingTime * 1000 ); startCountdown(expirationTime); return; } // Fall back to client cookie if server data not available yet const timer = getClientCookie("contactRequestSent"); if (timer) { try { startCountdown(timer); } catch (e) { console.error("Failed to start countdown from cookie:", e); } } }); const sendEmailTrigger = async (e: Event) => { if (!jsEnabled()) return; e.preventDefault(); const form = e.target as unknown as HTMLFormElement; const formData = new FormData(form); const name = formData.get("name") as string; const email = formData.get("email") as string; const message = formData.get("message") as string; if (name && email && message) { // Get fresh Turnstile token let currentToken = turnstileToken(); if ( !currentToken && typeof window !== "undefined" && (window as any).turnstile ) { const widgetEl = document.getElementById("turnstile-widget-1"); if (widgetEl) { const id = turnstileWidgetId(); currentToken = (window as any).turnstile.getResponse(id || widgetEl); } } if (!currentToken || currentToken.trim() === "") { setError("Please complete the security check."); setLoading(false); return; } setLoading(true); setError(""); setEmailSent(false); try { const res = await api.misc.sendContactRequest.mutate({ name, email, message, turnstileToken: currentToken }); if (res.message === "email sent") { setEmailSent(true); setError(""); form.reset(); // Reset Turnstile widget if (typeof window !== "undefined" && (window as any).turnstile) { const widgetEl = document.getElementById("turnstile-widget-1"); if (widgetEl) { const id = turnstileWidgetId(); (window as any).turnstile.reset(id || widgetEl); } } setTurnstileToken(""); // Set countdown directly - cookie might not be readable immediately const expirationTime = new Date( Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS ); startCountdown(expirationTime); } } catch (err: any) { setError(err.message || "An error occurred"); setEmailSent(false); } setLoading(false); } }; const LineageQuestionsDropDown = () => { return (
Feel free to use the form below, I will respond as quickly as possible, however, you may find an answer to your question in the following.
    1. Personal Information
    You can find the entire privacy policy{" "} here .
    2. Remote Backups
    Life and Lineage uses a per-user database approach for its remote storage, this provides better separation of users and therefore privacy, and it makes requesting the removal of your data simpler, you can even request the database dump if you so choose. This isn't particularly expensive, but not free for n users, so use of this feature requires a purchase of an IAP(in-app purchase) - this can be the specific IAP for the remote save feature, and any other IAP will also unlock this feature.
    3. Cross Device Play
    You can use the above mentioned remote-backups to save progress between devices/platforms.
    4. Online Requirements
    Currently, the only time you need to be online is for remote save access. There are plans for pvp, which will require an internet connection, but this is not implemented at time of writing.
    5. Microtransactions
    Microtransactions are not required to play or complete the game, the game can be fully completed without spending any money, however 2 of the classes(necromancer and ranger) are pay-walled. Microtransactions are supported cross-platform, so no need to pay for each device, you simply need to login to your gmail/apple/email account. This would require first creating a character, signing in under options{">"}remote backups first.
); }; const renderTime = ({ remainingTime }: { remainingTime: number }) => { const time = isNaN(remainingTime) ? 0 : Math.max(0, remainingTime); return (
{time.toFixed(0)}
); }; return ( <>
Contact
(for this website or any of my apps...)