feat(lead-forms): real file upload under 200KB and Jalali date
Public form file fields compress images or reject oversized files; dates use the full Jalali picker.
This commit is contained in:
+13
-1
@@ -1,11 +1,23 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { json, urlencoded } from 'express';
|
||||
import type { Request } from 'express';
|
||||
import { brand } from '@mento/shared';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
// bodyParser خاموش تا سقف ۲ مگ برای پیوست فشردهٔ فرم عمومی + حفظ rawBody برای وبهوک
|
||||
const app = await NestFactory.create(AppModule, { bodyParser: false });
|
||||
app.use(
|
||||
json({
|
||||
limit: '2mb',
|
||||
verify: (req: Request, _res, buf) => {
|
||||
(req as Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(urlencoded({ extended: true, limit: '2mb' }));
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/** سقف حجم فایل فیلد فرم عمومی پس از فشردهسازی */
|
||||
export const FORM_FILE_MAX_BYTES = 200 * 1024;
|
||||
|
||||
export class FormFileTooLargeError extends Error {
|
||||
constructor(message = 'حجم فایل زیاد است') {
|
||||
super(message);
|
||||
this.name = 'FormFileTooLargeError';
|
||||
}
|
||||
}
|
||||
|
||||
function toBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('فشردهسازی تصویر ناموفق بود.'))),
|
||||
type,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function fileToDataUrl(file: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(new Error('خواندن فایل ناموفق بود.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function compressImageUnder(file: File, maxBytes: number): Promise<File> {
|
||||
if (file.size <= maxBytes) return file;
|
||||
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
} catch {
|
||||
throw new FormFileTooLargeError();
|
||||
}
|
||||
|
||||
let edge = Math.max(bitmap.width, bitmap.height);
|
||||
edge = Math.min(edge, 1600);
|
||||
let quality = 0.85;
|
||||
let best: File | null = null;
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 14; attempt += 1) {
|
||||
const ratio = Math.min(edge / bitmap.width, edge / bitmap.height, 1);
|
||||
const width = Math.max(1, Math.round(bitmap.width * ratio));
|
||||
const height = Math.max(1, Math.round(bitmap.height * ratio));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new FormFileTooLargeError();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
|
||||
let blob: Blob;
|
||||
try {
|
||||
blob = await toBlob(canvas, 'image/jpeg', quality);
|
||||
} catch {
|
||||
blob = await toBlob(canvas, 'image/webp', quality);
|
||||
}
|
||||
|
||||
const base = file.name.replace(/\.[^/.]+$/, '') || 'file';
|
||||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
||||
const out = new File([blob], `${base}.${ext}`, {
|
||||
type: blob.type,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
if (!best || out.size < best.size) best = out;
|
||||
if (out.size <= maxBytes) return out;
|
||||
|
||||
if (quality > 0.45) {
|
||||
quality = Math.max(0.4, quality - 0.1);
|
||||
} else {
|
||||
quality = 0.72;
|
||||
edge = Math.round(edge * 0.72);
|
||||
if (edge < 320) break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
|
||||
if (best && best.size <= maxBytes) return best;
|
||||
throw new FormFileTooLargeError();
|
||||
}
|
||||
|
||||
/**
|
||||
* فایل را تا سقف ۲۰۰ کیلوبایت فشرده میکند (تصاویر با کاهش کیفیت/ابعاد).
|
||||
* اگر ممکن نباشد، خطای «حجم فایل زیاد است» میدهد.
|
||||
*/
|
||||
export async function prepareFormFieldFile(file: File): Promise<{
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
dataUrl: string;
|
||||
}> {
|
||||
let prepared = file;
|
||||
if (file.type.startsWith('image/')) {
|
||||
prepared = await compressImageUnder(file, FORM_FILE_MAX_BYTES);
|
||||
} else if (file.size > FORM_FILE_MAX_BYTES) {
|
||||
throw new FormFileTooLargeError();
|
||||
}
|
||||
|
||||
if (prepared.size > FORM_FILE_MAX_BYTES) {
|
||||
throw new FormFileTooLargeError();
|
||||
}
|
||||
|
||||
const dataUrl = await fileToDataUrl(prepared);
|
||||
return {
|
||||
name: prepared.name || file.name || 'file',
|
||||
mime: prepared.type || file.type || 'application/octet-stream',
|
||||
size: prepared.size,
|
||||
dataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/** مقدار ذخیرهشده در پاسخ فرم: نام + data URL */
|
||||
export function encodeFormFileValue(meta: {
|
||||
name: string;
|
||||
mime: string;
|
||||
dataUrl: string;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
name: meta.name,
|
||||
mime: meta.mime,
|
||||
dataUrl: meta.dataUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function formFileDisplayName(raw: string | undefined | null): string {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: string };
|
||||
if (parsed?.name) return parsed.name;
|
||||
} catch {
|
||||
/* plain string */
|
||||
}
|
||||
if (raw.startsWith('data:')) return 'فایل پیوست';
|
||||
return raw.length > 48 ? `${raw.slice(0, 48)}…` : raw;
|
||||
}
|
||||
@@ -276,7 +276,16 @@ function formatValue(val: unknown): string {
|
||||
if (Array.isArray(val)) return val.join('، ');
|
||||
if (val === true) return 'بله';
|
||||
if (val === false) return 'خیر';
|
||||
return val == null ? '' : String(val);
|
||||
if (val == null) return '';
|
||||
const s = String(val);
|
||||
try {
|
||||
const parsed = JSON.parse(s) as { name?: string; dataUrl?: string };
|
||||
if (parsed?.name && parsed?.dataUrl) return `📎 ${parsed.name}`;
|
||||
} catch {
|
||||
/* not a file payload */
|
||||
}
|
||||
if (s.startsWith('data:')) return '📎 فایل پیوست';
|
||||
return s;
|
||||
}
|
||||
|
||||
function readyIcon(type: LeadFormFieldType | string): IconName {
|
||||
|
||||
@@ -4,11 +4,22 @@ import { useRoute } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import type { PublicLeadFormDto, SubmitLeadFormResponse } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import {
|
||||
FormFileTooLargeError,
|
||||
FORM_FILE_MAX_BYTES,
|
||||
encodeFormFileValue,
|
||||
formFileDisplayName,
|
||||
prepareFormFieldFile,
|
||||
} from '@/lib/form-file-compress';
|
||||
|
||||
const route = useRoute();
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const fieldErrors = reactive<Record<string, string>>({});
|
||||
const fileBusy = reactive<Record<string, boolean>>({});
|
||||
const fileInputKey = reactive<Record<string, number>>({});
|
||||
const done = ref(false);
|
||||
const thankYou = ref('');
|
||||
const started = ref(false);
|
||||
@@ -113,6 +124,50 @@ function hasMulti(key: string, option: string) {
|
||||
return (values[key] || '').split(',').map((s) => s.trim()).includes(option);
|
||||
}
|
||||
|
||||
function clearFile(key: string) {
|
||||
values[key] = '';
|
||||
fieldErrors[key] = '';
|
||||
fileInputKey[key] = (fileInputKey[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
async function onFileChange(key: string, event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
fieldErrors[key] = '';
|
||||
if (!file) {
|
||||
values[key] = '';
|
||||
return;
|
||||
}
|
||||
fileBusy[key] = true;
|
||||
try {
|
||||
const prepared = await prepareFormFieldFile(file);
|
||||
values[key] = encodeFormFileValue(prepared);
|
||||
} catch (e) {
|
||||
values[key] = '';
|
||||
input.value = '';
|
||||
fieldErrors[key] =
|
||||
e instanceof FormFileTooLargeError
|
||||
? e.message
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: 'آپلود فایل ناموفق بود.';
|
||||
} finally {
|
||||
fileBusy[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateRequiredFiles(): string | null {
|
||||
if (!form.value) return null;
|
||||
for (const field of form.value.fields) {
|
||||
if (field.fieldType !== 'file' || !field.required) continue;
|
||||
if (!values[field.key]?.trim()) {
|
||||
fieldErrors[field.key] = 'آپلود فایل الزامی است.';
|
||||
return `فیلد «${field.label || 'آپلود فایل'}» را کامل کنید.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.value) return;
|
||||
if (isPreview) {
|
||||
@@ -120,6 +175,15 @@ async function submit() {
|
||||
done.value = true;
|
||||
return;
|
||||
}
|
||||
const fileErr = validateRequiredFiles();
|
||||
if (fileErr) {
|
||||
error.value = fileErr;
|
||||
return;
|
||||
}
|
||||
if (Object.values(fieldErrors).some(Boolean)) {
|
||||
error.value = 'لطفاً خطاهای فایل را برطرف کنید.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
@@ -189,7 +253,7 @@ onBeforeUnmount(() => window.removeEventListener('storage', onStorage));
|
||||
<template v-for="field in form.fields" :key="field.key">
|
||||
<p v-if="field.fieldType === 'heading'" class="heading">{{ field.label }}</p>
|
||||
<label v-else @focusin="onFieldFocus">
|
||||
{{ field.label }}
|
||||
{{ field.fieldType === 'file' ? (field.label || 'آپلود فایل') : field.label }}
|
||||
<span v-if="field.required" class="req">*</span>
|
||||
<textarea
|
||||
v-if="field.fieldType === 'textarea'"
|
||||
@@ -233,11 +297,40 @@ onBeforeUnmount(() => window.removeEventListener('storage', onStorage));
|
||||
★
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="field.fieldType === 'date'" class="date-field">
|
||||
<JalaliDateInput
|
||||
v-model="values[field.key]"
|
||||
mode="date"
|
||||
:required="field.required"
|
||||
:placeholder="field.placeholder || 'انتخاب تاریخ شمسی'"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="field.fieldType === 'file'" class="file-field">
|
||||
<input
|
||||
:key="fileInputKey[field.key] ?? 0"
|
||||
type="file"
|
||||
:disabled="fileBusy[field.key]"
|
||||
:aria-label="field.label || 'آپلود فایل'"
|
||||
@change="onFileChange(field.key, $event)"
|
||||
/>
|
||||
<small class="file-hint">
|
||||
حداکثر {{ Math.round(FORM_FILE_MAX_BYTES / 1024) }} کیلوبایت پس از فشردهسازی
|
||||
</small>
|
||||
<p v-if="fileBusy[field.key]" class="muted">در حال فشردهسازی…</p>
|
||||
<p v-else-if="values[field.key]" class="file-ok">
|
||||
{{ formFileDisplayName(values[field.key]) }}
|
||||
<button type="button" class="file-clear" @click="clearFile(field.key)">
|
||||
حذف
|
||||
</button>
|
||||
</p>
|
||||
<p v-if="fieldErrors[field.key]" class="error">{{ fieldErrors[field.key] }}</p>
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
v-model="values[field.key]"
|
||||
:type="field.fieldType === 'email' ? 'email' : field.fieldType === 'phone' ? 'tel' : field.fieldType === 'date' ? 'date' : field.fieldType === 'number' ? 'number' : field.fieldType === 'file' ? 'text' : 'text'"
|
||||
:placeholder="field.placeholder || (field.fieldType === 'file' ? 'نام فایل یا لینک' : '')"
|
||||
:type="field.fieldType === 'email' ? 'email' : field.fieldType === 'phone' ? 'tel' : field.fieldType === 'number' ? 'number' : 'text'"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:required="field.required"
|
||||
/>
|
||||
</label>
|
||||
@@ -246,7 +339,7 @@ onBeforeUnmount(() => window.removeEventListener('storage', onStorage));
|
||||
<button
|
||||
class="btn"
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
:disabled="saving || Object.values(fileBusy).some(Boolean)"
|
||||
:style="{ background: form.submitColor || '#14b8a6' }"
|
||||
>
|
||||
<UiIcon v-if="!saving" name="send" :size="16" />
|
||||
@@ -321,6 +414,35 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: #14b8a6
|
||||
.stars button { border: 0; background: none; font-size: 1.5rem; color: #cbd5e1; cursor: pointer; padding: 0; }
|
||||
.stars button.on { color: #eab308; }
|
||||
.heading { margin: 0.2rem 0 0; font-weight: 700; color: #0f172a; }
|
||||
.date-field { min-width: 0; }
|
||||
.date-field :deep(.jdi) { width: 100%; }
|
||||
.file-field { display: grid; gap: 0.35rem; }
|
||||
.file-field input[type='file'] {
|
||||
padding: 0.45rem 0.55rem;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-hint { color: #94a3b8; font-size: 0.75rem; font-weight: 400; }
|
||||
.file-ok {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #0f766e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.file-clear {
|
||||
border: 0;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border-radius: 6px;
|
||||
padding: 0.15rem 0.45rem;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hp {
|
||||
position: absolute;
|
||||
clip-path: inset(50%);
|
||||
|
||||
Reference in New Issue
Block a user