mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
Add OPEN_REGISTRATION gate, signup request form, favicon
This commit is contained in:
@@ -29,6 +29,9 @@ export async function loginAction(
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => null);
|
||||
if (detail?.detail === "registration_closed") {
|
||||
redirect("/signup");
|
||||
}
|
||||
const message = detail?.detail ?? "Unable to sign in.";
|
||||
return { error: message };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use server";
|
||||
|
||||
import { verifyTurnstile } from "../../../lib/turnstile";
|
||||
|
||||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||
const CONTACT_EMAIL = process.env.CONTACT_EMAIL || "sales@such.software";
|
||||
const SITE_NAME = process.env.NEXT_PUBLIC_SITE_NAME || "XMR Checkout";
|
||||
|
||||
export type SignupState = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
errors?: Record<string, string>;
|
||||
};
|
||||
|
||||
export async function submitSignupRequest(
|
||||
_prevState: SignupState,
|
||||
formData: FormData
|
||||
): Promise<SignupState> {
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
const email = String(formData.get("email") ?? "").trim();
|
||||
const storeUrl = String(formData.get("store_url") ?? "").trim();
|
||||
const message = String(formData.get("message") ?? "").trim();
|
||||
const token = String(formData.get("token") ?? "").trim();
|
||||
|
||||
const errors: Record<string, string> = {};
|
||||
if (name.length < 2) errors.name = "Name is too short.";
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
||||
errors.email = "Invalid email address.";
|
||||
if (message.length < 10) errors.message = "Please tell us more.";
|
||||
if (!token) errors.token = "Please complete the security check.";
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { success: false, message: "", errors };
|
||||
}
|
||||
|
||||
const isHuman = await verifyTurnstile(token);
|
||||
if (!isHuman) {
|
||||
return { success: false, message: "Security check failed. Please refresh." };
|
||||
}
|
||||
|
||||
if (!RESEND_API_KEY) {
|
||||
console.warn("RESEND_API_KEY not set, logging signup request instead");
|
||||
console.log("Signup request:", { name, email, storeUrl, message });
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
"Request received. We will review your application and get back to you.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { Resend } = await import("resend");
|
||||
const resend = new Resend(RESEND_API_KEY);
|
||||
|
||||
const subject = `[${SITE_NAME}] Merchant access request from ${name}`;
|
||||
|
||||
const { error } = await resend.emails.send({
|
||||
from: `${SITE_NAME} <noreply@such.software>`,
|
||||
to: CONTACT_EMAIL,
|
||||
replyTo: email,
|
||||
subject,
|
||||
text: [
|
||||
`Name: ${name}`,
|
||||
`Email: ${email}`,
|
||||
`Store URL: ${storeUrl || "(not provided)"}`,
|
||||
"",
|
||||
"Message:",
|
||||
message,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<h2>Merchant Access Request</h2>
|
||||
<p><strong>Name:</strong> ${name}</p>
|
||||
<p><strong>Email:</strong> <a href="mailto:${email}">${email}</a></p>
|
||||
<p><strong>Store URL:</strong> ${storeUrl ? `<a href="${storeUrl}">${storeUrl}</a>` : "<em>not provided</em>"}</p>
|
||||
<h3>Message:</h3>
|
||||
<p>${message.replace(/\n/g, "<br>")}</p>
|
||||
`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Resend error:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to send request. Please try again.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
"Request received. We will review your application and get back to you.",
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Email error:", e);
|
||||
return {
|
||||
success: false,
|
||||
message: "System error. Please try again or contact us directly.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Metadata } from "next";
|
||||
import SignupForm from "./signup-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Request Access",
|
||||
};
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="section-inner" style={{ maxWidth: 560 }}>
|
||||
<p className="eyebrow">Merchant access</p>
|
||||
<h1 className="section-title">Request access</h1>
|
||||
<p className="mt-4 text-ink-soft">
|
||||
Tell us about your store and we will get you set up. Once approved
|
||||
you will receive your credentials by email.
|
||||
</p>
|
||||
<SignupForm />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
import TurnstileWidget from "../../../components/turnstile-widget";
|
||||
import { submitSignupRequest, type SignupState } from "./actions";
|
||||
|
||||
const initialState: SignupState = { success: false, message: "" };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:bg-ink/60 disabled:text-cream/70 disabled:shadow-none disabled:hover:translate-y-0"
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? "Sending..." : "Request Access"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignupForm() {
|
||||
const [state, formAction] = useActionState(submitSignupRequest, initialState);
|
||||
const [token, setToken] = useState("");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.success && formRef.current) {
|
||||
formRef.current.reset();
|
||||
setToken("");
|
||||
}
|
||||
}, [state.success]);
|
||||
|
||||
return (
|
||||
<form ref={formRef} className="mt-6 grid gap-5" action={formAction}>
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||
htmlFor="name"
|
||||
>
|
||||
Your name
|
||||
</label>
|
||||
<input
|
||||
className="min-h-[48px] w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
/>
|
||||
{state.errors?.name ? (
|
||||
<p className="text-xs text-red-600">{state.errors.name}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
className="min-h-[48px] w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
{state.errors?.email ? (
|
||||
<p className="text-xs text-red-600">{state.errors.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||
htmlFor="store_url"
|
||||
>
|
||||
Store URL <span className="normal-case tracking-normal text-ink-soft/60">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
className="min-h-[48px] w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||
id="store_url"
|
||||
name="store_url"
|
||||
type="url"
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||
htmlFor="message"
|
||||
>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
className="min-h-[120px] w-full resize-none rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||
id="message"
|
||||
name="message"
|
||||
placeholder="Tell us about your store and how you plan to accept payments..."
|
||||
required
|
||||
/>
|
||||
{state.errors?.message ? (
|
||||
<p className="text-xs text-red-600">{state.errors.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="token" value={token} />
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between border-t border-stroke pt-4">
|
||||
<TurnstileWidget onVerify={setToken} />
|
||||
<SubmitButton />
|
||||
</div>
|
||||
|
||||
{state.message ? (
|
||||
<p
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-semibold ${
|
||||
state.success
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
: "border-red-200 bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{state.message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Reference in New Issue
Block a user