add contact request
@@ -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 }
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 538 B |
|
After Width: | Height: | Size: 538 B |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 538 B |
@@ -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 |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 538 B |
|
After Width: | Height: | Size: 538 B |
|
After Width: | Height: | Size: 538 B |
@@ -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 |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
@@ -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+(.*)$/
|
||||
|
||||
|
||||