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 |