Merge pull request 'feat: contact sayfasına iletişim formu + POST /contact endpoint'i' (#53) from dev into main
Reviewed-on: #53
This commit was merged in pull request #53.
This commit is contained in:
@@ -8,11 +8,11 @@ import { SentryModule } from "@sentry/nestjs/setup";
|
||||
import { AdminModule } from "./admin/admin.module";
|
||||
import { AnalyticsModule } from "./analytics/analytics.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { BlogModule } from "./blog/blog.module";
|
||||
import { BrandsModule } from "./brands/brands.module";
|
||||
import { CatalogModule } from "./catalog/catalog.module";
|
||||
import { CategoriesModule } from "./categories/categories.module";
|
||||
import { ChangelogModule } from "./changelog/changelog.module";
|
||||
import { BlogModule } from "./blog/blog.module";
|
||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
import { AuthGuard } from "./common/guards/auth.guard";
|
||||
import { ImpersonationReadonlyGuard } from "./common/guards/impersonation-readonly.guard";
|
||||
@@ -22,11 +22,12 @@ import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
|
||||
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
|
||||
import configuration from "./config/configuration";
|
||||
import { validate } from "./config/env.validation";
|
||||
import { ContactModule } from "./contact/contact.module";
|
||||
import { DatabaseModule } from "./database/database.module";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
||||
import { EmexModule } from "./integrations/emex/emex.module";
|
||||
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
import { PartsModule } from "./parts/parts.module";
|
||||
import { PaymentsModule } from "./payments/payments.module";
|
||||
@@ -87,6 +88,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
CatalogModule,
|
||||
ChangelogModule,
|
||||
BlogModule,
|
||||
ContactModule,
|
||||
PostHogModule,
|
||||
TelemetryModule,
|
||||
InternalAdminModule,
|
||||
|
||||
17
apps/api/src/contact/contact.controller.ts
Normal file
17
apps/api/src/contact/contact.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { ContactService } from "./contact.service";
|
||||
|
||||
@Controller("contact")
|
||||
export class ContactController {
|
||||
constructor(private readonly contactService: ContactService) {}
|
||||
|
||||
@Post()
|
||||
@Public()
|
||||
// Spam koruması: IP başına 10 dakikada en fazla 5 gönderim.
|
||||
@Throttle({ default: { limit: 5, ttl: 600_000 } })
|
||||
async submit(@Body() body: unknown) {
|
||||
return this.contactService.submit(body);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/contact/contact.dto.ts
Normal file
10
apps/api/src/contact/contact.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const contactMessageSchema = z.object({
|
||||
name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100, "Ad çok uzun"),
|
||||
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
|
||||
subject: z.string().trim().max(200, "Konu çok uzun").optional().default(""),
|
||||
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
|
||||
});
|
||||
|
||||
export type ContactMessage = z.infer<typeof contactMessageSchema>;
|
||||
10
apps/api/src/contact/contact.module.ts
Normal file
10
apps/api/src/contact/contact.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ContactController } from "./contact.controller";
|
||||
import { ContactService } from "./contact.service";
|
||||
|
||||
// EmailService @Global EmailModule'den gelir.
|
||||
@Module({
|
||||
controllers: [ContactController],
|
||||
providers: [ContactService],
|
||||
})
|
||||
export class ContactModule {}
|
||||
50
apps/api/src/contact/contact.service.ts
Normal file
50
apps/api/src/contact/contact.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { EmailService } from "../email/email.service";
|
||||
import { contactMessageSchema } from "./contact.dto";
|
||||
|
||||
// İletişim formu mesajlarının gönderileceği adres.
|
||||
const CONTACT_RECIPIENT = "admin@sase.tr";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContactService {
|
||||
private readonly logger = new Logger(ContactService.name);
|
||||
|
||||
constructor(private readonly email: EmailService) {}
|
||||
|
||||
async submit(body: unknown): Promise<{ success: true }> {
|
||||
const parsed = contactMessageSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestException(parsed.error.issues[0]?.message ?? "Geçersiz form verisi");
|
||||
}
|
||||
|
||||
const { name, email, message } = parsed.data;
|
||||
const subject = parsed.data.subject?.trim() || "Yeni mesaj";
|
||||
|
||||
await this.email.send({
|
||||
to: CONTACT_RECIPIENT,
|
||||
replyTo: email, // admin doğrudan gönderene yanıt verebilsin
|
||||
subject: `İletişim Formu: ${subject}`,
|
||||
tag: "contact-form",
|
||||
html: `
|
||||
<h2>Yeni iletişim formu mesajı</h2>
|
||||
<p><strong>Ad:</strong> ${escapeHtml(name)}</p>
|
||||
<p><strong>E-posta:</strong> ${escapeHtml(email)}</p>
|
||||
<p><strong>Konu:</strong> ${escapeHtml(subject)}</p>
|
||||
<p><strong>Mesaj:</strong></p>
|
||||
<p style="white-space: pre-wrap">${escapeHtml(message)}</p>
|
||||
`,
|
||||
text: `Ad: ${name}\nE-posta: ${email}\nKonu: ${subject}\n\nMesaj:\n${message}`,
|
||||
});
|
||||
|
||||
this.logger.log(`Contact form submitted by ${email}`);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export interface SendEmailOptions {
|
||||
html: string;
|
||||
text?: string;
|
||||
tag?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
interface PostalApiResponse {
|
||||
@@ -48,6 +49,7 @@ export class EmailService {
|
||||
html_body: options.html,
|
||||
...(options.text && { plain_body: options.text }),
|
||||
...(options.tag && { tag: options.tag }),
|
||||
...(options.replyTo && { reply_to: options.replyTo }),
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,13 +1,148 @@
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@sase/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
export const Route = createFileRoute("/contact")({
|
||||
component: ContactPage,
|
||||
});
|
||||
|
||||
// Backend (POST /contact) ile aynı kurallar
|
||||
const contactSchema = z.object({
|
||||
name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100, "Ad çok uzun"),
|
||||
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
|
||||
subject: z.string().trim().max(200, "Konu çok uzun").optional(),
|
||||
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
|
||||
});
|
||||
|
||||
type FieldErrors = Partial<Record<"name" | "email" | "subject" | "message", string>>;
|
||||
|
||||
const textareaClass =
|
||||
"flex min-h-[140px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
function ContactForm() {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = contactSchema.safeParse({ name, email, subject, message });
|
||||
if (!parsed.success) {
|
||||
const next: FieldErrors = {};
|
||||
for (const issue of parsed.error.issues) {
|
||||
const key = issue.path[0] as keyof FieldErrors;
|
||||
if (key && !next[key]) next[key] = issue.message;
|
||||
}
|
||||
setErrors(next);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post("/contact", parsed.data);
|
||||
toast.success("Mesajınız gönderildi", {
|
||||
description: "En kısa sürede size dönüş yapacağız.",
|
||||
});
|
||||
setName("");
|
||||
setEmail("");
|
||||
setSubject("");
|
||||
setMessage("");
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof ApiError ? err.message : "Mesaj gönderilemedi. Lütfen tekrar deneyin.";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Bize yazın</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-name">Ad Soyad</Label>
|
||||
<Input
|
||||
id="contact-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Adınız"
|
||||
aria-invalid={!!errors.name}
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-email">E-posta</Label>
|
||||
<Input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="ornek@eposta.com"
|
||||
aria-invalid={!!errors.email}
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-subject">Konu (opsiyonel)</Label>
|
||||
<Input
|
||||
id="contact-subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Mesajınızın konusu"
|
||||
aria-invalid={!!errors.subject}
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.subject && <p className="text-sm text-destructive">{errors.subject}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact-message">Mesaj</Label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Bize nasıl yardımcı olabiliriz?"
|
||||
aria-invalid={!!errors.message}
|
||||
disabled={loading}
|
||||
className={textareaClass}
|
||||
/>
|
||||
{errors.message && <p className="text-sm text-destructive">{errors.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Gönderiliyor…
|
||||
</>
|
||||
) : (
|
||||
"Gönder"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactPage() {
|
||||
usePageMeta({
|
||||
title: "İletişim — Sase.tr",
|
||||
@@ -19,68 +154,76 @@ function ContactPage() {
|
||||
<div className="min-h-screen">
|
||||
<SiteHeader />
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||
<main className="container mx-auto max-w-5xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">İletişim</h1>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Sorularınız, önerileriniz veya iş birliği talepleriniz için bize ulaşın.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">E-posta</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a href="mailto:info@sase.tr" className="text-primary underline">
|
||||
info@sase.tr
|
||||
</a>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Genel sorular ve destek talepleri için.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-5">
|
||||
{/* İletişim formu */}
|
||||
<div className="lg:col-span-3">
|
||||
<ContactForm />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Destek</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a href="mailto:destek@sase.tr" className="text-primary underline">
|
||||
destek@sase.tr
|
||||
</a>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Teknik sorunlar ve hesap işlemleri için.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* İletişim bilgileri */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">E-posta</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a href="mailto:info@sase.tr" className="text-primary underline">
|
||||
info@sase.tr
|
||||
</a>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Genel sorular ve destek talepleri için.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="sm:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Adres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<span className="text-foreground">Firma Adı:</span> THINXTRA LLC
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Destek</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a href="mailto:destek@sase.tr" className="text-primary underline">
|
||||
destek@sase.tr
|
||||
</a>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Teknik sorunlar ve hesap işlemleri için.
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Adres:</span> 1209 Mountain Road PL NE #11131
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Adres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<span className="text-foreground">Firma Adı:</span> THINXTRA LLC
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Adres:</span> 1209 Mountain Road PL NE #11131
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Şehir:</span> Albuquerque, NM 87110
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Ülke:</span> United States
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Vergi No:</span> 36-5177177
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Çalışma saatleri: Pazartesi – Cuma, 09:00 – 18:00
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Şehir:</span> Albuquerque, NM 87110
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Ülke:</span> United States
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-foreground">Vergi No:</span> 36-5177177
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Çalışma saatleri: Pazartesi – Cuma, 09:00 – 18:00
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user