import { createSignal, onMount, onCleanup, Show } from "solid-js"; import { useSearchParams, useNavigate, useLocation, query, createAsync } from "@solidjs/router"; import { Title, Meta } from "@solidjs/meta"; import { A } from "@solidjs/router"; import { action, redirect } from "@solidjs/router"; import { api } from "~/lib/api"; import { getClientCookie, setClientCookie } from "~/lib/cookies.client"; import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import LoadingSpinner from "~/components/LoadingSpinner"; import RevealDropDown from "~/components/RevealDropDown"; import type { UserProfile } from "~/types/user"; import { getCookie, setCookie } from "vinxi/http"; import { z } from "zod"; import { env } from "~/env/server"; import { fetchWithTimeout, checkResponse, fetchWithRetry, NetworkError, TimeoutError, APIError } from "~/server/fetch-utils"; import { NETWORK_CONFIG, COOLDOWN_TIMERS, VALIDATION_CONFIG, COUNTDOWN_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 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")}` ); } 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 location = useLocation(); const navigate = useNavigate(); const viewer = () => searchParams.viewer ?? "default"; // Load server data using createAsync const contactData = createAsync(() => getContactData(), { deferStream: true }); const [countDown, setCountDown] = createSignal(0); const [emailSent, setEmailSent] = createSignal( searchParams.success === "true" ); const [error, setError] = createSignal( searchParams.error ? decodeURIComponent(searchParams.error) : "" ); const [loading, setLoading] = createSignal(false); const [user, setUser] = createSignal(null); const [jsEnabled, setJsEnabled] = createSignal(false); let timerIdRef: ReturnType | null = null; const calcRemainder = (timer: string) => { const expires = new Date(timer); const remaining = expires.getTime() - Date.now(); const remainingInSeconds = remaining / 1000; if (remainingInSeconds <= 0) { setCountDown(0); if (timerIdRef !== null) { clearInterval(timerIdRef); } } else { setCountDown(remainingInSeconds); } }; onMount(() => { setJsEnabled(true); const serverData = contactData(); if (serverData?.remainingTime) { setCountDown(serverData.remainingTime); } const timer = getClientCookie("contactRequestSent"); if (timer) { timerIdRef = setInterval(() => calcRemainder(timer), 1000); } api.user.getProfile .query() .then((userData) => { if (userData) { setUser(userData); } }) .catch(() => {}); if (searchParams.success || searchParams.error) { const timer = setTimeout(() => { const newUrl = location.pathname + (viewer() !== "default" ? `?viewer=${viewer()}` : ""); navigate(newUrl, { replace: true }); setEmailSent(false); setError(""); }, 5000); onCleanup(() => clearTimeout(timer)); } onCleanup(() => { if (timerIdRef !== null) { clearInterval(timerIdRef); } }); }); const sendEmailTrigger = async (e: Event) => { if (!jsEnabled()) return; e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); 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) { setLoading(true); setError(""); setEmailSent(false); try { const res = await api.misc.sendContactRequest.mutate({ name, email, message }); if (res.message === "email sent") { setEmailSent(true); setError(""); (e.target as HTMLFormElement).reset(); const timer = getClientCookie("contactRequestSent"); if (timer) { if (timerIdRef !== null) { clearInterval(timerIdRef); } timerIdRef = setInterval(() => calcRemainder(timer), 1000); } } } 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 }) => { return (
{remainingTime.toFixed(0)}
); }; return ( <> Contact | Michael Freno
Contact
(for this website or any of my apps...)