add contact request

This commit is contained in:
2026-07-14 11:21:03 +03:30
parent 7f8eece848
commit ae4f2f721c
41 changed files with 230 additions and 12 deletions
+13 -5
View File
@@ -3,6 +3,9 @@ import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({
compatibilityDate: '2026-05-27',
srcDir: 'src/',
dir: {
public: 'public'
},
modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt'],
css: ['~/assets/css/fonts.css'],
tailwindcss: {
@@ -17,6 +20,12 @@ export default defineNuxtConfig({
dir: 'rtl'
},
title: 'استودیو نورو | Noro Studio',
link: [
{ rel: 'icon', href: '/favicon.ico', sizes: 'any' },
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon.png' },
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' }
],
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
@@ -25,10 +34,9 @@ export default defineNuxtConfig({
content:
'استودیو نورو — طراحی هویت بصری، UI/UX، سوشال مدیا و محتوای دیجیتال'
},
{ name: 'color-scheme', content: 'dark light' }
],
link: [
{ rel: 'icon', type: 'image/png', href: '/favicon.png' }
{ name: 'color-scheme', content: 'dark light' },
{ name: 'theme-color', content: '#39c0c4', media: '(prefers-color-scheme: light)' },
{ name: 'theme-color', content: '#050808', media: '(prefers-color-scheme: dark)' }
]
}
},
@@ -40,5 +48,5 @@ export default defineNuxtConfig({
cmsApiUrl: process.env.NUXT_PUBLIC_CMS_API_URL || 'http://localhost:4000'
}
},
devtools: { enabled: true }
devtools: { enabled: false }
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 538 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect x="5.2" y="11.8" width="21.6" height="8.4" rx="4.2" fill="#000000" />
<rect
x="4.5"
y="11"
width="23"
height="10"
rx="5"
stroke="#fafcfc"
stroke-width="1.6"
fill="none"
/>
<circle cx="23.2" cy="16" r="3.3" fill="#39c0c4" />
</svg>

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+48
View File
@@ -174,4 +174,52 @@
.noro-btn-secondary {
@apply inline-flex items-center justify-center rounded-xl border border-[rgb(var(--noro-btn-secondary-border))] bg-[rgb(var(--noro-btn-secondary-bg))] px-6 py-3 text-sm font-semibold text-[rgb(var(--noro-btn-secondary-text))] transition hover:opacity-90;
}
.blog-article-html h2 {
@apply pt-4 text-2xl font-bold text-noro-text;
}
.blog-article-html h3 {
@apply pt-2 text-xl font-bold text-noro-text;
}
.blog-article-html h4 {
@apply pt-1 text-lg font-semibold text-noro-text;
}
.blog-article-html p {
@apply my-3 whitespace-pre-line;
}
.blog-article-html ul {
@apply my-4 list-disc space-y-2 pr-6 marker:text-noro-brand;
}
.blog-article-html ol {
@apply my-4 list-decimal space-y-2 pr-6 marker:text-noro-brand;
}
.blog-article-html blockquote {
@apply my-6 border-r-4 border-noro-brand/30 bg-noro-surface/60 px-4 py-3 italic;
}
.blog-article-html a {
@apply text-noro-brand underline decoration-noro-brand/30 underline-offset-2 transition hover:decoration-noro-brand;
}
.blog-article-html hr {
@apply my-8 border-noro-border;
}
.blog-article-html img {
@apply my-6 w-full rounded-2xl border border-noro-border object-cover;
}
.blog-article-html figure {
@apply my-6 overflow-hidden rounded-2xl border border-noro-border bg-noro-surface;
}
.blog-article-html figcaption {
@apply px-4 py-3 text-center text-sm text-noro-muted;
}
}
+15 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { parseBlogContent } from '~/utils/blogContent'
import { isHtmlContent, parseBlogContent, resolveBlogHtml } from '~/utils/blogContent'
import { resolveCmsUrl } from '~/utils/cms'
const route = useRoute()
@@ -17,11 +17,17 @@ watchEffect(() => {
})
const contentBlocks = computed(() =>
post.value?.content
post.value?.content && !isHtmlContent(post.value.content)
? parseBlogContent(post.value.content, apiBase.value)
: []
)
const htmlContent = computed(() =>
post.value?.content && isHtmlContent(post.value.content)
? resolveBlogHtml(post.value.content, apiBase.value)
: ''
)
const coverUrl = computed(() =>
post.value?.coverUrl ? resolveCmsUrl(post.value.coverUrl, apiBase.value) : ''
)
@@ -125,7 +131,13 @@ useHead({
>
</div>
<div class="space-y-6 text-base leading-8 text-noro-muted">
<div
v-if="htmlContent"
class="blog-article-html space-y-6 text-base leading-8 text-noro-muted"
v-html="htmlContent"
/>
<div v-else class="space-y-6 text-base leading-8 text-noro-muted">
<template v-for="(block, index) in contentBlocks" :key="index">
<h2
v-if="block.type === 'h2'"
+37 -4
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
const { portfolioCategories, site, messengersLabel } = useNoroData()
const config = useRuntimeConfig()
useHead({
title: 'تماس — استودیو نورو'
@@ -14,9 +15,32 @@ const form = reactive({
})
const submitted = ref(false)
const submitting = ref(false)
const submitError = ref('')
function handleSubmit() {
submitted.value = true
async function handleSubmit() {
submitting.value = true
submitError.value = ''
try {
const apiBase = config.public.cmsApiUrl.replace(/\/$/, '')
const res = await fetch(`${apiBase}/api/contact-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'خطا در ارسال درخواست')
}
submitted.value = true
} catch (e) {
submitError.value = e instanceof Error ? e.message : 'خطا در ارسال درخواست'
} finally {
submitting.value = false
}
}
const categoryOptions = computed(() =>
@@ -151,8 +175,17 @@ const categoryOptions = computed(() =>
/>
</label>
<UiButton type="submit" size="lg" class="w-full justify-center sm:w-auto">
ارسال درخواست
<p v-if="submitError" class="text-sm text-red-500">
{{ submitError }}
</p>
<UiButton
type="submit"
size="lg"
class="w-full justify-center sm:w-auto"
:disabled="submitting"
>
{{ submitting ? 'در حال ارسال...' : 'ارسال درخواست' }}
</UiButton>
</form>
+74
View File
@@ -25,6 +25,8 @@ const descriptionParagraphs = computed(() =>
const lightboxIndex = ref<number | null>(null)
const galleryLength = computed(() => project.value?.gallery.length ?? 0)
function openLightbox(index: number) {
lightboxIndex.value = index
}
@@ -33,6 +35,45 @@ function closeLightbox() {
lightboxIndex.value = null
}
function showPrevImage() {
if (lightboxIndex.value === null || galleryLength.value < 2) return
lightboxIndex.value =
(lightboxIndex.value - 1 + galleryLength.value) % galleryLength.value
}
function showNextImage() {
if (lightboxIndex.value === null || galleryLength.value < 2) return
lightboxIndex.value = (lightboxIndex.value + 1) % galleryLength.value
}
function onLightboxKeydown(event: KeyboardEvent) {
if (lightboxIndex.value === null) return
if (event.key === 'Escape') {
closeLightbox()
} else if (event.key === 'ArrowRight') {
showPrevImage()
} else if (event.key === 'ArrowLeft') {
showNextImage()
}
}
watch(lightboxIndex, (index) => {
if (import.meta.client) {
if (index !== null) {
window.addEventListener('keydown', onLightboxKeydown)
} else {
window.removeEventListener('keydown', onLightboxKeydown)
}
}
})
onUnmounted(() => {
if (import.meta.client) {
window.removeEventListener('keydown', onLightboxKeydown)
}
})
useHead({
title: computed(() =>
project.value ? `${project.value.title} — استودیو نورو` : 'استودیو نورو'
@@ -234,10 +275,43 @@ useHead({
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button
v-if="galleryLength > 1"
type="button"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition hover:bg-white/20"
aria-label="تصویر قبلی"
@click.stop="showPrevImage"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<button
v-if="galleryLength > 1"
type="button"
class="absolute left-4 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition hover:bg-white/20"
aria-label="تصویر بعدی"
@click.stop="showNextImage"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<p
v-if="galleryLength > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-sm text-white"
>
{{ lightboxIndex + 1 }} / {{ galleryLength }}
</p>
<img
:src="project.gallery[lightboxIndex]"
:alt="`${project.title} — تصویر ${lightboxIndex + 1}`"
class="max-h-[90vh] max-w-full rounded-xl object-contain"
@click.stop
>
</div>
</Teleport>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect x="5.2" y="11.8" width="21.6" height="8.4" rx="4.2" fill="#000000" />
<rect
x="4.5"
y="11"
width="23"
height="10"
rx="5"
stroke="#fafcfc"
stroke-width="1.6"
fill="none"
/>
<circle cx="23.2" cy="16" r="3.3" fill="#39c0c4" />
</svg>

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+15
View File
@@ -6,6 +6,21 @@ export type BlogContentBlock =
| { type: 'ul'; items: string[] }
| { type: 'img'; src: string; alt: string }
export function isHtmlContent(content: string) {
const trimmed = content.trim()
return /^</.test(trimmed) || /<(?:p|h[1-6]|ul|ol|figure|img|blockquote)\b/i.test(trimmed)
}
export function resolveBlogHtml(content: string, apiBase = '') {
if (!content) return ''
const base = apiBase.replace(/\/$/, '')
return content.replace(
/(<(?:img|source)[^>]+src=["'])(\/uploads\/[^"']+)(["'])/gi,
(_, before, path, after) => `${before}${base}${path}${after}`
)
}
const imagePattern = /^!\[(.*?)\]\((.*?)\)$/
const headingPattern = /^(#{2,4})\s+(.*)$/