feat: initial commit — site + admin panel + Postgres content pipeline
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "corepack",
|
||||
"runtimeArgs": ["yarn", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.dist-staging
|
||||
.vite-ssg-temp
|
||||
dev-dist
|
||||
.env
|
||||
.git
|
||||
.history
|
||||
.vscode
|
||||
.claude
|
||||
*.log
|
||||
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.dist-staging
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.history
|
||||
.vite-ssg-temp
|
||||
dev-dist
|
||||
.env
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Müco Mutfak — statik site + admin API tek container
|
||||
# Sunucu, açılışta PUBLISH_ON_BOOT=true ile DB'den güncel içeriği çekip build alır.
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
# İlk build (repo'daki JSON'larla) — boot'ta DB içeriğiyle yenilenir
|
||||
RUN yarn build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV ADMIN_PORT=3001
|
||||
ENV PUBLISH_ON_BOOT=true
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "server/index.mjs"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Cansu Arı
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Müco Mutfak Corporate Website
|
||||
|
||||
Progressive Web App
|
||||
|
||||
## Requirements
|
||||
|
||||
**Node.js:** 22.22.2
|
||||
|
||||
## How to run
|
||||
|
||||
```code
|
||||
yarn install
|
||||
```
|
||||
|
||||
### Run for dev
|
||||
|
||||
```code
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### Run for prod
|
||||
|
||||
```code
|
||||
yarn build
|
||||
```
|
||||
|
||||
## Prod Deploy
|
||||
|
||||
Web sitesi Cloudflare Pages üzerinden ücretsiz plan ile yayına alınmıştır.
|
||||
Github repo ile bağlantılıdır.
|
||||
Github repoya commit atıldığında otomatik olarak deploy olur.
|
||||
Tüm environment'lar Cloudflare'de mevcuttur.
|
||||
Recaptcha için Cloudflare üzerinden Turnstile kullanılmıştır.
|
||||
Email için ise Resend kullanılmıştır.
|
||||
|
||||
## Color Pallette
|
||||
|
||||
- #3C393D
|
||||
- #F1F4F5
|
||||
- #DFEDEE
|
||||
- #3FA0C7
|
||||
- #8FCFE1
|
||||
|
||||
## Font
|
||||
|
||||
- All Round Gothic Font Family
|
||||
- Shadows Into Light Regular
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Button: typeof import('./src/components/Button.vue')['default']
|
||||
Footer: typeof import('./src/components/Footer.vue')['default']
|
||||
Header: typeof import('./src/components/Header.vue')['default']
|
||||
HeroPromo: typeof import('./src/components/Hero/Promo.vue')['default']
|
||||
LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default']
|
||||
MenuCampaings: typeof import('./src/components/Menu/Campaings.vue')['default']
|
||||
MenuContact: typeof import('./src/components/Menu/Contact.vue')['default']
|
||||
MenuDesktop: typeof import('./src/components/Menu/Desktop.vue')['default']
|
||||
MenuDesktopOld: typeof import('./src/components/Menu/Desktop-old.vue')['default']
|
||||
MenuHome: typeof import('./src/components/Menu/Home.vue')['default']
|
||||
MenuLayout: typeof import('./src/components/Menu/Layout.vue')['default']
|
||||
MenuMobile: typeof import('./src/components/Menu/Mobile.vue')['default']
|
||||
MenuMobileList: typeof import('./src/components/Menu/MobileList.vue')['default']
|
||||
MenuSurvey: typeof import('./src/components/Menu/Survey.vue')['default']
|
||||
MenuWrapper: typeof import('./src/components/Menu/MenuWrapper.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SectionsBanner: typeof import('./src/components/Sections/Banner.vue')['default']
|
||||
SectionsBreakfast: typeof import('./src/components/Sections/Breakfast.vue')['default']
|
||||
SectionsBreakfastEn: typeof import('./src/components/Sections/BreakfastEn.vue')['default']
|
||||
SectionsBreakfastOld: typeof import('./src/components/Sections/Breakfast-old.vue')['default']
|
||||
SectionsContact: typeof import('./src/components/Sections/Contact.vue')['default']
|
||||
SectionsCTA: typeof import('./src/components/Sections/CTA.vue')['default']
|
||||
SectionsGallery: typeof import('./src/components/Sections/Gallery.vue')['default']
|
||||
SectionsHighlight: typeof import('./src/components/Sections/Highlight.vue')['default']
|
||||
SectionsOverview: typeof import('./src/components/Sections/Overview.vue')['default']
|
||||
SectionsParallax: typeof import('./src/components/Sections/Parallax.vue')['default']
|
||||
SectionsTestimonials: typeof import('./src/components/Sections/Testimonials.vue')['default']
|
||||
TopBar: typeof import('./src/components/TopBar.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
// functions/contact/index.ts
|
||||
|
||||
declare global {
|
||||
interface KVNamespace {
|
||||
get(key: string): Promise<string | null>;
|
||||
put(
|
||||
key: string,
|
||||
value: string,
|
||||
options?: { expirationTtl?: number },
|
||||
): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
interface Env {
|
||||
RESEND_API_KEY: string;
|
||||
EMAILS_API: string;
|
||||
EMAILS_TO: string;
|
||||
EMAILS_TO_CC: string;
|
||||
EMAILS_FROM: string;
|
||||
TURNSTILE_SECRET_KEY?: string;
|
||||
RATE_LIMIT_KV?: KVNamespace;
|
||||
}
|
||||
|
||||
function escapeHtml(input: unknown): string {
|
||||
return String(input ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function safeEmailHref(email: string): string {
|
||||
return `mailto:${encodeURIComponent(email)}`;
|
||||
}
|
||||
|
||||
type Validated = {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
subject: "job-application" | "suggestion" | "something-else";
|
||||
subjectOther?: string;
|
||||
message: string;
|
||||
consent: boolean;
|
||||
};
|
||||
|
||||
function validatePayload(
|
||||
body: any,
|
||||
): { ok: true; value: Validated } | { ok: false; error: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
return { ok: false, error: "Invalid payload" };
|
||||
}
|
||||
|
||||
const fullName = String(body.fullName ?? "").trim();
|
||||
|
||||
if (fullName.length < 4 || fullName.length > 100) {
|
||||
return { ok: false, error: "Invalid name length" };
|
||||
}
|
||||
|
||||
const email = String(body.email ?? "").trim();
|
||||
const emailRe = /^(?:[^\s@]+)@(?:[^\s@]+)\.[^\s@]{2,}$/i;
|
||||
|
||||
if (!emailRe.test(email) || email.length > 254) {
|
||||
return { ok: false, error: "Invalid email" };
|
||||
}
|
||||
|
||||
const phone = String(body.phone ?? "").trim();
|
||||
|
||||
if (phone) {
|
||||
const phoneRe =
|
||||
/^\+?[1-9]\d{0,3}[\s\-().]?\d{1,4}[\s\-().]?\d{1,4}[\s\-().]?\d{1,9}$/;
|
||||
|
||||
if (!phoneRe.test(phone) || phone.length > 30) {
|
||||
return { ok: false, error: "Invalid phone" };
|
||||
}
|
||||
}
|
||||
|
||||
const subject = String(body.subject ?? "");
|
||||
const allowedSubjects = ["job-application", "suggestion", "something-else"];
|
||||
|
||||
if (!allowedSubjects.includes(subject)) {
|
||||
return { ok: false, error: "Invalid subject" };
|
||||
}
|
||||
|
||||
const subjectOther = String(body.subjectOther ?? "").trim();
|
||||
|
||||
if (subject === "something-else" && subjectOther.length === 0) {
|
||||
return { ok: false, error: "Subject other required" };
|
||||
}
|
||||
|
||||
if (subjectOther.length > 120) {
|
||||
return { ok: false, error: "subjectOther too long" };
|
||||
}
|
||||
|
||||
const message = String(body.message ?? "").trim();
|
||||
|
||||
if (message.length < 25 || message.length > 600) {
|
||||
return { ok: false, error: "Invalid message length" };
|
||||
}
|
||||
|
||||
const consent = Boolean(body.consent);
|
||||
|
||||
if (!consent) {
|
||||
return { ok: false, error: "Consent required" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
subject: subject as Validated["subject"],
|
||||
subjectOther,
|
||||
message,
|
||||
consent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowedOrigin(request: Request): boolean {
|
||||
const origin = request.headers.get("Origin") || "";
|
||||
|
||||
const allowed = ["https://mucomutfak.com", "https://www.mucomutfak.com"];
|
||||
|
||||
if (!origin) return true;
|
||||
|
||||
return allowed.includes(origin);
|
||||
}
|
||||
|
||||
async function verifyTurnstile(
|
||||
secret: string | undefined,
|
||||
token: string | undefined,
|
||||
remoteIp?: string,
|
||||
) {
|
||||
if (!secret) return { ok: false, error: "Turnstile not configured" };
|
||||
if (!token) return { ok: false, error: "Missing Turnstile token" };
|
||||
|
||||
try {
|
||||
const form = new URLSearchParams();
|
||||
|
||||
form.set("secret", secret);
|
||||
form.set("response", token);
|
||||
|
||||
if (remoteIp) {
|
||||
form.set("remoteip", remoteIp);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
},
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.success) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: "Turnstile failed",
|
||||
details: json["error-codes"] || json,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, error: "Turnstile verification error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function rateLimitCheck(
|
||||
ip: string | null,
|
||||
kv: KVNamespace | undefined,
|
||||
): Promise<{ ok: true } | { ok: false; retryAfter?: number }> {
|
||||
if (!kv || !ip) return { ok: true };
|
||||
|
||||
const key = `cf_rl:${ip}:${new Date().toISOString().slice(0, 16)}`;
|
||||
|
||||
try {
|
||||
const existing = await kv.get(key);
|
||||
const count = existing ? parseInt(existing, 10) : 0;
|
||||
const limitPerMinute = 6;
|
||||
|
||||
if (count >= limitPerMinute) {
|
||||
return { ok: false, retryAfter: 60 };
|
||||
}
|
||||
|
||||
await kv.put(key, String(count + 1), {
|
||||
expirationTtl: 65,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
console.error("KV rate limit error", e);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function onRequestPost({
|
||||
request,
|
||||
env,
|
||||
}: {
|
||||
request: Request;
|
||||
env: Env;
|
||||
}) {
|
||||
try {
|
||||
console.log("[contact] function invoked", {
|
||||
origin: request.headers.get("Origin"),
|
||||
hasSecret: !!env.TURNSTILE_SECRET_KEY,
|
||||
hasKV: !!env.RATE_LIMIT_KV,
|
||||
});
|
||||
|
||||
if (!isAllowedOrigin(request)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Forbidden",
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (body?.checkIfYouAreNotARobot) {
|
||||
console.warn("Robot triggered from request");
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const validated = validatePayload(body);
|
||||
|
||||
if (!validated.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: validated.error,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const data = validated.value;
|
||||
|
||||
const ip =
|
||||
request.headers.get("cf-connecting-ip") ||
|
||||
request.headers.get("x-forwarded-for") ||
|
||||
null;
|
||||
|
||||
const rateLimit = await rateLimitCheck(ip, env.RATE_LIMIT_KV);
|
||||
|
||||
if (!rateLimit.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Rate limit exceeded",
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const turnstileToken = String(body?.turnstileToken ?? "");
|
||||
|
||||
if (env.TURNSTILE_SECRET_KEY) {
|
||||
console.log("[contact] using TURNSTILE_SECRET_KEY");
|
||||
|
||||
const turnstile = await verifyTurnstile(
|
||||
env.TURNSTILE_SECRET_KEY,
|
||||
turnstileToken,
|
||||
ip || undefined,
|
||||
);
|
||||
|
||||
if (!turnstile.ok) {
|
||||
console.warn("Turnstile failed", turnstile);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Bot verification failed",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const subjectLine =
|
||||
data.subject === "something-else"
|
||||
? data.subjectOther || "Contact Form"
|
||||
: data.subject;
|
||||
|
||||
const receivedAt = new Date().toISOString();
|
||||
const sourceOrigin =
|
||||
request.headers.get("Origin") || request.headers.get("Referer") || "N/A";
|
||||
const clientIp = ip || "N/A";
|
||||
const subjectOtherSafe =
|
||||
data.subject === "something-else" ? data.subjectOther || "" : "";
|
||||
const consentText = data.consent ? "Yes" : "No";
|
||||
const phoneText = data.phone || "-";
|
||||
|
||||
const quotedMessage = String(data.message)
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n");
|
||||
|
||||
const mailText = `
|
||||
Yeni Mesajınız Var!
|
||||
|
||||
── İletişim Detayları ─────────────────────
|
||||
Ad Soyad: ${data.fullName}
|
||||
E-posta: ${data.email}
|
||||
Telefon: ${phoneText}
|
||||
Konu: ${subjectLine} ${subjectOtherSafe ? " - " + subjectOtherSafe : ""}
|
||||
İletişim İzni: ${consentText}
|
||||
|
||||
── Mesaj ─────────────────────────────
|
||||
${data.message}
|
||||
|
||||
── Meta ─────────────────────────────────
|
||||
Origin: ${sourceOrigin}
|
||||
IP: ${clientIp}
|
||||
Tarih: ${receivedAt}
|
||||
|
||||
── Yanıtla ─
|
||||
> Ad Soyad: ${data.fullName}
|
||||
> E-posta: ${data.email}
|
||||
> Telefon: ${phoneText}
|
||||
> Konu: ${subjectLine} ${subjectOtherSafe ? " - " + subjectOtherSafe : ""}
|
||||
>
|
||||
${quotedMessage}
|
||||
`;
|
||||
|
||||
const mailHtml = `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<style>
|
||||
.wrapper { width:100%; background:#f5f7fb; margin:0; padding:24px 12px; }
|
||||
.container { width:100%; max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; border:1px solid #e5e7eb; overflow:hidden; }
|
||||
.header { padding:20px 24px; background:#0f172a; color:#ffffff; }
|
||||
.title { margin:0; font-size:20px; line-height:1.3; }
|
||||
.section { padding:20px 24px; }
|
||||
.kv { width:100%; border-collapse:collapse; }
|
||||
.kv th { text-align:left; padding:8px 0; font-size:13px; color:#64748b; width:160px; vertical-align:top; }
|
||||
.kv td { padding:8px 0; font-size:14px; color:#0f172a; }
|
||||
.divider { height:1px; background:#e5e7eb; margin:12px 0; }
|
||||
.message { white-space:pre-wrap; padding:12px; border-radius:8px; background:#f8fafc; border:1px solid #e2e8f0; color:#0f172a; font-size:14px; }
|
||||
.footer { padding:16px 24px; font-size:12px; color:#94a3b8; background:#fafafa; }
|
||||
.pill { display:inline-block; padding:4px 10px; border-radius:9999px; border:1px solid #e2e8f0; font-size:12px; color:#0f172a; background:#f8fafc; }
|
||||
.btn { display:inline-block; font-size:14px; text-decoration:none; padding:10px 14px; border-radius:8px; border:1px solid #0f172a; }
|
||||
@media (max-width: 480px) {
|
||||
.section { padding:16px; }
|
||||
.header { padding:16px; }
|
||||
.kv th { width:120px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background:#f5f7fb;">
|
||||
<div class="wrapper">
|
||||
<table role="presentation" class="container" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td class="header">
|
||||
<h2 class="title">Yeni Mesajınız Var!</h2>
|
||||
<div style="margin-top:8px;">
|
||||
<span class="pill">${escapeHtml(subjectLine)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="section">
|
||||
<table class="kv" role="presentation">
|
||||
<tr>
|
||||
<th>Ad Soyad</th>
|
||||
<td>${escapeHtml(data.fullName)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>E-posta</th>
|
||||
<td>
|
||||
<a href="${safeEmailHref(data.email)}" style="color:#0f172a;">
|
||||
${escapeHtml(data.email)}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>Telefon</th>
|
||||
<td>${escapeHtml(phoneText)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>Konu</th>
|
||||
<td>
|
||||
${escapeHtml(subjectLine)}
|
||||
${subjectOtherSafe ? " - " + escapeHtml(subjectOtherSafe) : ""}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>İletişim İzni</th>
|
||||
<td>${escapeHtml(consentText)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="margin-bottom:6px;">Mesaj</div>
|
||||
|
||||
<div class="message">
|
||||
${escapeHtml(data.message)}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div style="font-weight:600; margin-bottom:6px;">
|
||||
Reply-ready quote
|
||||
</div>
|
||||
|
||||
<blockquote style="margin:0; padding-left:12px; border-left:3px solid #e2e8f0; color:#334155;">
|
||||
<div>
|
||||
<strong>Kimden:</strong>
|
||||
${escapeHtml(data.fullName)} <${escapeHtml(data.email)}>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Telefon:</strong>
|
||||
${escapeHtml(phoneText)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Konu:</strong>
|
||||
${escapeHtml(subjectLine)}
|
||||
${subjectOtherSafe ? " - " + escapeHtml(subjectOtherSafe) : ""}
|
||||
</div>
|
||||
|
||||
<div style="margin-top:8px; white-space:pre-wrap;">
|
||||
${escapeHtml(data.message)}
|
||||
</div>
|
||||
</blockquote>
|
||||
|
||||
<div style="margin-top:16px;">
|
||||
<a class="btn" href="${safeEmailHref(data.email)}">
|
||||
Yanıtla ${escapeHtml(data.fullName)}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="section" style="padding-top:0;">
|
||||
<table class="kv" role="presentation">
|
||||
<tr>
|
||||
<th>Origin</th>
|
||||
<td>${escapeHtml(sourceOrigin)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<td>${escapeHtml(clientIp)}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>Tarih</th>
|
||||
<td>${escapeHtml(receivedAt)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="footer">
|
||||
Bu e-posta iletişim formundan otomatik olarak gönderilmiştir.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const cc = env.EMAILS_TO_CC
|
||||
? env.EMAILS_TO_CC.split(",")
|
||||
.map((email) => email.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const mailResponse = await fetch(env.EMAILS_API, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: env.EMAILS_FROM,
|
||||
to: [env.EMAILS_TO],
|
||||
cc,
|
||||
subject: `Yeni Mesajınız Var: ${subjectLine}${
|
||||
subjectOtherSafe ? " - " + subjectOtherSafe : ""
|
||||
}`,
|
||||
text: mailText,
|
||||
html: mailHtml,
|
||||
reply_to: data.email,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!mailResponse.ok) {
|
||||
const detail = await mailResponse.text().catch(() => "no details");
|
||||
|
||||
console.error("Resend error:", mailResponse.status, detail);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Unable to send message",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const responseJson = await mailResponse.json().catch(() => ({}));
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
id: responseJson.id || null,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Unhandled error in contact function", err);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Server error",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#3FA0C7" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "mucomutfak",
|
||||
"private": true,
|
||||
"version": "1.1.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"admin": "node server/index.mjs",
|
||||
"build": "vite-ssg build",
|
||||
"preview": "vite preview --outDir dist --strictPort"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@vueuse/head": "^2.0.0",
|
||||
"add": "^2.0.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"gsap": "^3.15.0",
|
||||
"locomotive-scroll": "^4.1.4",
|
||||
"markdown-it": "^14.1.1",
|
||||
"pg": "^8.22.0",
|
||||
"swiper": "^12.1.3",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vite-plugin-sitemap": "^0.8.2",
|
||||
"vite-ssg": "^28.1.0",
|
||||
"vue": "^3.5.21",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^24.6.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"unplugin-vue-components": "^29.1.0",
|
||||
"vite": "^7.1.6"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Cache-Control: public, max-age=1, must-revalidate
|
||||
|
||||
/*.html
|
||||
Cache-Control: public, max-age=1, must-revalidate
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public, max-age=1, must-revalidate
|
||||
|
||||
/*.json
|
||||
Cache-Control: public, max-age=1, must-revalidate
|
||||
|
||||
/sw.js
|
||||
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
|
||||
|
||||
/workbox-*.js
|
||||
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
|
||||
|
||||
/manifest.webmanifest
|
||||
Cache-Control: public, max-age=1, must-revalidate
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 760 KiB |
|
After Width: | Height: | Size: 475 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 652 KiB |
|
After Width: | Height: | Size: 803 KiB |
|
After Width: | Height: | Size: 612 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 677 KiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 54 KiB |