Merged changes from o10aupva/main
Replit-Task-Id: 96838fc6-bf00-4a8d-ae18-84ba08feec56
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@getbrevo/brevo": "^5.0.4",
|
||||||
"@workspace/api-zod": "workspace:*",
|
"@workspace/api-zod": "workspace:*",
|
||||||
"@workspace/db": "workspace:*",
|
"@workspace/db": "workspace:*",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
|||||||
73
artifacts/api-server/src/routes/contact.ts
Normal file
73
artifacts/api-server/src/routes/contact.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Router, type IRouter } from "express";
|
||||||
|
import { BrevoClient } from "@getbrevo/brevo";
|
||||||
|
import { SendContactMessageBody } from "@workspace/api-zod";
|
||||||
|
|
||||||
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
function getBrevoClient() {
|
||||||
|
const apiKey = process.env.BREVO_API_KEY;
|
||||||
|
if (!apiKey) throw new Error("BREVO_API_KEY is not set");
|
||||||
|
return new BrevoClient({ apiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post("/contact", async (req, res) => {
|
||||||
|
const parsed = SendContactMessageBody.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Ungültige Eingabe. Bitte überprüfen Sie Ihre Angaben.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email, subject, message } = parsed.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const brevo = getBrevoClient();
|
||||||
|
|
||||||
|
await brevo.transactionalEmails.sendTransacEmail({
|
||||||
|
sender: { name: "Kontaktformular Portfolio", email: "jh@unixweb.de" },
|
||||||
|
to: [{ email: "jh@unixweb.de", name: "Joachim Hummel" }],
|
||||||
|
replyTo: { email, name },
|
||||||
|
subject: subject ? `[Portfolio] ${subject}` : `[Portfolio] Neue Anfrage von ${name}`,
|
||||||
|
textContent: [
|
||||||
|
`Name: ${name}`,
|
||||||
|
`E-Mail: ${email}`,
|
||||||
|
subject ? `Betreff: ${subject}` : "",
|
||||||
|
"",
|
||||||
|
message,
|
||||||
|
]
|
||||||
|
.filter((l) => l !== undefined)
|
||||||
|
.join("\n"),
|
||||||
|
htmlContent: `
|
||||||
|
<p><strong>Name:</strong> ${escapeHtml(name)}</p>
|
||||||
|
<p><strong>E-Mail:</strong> ${escapeHtml(email)}</p>
|
||||||
|
${subject ? `<p><strong>Betreff:</strong> ${escapeHtml(subject)}</p>` : ""}
|
||||||
|
<hr />
|
||||||
|
<p style="white-space:pre-wrap">${escapeHtml(message)}</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.log.info({ to: "jh@unixweb.de", from: email }, "Contact message sent via Brevo");
|
||||||
|
res.json({ success: true, message: "Ihre Nachricht wurde erfolgreich gesendet." });
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error({ err }, "Failed to send contact email via Brevo");
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
"Die Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import healthRouter from "./health";
|
import healthRouter from "./health";
|
||||||
|
import contactRouter from "./contact";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(healthRouter);
|
router.use(healthRouter);
|
||||||
|
router.use(contactRouter);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
@@ -1,10 +1,10 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Mail, ExternalLink, ArrowRight } from "lucide-react";
|
import { ExternalLink, ArrowRight, Send, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
const emailAddresses = [
|
import { useForm } from "react-hook-form";
|
||||||
{ address: "jh@unixweb.de", label: "jh@unixweb.de" },
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
{ address: "kontakt@joachimhummel.de", label: "kontakt@joachimhummel.de" },
|
import { z } from "zod";
|
||||||
];
|
import { useSendContactMessage } from "@workspace/api-client-react";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
@@ -19,6 +19,177 @@ const links = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const contactSchema = z.object({
|
||||||
|
name: z.string().min(1, "Bitte geben Sie Ihren Namen ein").max(100),
|
||||||
|
email: z.string().email("Bitte geben Sie eine gültige E-Mail-Adresse ein").max(200),
|
||||||
|
subject: z.string().max(200).optional(),
|
||||||
|
message: z.string().min(1, "Bitte geben Sie eine Nachricht ein").max(5000, "Die Nachricht ist zu lang (max. 5000 Zeichen)"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ContactFormData = z.infer<typeof contactSchema>;
|
||||||
|
|
||||||
|
function ContactForm() {
|
||||||
|
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
|
||||||
|
const [serverMessage, setServerMessage] = useState("");
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ContactFormData>({
|
||||||
|
resolver: zodResolver(contactSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: sendMessage, isPending } = useSendContactMessage({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setServerMessage(data.message);
|
||||||
|
setStatus("success");
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const apiErr = err as { data?: { message?: string } };
|
||||||
|
setServerMessage(
|
||||||
|
apiErr?.data?.message ??
|
||||||
|
"Die Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
|
||||||
|
);
|
||||||
|
setStatus("error");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: ContactFormData) => {
|
||||||
|
setStatus("idle");
|
||||||
|
sendMessage({ data: { name: data.name, email: data.email, subject: data.subject, message: data.message } });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "success") {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center justify-center gap-4 p-8 rounded-2xl bg-green-50 border border-green-200 text-center"
|
||||||
|
data-testid="contact-form-success"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-10 h-10 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-800 text-lg">Nachricht gesendet!</p>
|
||||||
|
<p className="text-green-700 text-sm mt-1">{serverMessage}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatus("idle")}
|
||||||
|
className="mt-2 text-sm text-green-700 underline underline-offset-2 hover:text-green-900 transition-colors"
|
||||||
|
>
|
||||||
|
Weitere Nachricht senden
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4" noValidate data-testid="contact-form">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="contact-name" className="text-sm font-medium text-foreground">
|
||||||
|
Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-name"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
placeholder="Max Mustermann"
|
||||||
|
className={`rounded-xl border px-4 py-2.5 text-sm bg-white text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:ring-2 focus:ring-primary/30 focus:border-primary ${errors.name ? "border-red-400" : "border-border"}`}
|
||||||
|
data-testid="input-contact-name"
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-red-500">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="contact-email" className="text-sm font-medium text-foreground">
|
||||||
|
E-Mail <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="max@beispiel.de"
|
||||||
|
className={`rounded-xl border px-4 py-2.5 text-sm bg-white text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:ring-2 focus:ring-primary/30 focus:border-primary ${errors.email ? "border-red-400" : "border-border"}`}
|
||||||
|
data-testid="input-contact-email"
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-xs text-red-500">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="contact-subject" className="text-sm font-medium text-foreground">
|
||||||
|
Betreff <span className="text-muted-foreground font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-subject"
|
||||||
|
type="text"
|
||||||
|
placeholder="Worum geht es?"
|
||||||
|
className="rounded-xl border border-border px-4 py-2.5 text-sm bg-white text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||||
|
data-testid="input-contact-subject"
|
||||||
|
{...register("subject")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="contact-message" className="text-sm font-medium text-foreground">
|
||||||
|
Nachricht <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
|
rows={5}
|
||||||
|
placeholder="Ihre Nachricht..."
|
||||||
|
className={`rounded-xl border px-4 py-2.5 text-sm bg-white text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:ring-2 focus:ring-primary/30 focus:border-primary resize-none ${errors.message ? "border-red-400" : "border-border"}`}
|
||||||
|
data-testid="input-contact-message"
|
||||||
|
{...register("message")}
|
||||||
|
/>
|
||||||
|
{errors.message && (
|
||||||
|
<p className="text-xs text-red-500">{errors.message.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-xl bg-red-50 border border-red-200 text-sm text-red-700" data-testid="contact-form-error">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{serverMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Ihre Daten werden ausschließlich zur Bearbeitung Ihrer Anfrage verwendet und nicht an Dritte weitergegeben.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-primary text-white font-semibold text-sm hover:bg-primary/90 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||||
|
data-testid="button-contact-submit"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Wird gesendet…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
Nachricht senden
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Contact() {
|
export function Contact() {
|
||||||
return (
|
return (
|
||||||
<div className="py-24">
|
<div className="py-24">
|
||||||
@@ -40,39 +211,6 @@ export function Contact() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{emailAddresses.map((email, i) => (
|
|
||||||
<motion.a
|
|
||||||
key={email.address}
|
|
||||||
href={`mailto:${email.address}`}
|
|
||||||
whileHover={{ scale: 1.01 }}
|
|
||||||
className={`flex items-center gap-5 p-5 rounded-2xl border card-hover group ${
|
|
||||||
i === 0
|
|
||||||
? "bg-primary text-white border-primary"
|
|
||||||
: "bg-white text-foreground border-border"
|
|
||||||
}`}
|
|
||||||
data-testid={`link-contact-email-${i}`}
|
|
||||||
>
|
|
||||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${
|
|
||||||
i === 0 ? "bg-white/20" : "bg-secondary"
|
|
||||||
}`}>
|
|
||||||
<Mail className={`w-5 h-5 ${i === 0 ? "text-white" : "text-primary"}`} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-grow min-w-0">
|
|
||||||
<p className={`text-xs font-medium mb-0.5 ${i === 0 ? "text-white/70" : "text-muted-foreground"}`}>
|
|
||||||
{i === 0 ? "Hauptadresse" : "Alternativ"}
|
|
||||||
</p>
|
|
||||||
<p className={`text-sm font-semibold truncate ${i === 0 ? "text-white" : "text-foreground"}`}>
|
|
||||||
{email.label}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className={`w-4 h-4 shrink-0 group-hover:translate-x-1 transition-all ${
|
|
||||||
i === 0 ? "text-white/60" : "text-muted-foreground/40 group-hover:text-primary"
|
|
||||||
}`} />
|
|
||||||
</motion.a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<motion.a
|
<motion.a
|
||||||
@@ -95,6 +233,11 @@ export function Contact() {
|
|||||||
</motion.a>
|
</motion.a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-white border border-border p-6 shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-5">Direkt schreiben</h3>
|
||||||
|
<ContactForm />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 pt-8 border-t border-border text-center text-sm text-muted-foreground">
|
<div className="mt-12 pt-8 border-t border-border text-center text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const stations: Station[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
period: "01/2024 – 05/2025",
|
period: "01/2024 – heute",
|
||||||
role: "Senior IT-Consultant",
|
role: "Senior IT-Consultant",
|
||||||
client: "Landesamt für Statistik Bayern, München",
|
client: "Landesamt für Statistik Bayern, München",
|
||||||
type: "behoerde",
|
type: "behoerde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { motion, useScroll, useMotionValueEvent } from "framer-motion";
|
import { motion, useScroll, useMotionValueEvent } from "framer-motion";
|
||||||
import { Menu, X, ExternalLink } from "lucide-react";
|
import { Menu, X } from "lucide-react";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
@@ -12,13 +12,11 @@ export function Navbar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ name: "Kompetenzen", href: "#competencies" },
|
|
||||||
{ name: "Projekte", href: "#projects" },
|
{ name: "Projekte", href: "#projects" },
|
||||||
{ name: "Erfahrung", href: "#experience" },
|
{ name: "Erfahrung", href: "#experience" },
|
||||||
|
{ name: "Über mich", href: "#bio" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const blogLinks: { name: string; href: string }[] = [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.header
|
<motion.header
|
||||||
className={`fixed top-0 w-full z-50 transition-all duration-300 ${
|
className={`fixed top-0 w-full z-50 transition-all duration-300 ${
|
||||||
@@ -53,20 +51,6 @@ export function Navbar() {
|
|||||||
{link.name}
|
{link.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
|
||||||
{blogLinks.map((link) => (
|
|
||||||
<a
|
|
||||||
key={link.name}
|
|
||||||
href={link.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary rounded-lg transition-all"
|
|
||||||
data-testid={`link-nav-${link.name.toLowerCase().replace(/\s/g, '-')}`}
|
|
||||||
>
|
|
||||||
{link.name}
|
|
||||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
@@ -104,19 +88,6 @@ export function Navbar() {
|
|||||||
{link.name}
|
{link.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
<div className="border-t border-border my-1" />
|
|
||||||
{blogLinks.map((link) => (
|
|
||||||
<a
|
|
||||||
key={link.name}
|
|
||||||
href={link.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-3 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
{link.name}
|
|
||||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
<a
|
<a
|
||||||
href="#contact"
|
href="#contact"
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
@@ -1,10 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.5.3 🍺
|
* Generated by orval v8.9.1 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* API specification
|
* API specification
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
export interface ContactRequest {
|
||||||
|
/**
|
||||||
|
* @minLength 1
|
||||||
|
* @maxLength 100
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/** @maxLength 200 */
|
||||||
|
email: string;
|
||||||
|
/** @maxLength 200 */
|
||||||
|
subject?: string;
|
||||||
|
/**
|
||||||
|
* @minLength 1
|
||||||
|
* @maxLength 5000
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactError {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HealthStatus {
|
export interface HealthStatus {
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +1,190 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.5.3 🍺
|
* Generated by orval v8.9.1 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* API specification
|
* API specification
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery
|
||||||
|
} from '@tanstack/react-query';
|
||||||
import type {
|
import type {
|
||||||
|
MutationFunction,
|
||||||
QueryFunction,
|
QueryFunction,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
|
UseMutationOptions,
|
||||||
|
UseMutationResult,
|
||||||
UseQueryOptions,
|
UseQueryOptions,
|
||||||
UseQueryResult,
|
UseQueryResult
|
||||||
} from "@tanstack/react-query";
|
} from '@tanstack/react-query';
|
||||||
|
|
||||||
import type { HealthStatus } from "./api.schemas";
|
import type {
|
||||||
|
ContactError,
|
||||||
|
ContactRequest,
|
||||||
|
ContactResponse,
|
||||||
|
HealthStatus
|
||||||
|
} from './api.schemas';
|
||||||
|
|
||||||
import { customFetch } from "../custom-fetch";
|
import { customFetch } from '../custom-fetch';
|
||||||
import type { ErrorType } from "../custom-fetch";
|
import type { ErrorType , BodyType } from '../custom-fetch';
|
||||||
|
|
||||||
type AwaitedInput<T> = PromiseLike<T> | T;
|
type AwaitedInput<T> = PromiseLike<T> | T;
|
||||||
|
|
||||||
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
|
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
|
||||||
|
|
||||||
|
|
||||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getSendContactMessageUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/contact`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a contact message via email
|
||||||
|
* @summary Send contact form message
|
||||||
|
*/
|
||||||
|
export const sendContactMessage = async (contactRequest: ContactRequest, options?: RequestInit): Promise<ContactResponse> => {
|
||||||
|
|
||||||
|
return customFetch<ContactResponse>(getSendContactMessageUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
contactRequest,)
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getSendContactMessageMutationOptions = <TError = ErrorType<ContactError>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof sendContactMessage>>, TError,{data: BodyType<ContactRequest>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof sendContactMessage>>, TError,{data: BodyType<ContactRequest>}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['sendContactMessage'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof sendContactMessage>>, {data: BodyType<ContactRequest>}> = (props) => {
|
||||||
|
const {data} = props ?? {};
|
||||||
|
|
||||||
|
return sendContactMessage(data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type SendContactMessageMutationResult = NonNullable<Awaited<ReturnType<typeof sendContactMessage>>>
|
||||||
|
export type SendContactMessageMutationBody = BodyType<ContactRequest>
|
||||||
|
export type SendContactMessageMutationError = ErrorType<ContactError>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Send contact form message
|
||||||
|
*/
|
||||||
|
export const useSendContactMessage = <TError = ErrorType<ContactError>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof sendContactMessage>>, TError,{data: BodyType<ContactRequest>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof sendContactMessage>>,
|
||||||
|
TError,
|
||||||
|
{data: BodyType<ContactRequest>},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getSendContactMessageMutationOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHealthCheckUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/healthz`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns server health status
|
* Returns server health status
|
||||||
* @summary Health check
|
* @summary Health check
|
||||||
*/
|
*/
|
||||||
export const getHealthCheckUrl = () => {
|
export const healthCheck = async ( options?: RequestInit): Promise<HealthStatus> => {
|
||||||
return `/api/healthz`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const healthCheck = async (
|
return customFetch<HealthStatus>(getHealthCheckUrl(),
|
||||||
options?: RequestInit,
|
{
|
||||||
): Promise<HealthStatus> => {
|
|
||||||
return customFetch<HealthStatus>(getHealthCheckUrl(), {
|
|
||||||
...options,
|
...options,
|
||||||
method: "GET",
|
method: 'GET'
|
||||||
});
|
|
||||||
};
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const getHealthCheckQueryKey = () => {
|
export const getHealthCheckQueryKey = () => {
|
||||||
return [`/api/healthz`] as const;
|
return [
|
||||||
};
|
`/api/healthz`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
export const getHealthCheckQueryOptions = <
|
|
||||||
TData = Awaited<ReturnType<typeof healthCheck>>,
|
export const getHealthCheckQueryOptions = <TData = Awaited<ReturnType<typeof healthCheck>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof healthCheck>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
TError = ErrorType<unknown>,
|
) => {
|
||||||
>(options?: {
|
|
||||||
query?: UseQueryOptions<
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
Awaited<ReturnType<typeof healthCheck>>,
|
|
||||||
TError,
|
|
||||||
TData
|
|
||||||
>;
|
|
||||||
request?: SecondParameter<typeof customFetch>;
|
|
||||||
}) => {
|
|
||||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
|
||||||
|
|
||||||
const queryKey = queryOptions?.queryKey ?? getHealthCheckQueryKey();
|
const queryKey = queryOptions?.queryKey ?? getHealthCheckQueryKey();
|
||||||
|
|
||||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof healthCheck>>> = ({
|
|
||||||
signal,
|
|
||||||
}) => healthCheck({ signal, ...requestOptions });
|
|
||||||
|
|
||||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
|
||||||
Awaited<ReturnType<typeof healthCheck>>,
|
|
||||||
TError,
|
|
||||||
TData
|
|
||||||
> & { queryKey: QueryKey };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HealthCheckQueryResult = NonNullable<
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof healthCheck>>> = ({ signal }) => healthCheck({ signal, ...requestOptions });
|
||||||
Awaited<ReturnType<typeof healthCheck>>
|
|
||||||
>;
|
|
||||||
export type HealthCheckQueryError = ErrorType<unknown>;
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof healthCheck>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HealthCheckQueryResult = NonNullable<Awaited<ReturnType<typeof healthCheck>>>
|
||||||
|
export type HealthCheckQueryError = ErrorType<unknown>
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Health check
|
* @summary Health check
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function useHealthCheck<
|
export function useHealthCheck<TData = Awaited<ReturnType<typeof healthCheck>>, TError = ErrorType<unknown>>(
|
||||||
TData = Awaited<ReturnType<typeof healthCheck>>,
|
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof healthCheck>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
TError = ErrorType<unknown>,
|
|
||||||
>(options?: {
|
|
||||||
query?: UseQueryOptions<
|
|
||||||
Awaited<ReturnType<typeof healthCheck>>,
|
|
||||||
TError,
|
|
||||||
TData
|
|
||||||
>;
|
|
||||||
request?: SecondParameter<typeof customFetch>;
|
|
||||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
|
||||||
const queryOptions = getHealthCheckQueryOptions(options);
|
|
||||||
|
|
||||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
queryKey: QueryKey;
|
|
||||||
};
|
const queryOptions = getHealthCheckQueryOptions(options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
return { ...query, queryKey: queryOptions.queryKey };
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,40 @@ servers:
|
|||||||
tags:
|
tags:
|
||||||
- name: health
|
- name: health
|
||||||
description: Health operations
|
description: Health operations
|
||||||
|
- name: contact
|
||||||
|
description: Contact form
|
||||||
paths:
|
paths:
|
||||||
|
/contact:
|
||||||
|
post:
|
||||||
|
operationId: sendContactMessage
|
||||||
|
tags: [contact]
|
||||||
|
summary: Send contact form message
|
||||||
|
description: Sends a contact message via email
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ContactRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Message sent successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ContactResponse"
|
||||||
|
"400":
|
||||||
|
description: Validation error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ContactError"
|
||||||
|
"500":
|
||||||
|
description: Server error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ContactError"
|
||||||
/healthz:
|
/healthz:
|
||||||
get:
|
get:
|
||||||
operationId: healthCheck
|
operationId: healthCheck
|
||||||
@@ -26,6 +59,48 @@ paths:
|
|||||||
$ref: "#/components/schemas/HealthStatus"
|
$ref: "#/components/schemas/HealthStatus"
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
|
ContactRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 100
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
maxLength: 200
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
maxLength: 200
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 5000
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- email
|
||||||
|
- message
|
||||||
|
ContactResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- message
|
||||||
|
ContactError:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- success
|
||||||
|
- message
|
||||||
HealthStatus:
|
HealthStatus:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -1,16 +1,46 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.5.3 🍺
|
* Generated by orval v8.9.1 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* API specification
|
* API specification
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import * as zod from "zod";
|
import * as zod from 'zod';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a contact message via email
|
||||||
|
* @summary Send contact form message
|
||||||
|
*/
|
||||||
|
export const sendContactMessageBodyNameMax = 100;
|
||||||
|
|
||||||
|
export const sendContactMessageBodyEmailMax = 200;
|
||||||
|
|
||||||
|
export const sendContactMessageBodySubjectMax = 200;
|
||||||
|
|
||||||
|
export const sendContactMessageBodyMessageMax = 5000;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const SendContactMessageBody = zod.object({
|
||||||
|
"name": zod.string().min(1).max(sendContactMessageBodyNameMax),
|
||||||
|
"email": zod.string().email().max(sendContactMessageBodyEmailMax),
|
||||||
|
"subject": zod.string().max(sendContactMessageBodySubjectMax).optional(),
|
||||||
|
"message": zod.string().min(1).max(sendContactMessageBodyMessageMax)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SendContactMessageResponse = zod.object({
|
||||||
|
"success": zod.boolean(),
|
||||||
|
"message": zod.string()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns server health status
|
* Returns server health status
|
||||||
* @summary Health check
|
* @summary Health check
|
||||||
*/
|
*/
|
||||||
export const HealthCheckResponse = zod.object({
|
export const HealthCheckResponse = zod.object({
|
||||||
status: zod.string(),
|
"status": zod.string()
|
||||||
});
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
12
lib/api-zod/src/generated/types/contactError.ts
Normal file
12
lib/api-zod/src/generated/types/contactError.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* API specification
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ContactError {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
24
lib/api-zod/src/generated/types/contactRequest.ts
Normal file
24
lib/api-zod/src/generated/types/contactRequest.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* API specification
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ContactRequest {
|
||||||
|
/**
|
||||||
|
* @minLength 1
|
||||||
|
* @maxLength 100
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/** @maxLength 200 */
|
||||||
|
email: string;
|
||||||
|
/** @maxLength 200 */
|
||||||
|
subject?: string;
|
||||||
|
/**
|
||||||
|
* @minLength 1
|
||||||
|
* @maxLength 5000
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
12
lib/api-zod/src/generated/types/contactResponse.ts
Normal file
12
lib/api-zod/src/generated/types/contactResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* API specification
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ContactResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.5.3 🍺
|
* Generated by orval v8.9.1 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* API specification
|
* API specification
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.5.3 🍺
|
* Generated by orval v8.9.1 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* API specification
|
* API specification
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./healthStatus";
|
export * from './contactError';
|
||||||
|
export * from './contactRequest';
|
||||||
|
export * from './contactResponse';
|
||||||
|
export * from './healthStatus';
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -166,6 +166,9 @@ importers:
|
|||||||
|
|
||||||
artifacts/api-server:
|
artifacts/api-server:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@getbrevo/brevo':
|
||||||
|
specifier: ^5.0.4
|
||||||
|
version: 5.0.4
|
||||||
'@workspace/api-zod':
|
'@workspace/api-zod':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../lib/api-zod
|
version: link:../../lib/api-zod
|
||||||
@@ -767,6 +770,10 @@ packages:
|
|||||||
'@gerrit0/mini-shiki@3.23.0':
|
'@gerrit0/mini-shiki@3.23.0':
|
||||||
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
|
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
|
||||||
|
|
||||||
|
'@getbrevo/brevo@5.0.4':
|
||||||
|
resolution: {integrity: sha512-wN4mHE6O0Pb/d/Dh3E4MAm2zCHbLLUcFF8/wgwTeMPy9KzHBYLu7S/nsJbzd0AWL09MHn8vwue8ULL9YOzluOQ==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
'@hookform/resolvers@3.10.0':
|
'@hookform/resolvers@3.10.0':
|
||||||
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
|
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3317,6 +3324,8 @@ snapshots:
|
|||||||
'@shikijs/types': 3.23.0
|
'@shikijs/types': 3.23.0
|
||||||
'@shikijs/vscode-textmate': 10.0.2
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
|
|
||||||
|
'@getbrevo/brevo@5.0.4': {}
|
||||||
|
|
||||||
'@hookform/resolvers@3.10.0(react-hook-form@7.75.0(react@19.1.0))':
|
'@hookform/resolvers@3.10.0(react-hook-form@7.75.0(react@19.1.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
react-hook-form: 7.75.0(react@19.1.0)
|
react-hook-form: 7.75.0(react@19.1.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user