fix: Optimize flipping card animation backface and timing

This commit is contained in:
Timo Knuth
2026-01-22 15:46:31 +01:00
parent 05531cda3f
commit efb1654370
50 changed files with 12232 additions and 9632 deletions

View File

@@ -11,6 +11,14 @@ const envSchema = z.object({
REDIS_URL: z.string().optional(),
IP_SALT: z.string().default('development-salt-change-in-production'),
ENABLE_DEMO: z.string().default('false'),
// Cloudflare R2 (S3 Compatible)
R2_ACCOUNT_ID: z.string().optional(),
R2_ACCESS_KEY_ID: z.string().optional(),
R2_SECRET_ACCESS_KEY: z.string().optional(),
R2_BUCKET_NAME: z.string().default('qrmaster-menus'),
R2_PUBLIC_URL: z.string().optional(),
MAX_UPLOAD_SIZE: z.string().default('10485760'), // 10MB default
});
// During build, we might not have all env vars, so we'll use defaults

65
src/lib/r2.ts Normal file
View File

@@ -0,0 +1,65 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { env } from './env';
import crypto from 'crypto';
// Initialize S3 client for Cloudflare R2
const r2Client = new S3Client({
region: 'auto',
endpoint: `https://${env.R2_ACCOUNT_ID || 'placeholder'}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID || '',
secretAccessKey: env.R2_SECRET_ACCESS_KEY || '',
},
});
export async function uploadFileToR2(
file: Buffer,
filename: string,
contentType: string = 'application/pdf'
): Promise<string> {
// Generate a unique key for the file
const ext = filename.split('.').pop() || 'pdf';
const randomId = crypto.randomBytes(8).toString('hex');
const timestamp = Date.now();
const key = `uploads/${timestamp}_${randomId}.${ext}`;
await r2Client.send(
new PutObjectCommand({
Bucket: env.R2_BUCKET_NAME,
Key: key,
Body: file,
ContentType: contentType,
ContentDisposition: `inline; filename="${filename}"`,
// Cache for 1 year, as these are static files
CacheControl: 'public, max-age=31536000',
})
);
// Return the public URL
// If R2_PUBLIC_URL is set, use it (custom domain or r2.dev subdomain)
// Otherwise, construct a fallback (which might not work without public access enabled on bucket)
const publicUrl = env.R2_PUBLIC_URL
? `${env.R2_PUBLIC_URL}/${key}`
: `https://${env.R2_BUCKET_NAME}.r2.dev/${key}`;
return publicUrl;
}
export async function deleteFileFromR2(fileUrl: string): Promise<void> {
try {
// Extract key from URL
// URL format: https://domain.com/uploads/filename.pdf
const url = new URL(fileUrl);
const key = url.pathname.substring(1); // Remove leading slash
await r2Client.send(
new DeleteObjectCommand({
Bucket: env.R2_BUCKET_NAME,
Key: key,
})
);
} catch (error) {
console.error('Error deleting file from R2:', error);
// Suppress error, as deletion failure shouldn't block main flow
}
}

View File

@@ -1,245 +1,245 @@
export interface BreadcrumbItem {
name: string;
url: string;
}
export interface BlogPost {
title: string;
description: string;
slug: string;
author: string;
authorUrl: string;
datePublished: string;
dateModified: string;
image: string;
}
export interface FAQItem {
question: string;
answer: string;
}
export interface ProductOffer {
name: string;
price: string;
priceCurrency: string;
availability: string;
url: string;
}
export interface HowToStep {
name: string;
text: string;
url?: string;
}
export interface HowToTask {
name: string;
description: string;
steps: HowToStep[];
totalTime?: string;
}
export function organizationSchema() {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': 'https://www.qrmaster.net/#organization',
name: 'QR Master',
alternateName: 'QRMaster',
url: 'https://www.qrmaster.net',
logo: {
'@type': 'ImageObject',
url: 'https://www.qrmaster.net/static/og-image.png',
width: 1200,
height: 630,
},
image: 'https://www.qrmaster.net/static/og-image.png',
sameAs: [
'https://twitter.com/qrmaster',
],
contactPoint: {
'@type': 'ContactPoint',
contactType: 'Customer Support',
email: 'support@qrmaster.net',
availableLanguage: ['English', 'German'],
},
description: 'B2B SaaS platform for dynamic QR code generation with analytics, branding, and bulk generation for enterprise marketing campaigns.',
slogan: 'Dynamic QR codes that work smarter',
foundingDate: '2025',
areaServed: 'Worldwide',
serviceType: 'Software as a Service',
priceRange: '$0 - $29',
knowsAbout: [
'QR Code Generation',
'Marketing Analytics',
'Campaign Tracking',
'Dynamic QR Codes',
'Bulk QR Generation',
],
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: 'QR Master Plans',
itemListElement: [
{
'@type': 'Offer',
itemOffered: {
'@type': 'SoftwareApplication',
name: 'QR Master Free',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser',
},
},
{
'@type': 'Offer',
itemOffered: {
'@type': 'SoftwareApplication',
name: 'QR Master Pro',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser',
},
},
],
},
inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net',
};
}
export function websiteSchema() {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': 'https://www.qrmaster.net/#website',
name: 'QR Master',
url: 'https://www.qrmaster.net',
inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net',
publisher: {
'@id': 'https://www.qrmaster.net/#organization',
},
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: 'https://www.qrmaster.net/blog?q={search_term_string}',
},
'query-input': 'required name=search_term_string',
},
};
}
export function breadcrumbSchema(items: BreadcrumbItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
'@id': `https://www.qrmaster.net${items[items.length - 1]?.url}#breadcrumb`,
inLanguage: 'en',
mainEntityOfPage: `https://www.qrmaster.net${items[items.length - 1]?.url}`,
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: `https://www.qrmaster.net${item.url}`,
})),
};
}
export function blogPostingSchema(post: BlogPost) {
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'@id': `https://www.qrmaster.net/blog/${post.slug}#article`,
headline: post.title,
description: post.description,
image: post.image,
datePublished: post.datePublished,
dateModified: post.dateModified,
inLanguage: 'en',
mainEntityOfPage: `https://www.qrmaster.net/blog/${post.slug}`,
author: {
'@type': 'Person',
name: post.author,
url: post.authorUrl,
},
publisher: {
'@type': 'Organization',
name: 'QR Master',
url: 'https://www.qrmaster.net',
logo: {
'@type': 'ImageObject',
url: 'https://www.qrmaster.net/static/og-image.png',
width: 1200,
height: 630,
},
},
isPartOf: {
'@type': 'Blog',
'@id': 'https://www.qrmaster.net/blog#blog',
name: 'QR Master Blog',
url: 'https://www.qrmaster.net/blog',
},
};
}
export function faqPageSchema(faqs: FAQItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'@id': 'https://www.qrmaster.net/faq#faqpage',
inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net/faq',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
};
}
export function productSchema(product: { name: string; description: string; offers: ProductOffer[] }) {
return {
'@context': 'https://schema.org',
'@type': 'Product',
'@id': 'https://www.qrmaster.net/pricing#product',
name: product.name,
description: product.description,
inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net/pricing',
brand: {
'@type': 'Organization',
name: 'QR Master',
},
offers: product.offers.map((offer) => ({
'@type': 'Offer',
name: offer.name,
price: offer.price,
priceCurrency: offer.priceCurrency,
availability: offer.availability,
url: offer.url,
})),
};
}
export function howToSchema(task: HowToTask) {
return {
'@context': 'https://schema.org',
'@type': 'HowTo',
'@id': `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}#howto`,
name: task.name,
description: task.description,
inLanguage: 'en',
mainEntityOfPage: `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}`,
totalTime: task.totalTime || 'PT5M',
step: task.steps.map((step, index) => ({
'@type': 'HowToStep',
position: index + 1,
name: step.name,
text: step.text,
url: step.url,
})),
};
}
export interface BreadcrumbItem {
name: string;
url: string;
}
export interface BlogPost {
title: string;
description: string;
slug: string;
author: string;
authorUrl: string;
datePublished: string;
dateModified: string;
image: string;
}
export interface FAQItem {
question: string;
answer: string;
}
export interface ProductOffer {
name: string;
price: string;
priceCurrency: string;
availability: string;
url: string;
}
export interface HowToStep {
name: string;
text: string;
url?: string;
}
export interface HowToTask {
name: string;
description: string;
steps: HowToStep[];
totalTime?: string;
}
export function organizationSchema() {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': 'https://www.qrmaster.net/#organization',
name: 'QR Master',
alternateName: 'QRMaster',
url: 'https://www.qrmaster.net',
logo: {
'@type': 'ImageObject',
url: 'https://www.qrmaster.net/static/og-image.png',
width: 1200,
height: 630,
},
image: 'https://www.qrmaster.net/static/og-image.png',
sameAs: [
'https://twitter.com/qrmaster',
],
contactPoint: {
'@type': 'ContactPoint',
contactType: 'Customer Support',
email: 'support@qrmaster.net',
availableLanguage: ['English', 'German'],
},
description: 'B2B SaaS platform for dynamic QR code generation with analytics, branding, and bulk generation for enterprise marketing campaigns.',
slogan: 'Dynamic QR codes that work smarter',
foundingDate: '2025',
areaServed: 'Worldwide',
serviceType: 'Software as a Service',
priceRange: '$0 - $29',
knowsAbout: [
'QR Code Generation',
'Marketing Analytics',
'Campaign Tracking',
'Dynamic QR Codes',
'Bulk QR Generation',
],
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: 'QR Master Plans',
itemListElement: [
{
'@type': 'Offer',
itemOffered: {
'@type': 'SoftwareApplication',
name: 'QR Master Free',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser',
},
},
{
'@type': 'Offer',
itemOffered: {
'@type': 'SoftwareApplication',
name: 'QR Master Pro',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web Browser',
},
},
],
},
inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net',
};
}
export function websiteSchema() {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': 'https://www.qrmaster.net/#website',
name: 'QR Master',
url: 'https://www.qrmaster.net',
inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net',
publisher: {
'@id': 'https://www.qrmaster.net/#organization',
},
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: 'https://www.qrmaster.net/blog?q={search_term_string}',
},
'query-input': 'required name=search_term_string',
},
};
}
export function breadcrumbSchema(items: BreadcrumbItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
'@id': `https://www.qrmaster.net${items[items.length - 1]?.url}#breadcrumb`,
inLanguage: 'en',
mainEntityOfPage: `https://www.qrmaster.net${items[items.length - 1]?.url}`,
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: `https://www.qrmaster.net${item.url}`,
})),
};
}
export function blogPostingSchema(post: BlogPost) {
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'@id': `https://www.qrmaster.net/blog/${post.slug}#article`,
headline: post.title,
description: post.description,
image: post.image,
datePublished: post.datePublished,
dateModified: post.dateModified,
inLanguage: 'en',
mainEntityOfPage: `https://www.qrmaster.net/blog/${post.slug}`,
author: {
'@type': 'Person',
name: post.author,
url: post.authorUrl,
},
publisher: {
'@type': 'Organization',
name: 'QR Master',
url: 'https://www.qrmaster.net',
logo: {
'@type': 'ImageObject',
url: 'https://www.qrmaster.net/static/og-image.png',
width: 1200,
height: 630,
},
},
isPartOf: {
'@type': 'Blog',
'@id': 'https://www.qrmaster.net/blog#blog',
name: 'QR Master Blog',
url: 'https://www.qrmaster.net/blog',
},
};
}
export function faqPageSchema(faqs: FAQItem[]) {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'@id': 'https://www.qrmaster.net/faq#faqpage',
inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net/faq',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
};
}
export function productSchema(product: { name: string; description: string; offers: ProductOffer[] }) {
return {
'@context': 'https://schema.org',
'@type': 'Product',
'@id': 'https://www.qrmaster.net/pricing#product',
name: product.name,
description: product.description,
inLanguage: 'en',
mainEntityOfPage: 'https://www.qrmaster.net/pricing',
brand: {
'@type': 'Organization',
name: 'QR Master',
},
offers: product.offers.map((offer) => ({
'@type': 'Offer',
name: offer.name,
price: offer.price,
priceCurrency: offer.priceCurrency,
availability: offer.availability,
url: offer.url,
})),
};
}
export function howToSchema(task: HowToTask) {
return {
'@context': 'https://schema.org',
'@type': 'HowTo',
'@id': `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}#howto`,
name: task.name,
description: task.description,
inLanguage: 'en',
mainEntityOfPage: `https://www.qrmaster.net/blog/${task.name.toLowerCase().replace(/\s+/g, '-')}`,
totalTime: task.totalTime || 'PT5M',
step: task.steps.map((step, index) => ({
'@type': 'HowToStep',
position: index + 1,
name: step.name,
text: step.text,
url: step.url,
})),
};
}

View File

@@ -1,186 +1,186 @@
/**
* Zod Validation Schemas for API endpoints
* Centralized validation logic for type-safety and security
*/
import { z } from 'zod';
// ==========================================
// QR Code Schemas
// ==========================================
export const qrStyleSchema = z.object({
fgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid foreground color format').optional(),
bgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid background color format').optional(),
cornerStyle: z.enum(['square', 'rounded']).optional(),
size: z.number().min(100).max(1000).optional(),
});
export const createQRSchema = z.object({
title: z.string()
.min(1, 'Title is required')
.max(100, 'Title must be less than 100 characters'),
content: z.record(z.any()), // Accept any object structure for content
isStatic: z.boolean().optional(),
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK'], {
errorMap: () => ({ message: 'Invalid content type' })
}),
tags: z.array(z.string()).optional(),
style: z.object({
foregroundColor: z.string().optional(),
backgroundColor: z.string().optional(),
cornerStyle: z.enum(['square', 'rounded']).optional(),
size: z.number().optional(),
}).optional(),
});
export const updateQRSchema = z.object({
title: z.string()
.min(1, 'Title is required')
.max(100, 'Title must be less than 100 characters')
.optional(),
content: z.string()
.min(1, 'Content is required')
.max(5000, 'Content must be less than 5000 characters')
.optional(),
style: qrStyleSchema.optional(),
isActive: z.boolean().optional(),
});
export const bulkQRSchema = z.object({
qrs: z.array(
z.object({
title: z.string().min(1).max(100),
content: z.string().min(1).max(5000),
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK']),
})
).min(1, 'At least one QR code is required')
.max(100, 'Maximum 100 QR codes per bulk creation'),
});
// ==========================================
// Authentication Schemas
// ==========================================
export const loginSchema = z.object({
email: z.string()
.email('Invalid email format')
.toLowerCase(),
password: z.string()
.min(1, 'Password is required'),
});
export const signupSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters')
.trim(),
email: z.string()
.email('Invalid email format')
.toLowerCase()
.trim(),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
});
export const forgotPasswordSchema = z.object({
email: z.string()
.email('Invalid email format')
.toLowerCase()
.trim(),
});
export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Reset token is required'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
});
// ==========================================
// Settings Schemas
// ==========================================
export const updateProfileSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters')
.trim(),
});
export const changePasswordSchema = z.object({
currentPassword: z.string()
.min(1, 'Current password is required'),
newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
});
// ==========================================
// Stripe Schemas
// ==========================================
export const createCheckoutSchema = z.object({
priceId: z.string().min(1, 'Price ID is required'),
});
// ==========================================
// Newsletter Schemas
// ==========================================
export const newsletterSubscribeSchema = z.object({
email: z.string()
.email('Invalid email format')
.toLowerCase()
.trim()
.max(255, 'Email must be less than 255 characters'),
});
// ==========================================
// Helper: Format Zod Errors
// ==========================================
export function formatZodError(error: z.ZodError) {
return {
error: 'Validation failed',
details: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
})),
};
}
// ==========================================
// Helper: Validate with Zod
// ==========================================
export async function validateRequest<T>(
schema: z.ZodSchema<T>,
data: unknown
): Promise<{ success: true; data: T } | { success: false; error: any }> {
try {
const validatedData = schema.parse(data);
return { success: true, data: validatedData };
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, error: formatZodError(error) };
}
return { success: false, error: { error: 'Invalid request data' } };
}
}
/**
* Zod Validation Schemas for API endpoints
* Centralized validation logic for type-safety and security
*/
import { z } from 'zod';
// ==========================================
// QR Code Schemas
// ==========================================
export const qrStyleSchema = z.object({
fgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid foreground color format').optional(),
bgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid background color format').optional(),
cornerStyle: z.enum(['square', 'rounded']).optional(),
size: z.number().min(100).max(1000).optional(),
});
export const createQRSchema = z.object({
title: z.string()
.min(1, 'Title is required')
.max(100, 'Title must be less than 100 characters'),
content: z.record(z.any()), // Accept any object structure for content
isStatic: z.boolean().optional(),
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK'], {
errorMap: () => ({ message: 'Invalid content type' })
}),
tags: z.array(z.string()).optional(),
style: z.object({
foregroundColor: z.string().optional(),
backgroundColor: z.string().optional(),
cornerStyle: z.enum(['square', 'rounded']).optional(),
size: z.number().optional(),
}).optional(),
});
export const updateQRSchema = z.object({
title: z.string()
.min(1, 'Title is required')
.max(100, 'Title must be less than 100 characters')
.optional(),
content: z.string()
.min(1, 'Content is required')
.max(5000, 'Content must be less than 5000 characters')
.optional(),
style: qrStyleSchema.optional(),
isActive: z.boolean().optional(),
});
export const bulkQRSchema = z.object({
qrs: z.array(
z.object({
title: z.string().min(1).max(100),
content: z.string().min(1).max(5000),
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT', 'PDF', 'APP', 'COUPON', 'FEEDBACK']),
})
).min(1, 'At least one QR code is required')
.max(100, 'Maximum 100 QR codes per bulk creation'),
});
// ==========================================
// Authentication Schemas
// ==========================================
export const loginSchema = z.object({
email: z.string()
.email('Invalid email format')
.toLowerCase(),
password: z.string()
.min(1, 'Password is required'),
});
export const signupSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters')
.trim(),
email: z.string()
.email('Invalid email format')
.toLowerCase()
.trim(),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
});
export const forgotPasswordSchema = z.object({
email: z.string()
.email('Invalid email format')
.toLowerCase()
.trim(),
});
export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Reset token is required'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
});
// ==========================================
// Settings Schemas
// ==========================================
export const updateProfileSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters')
.trim(),
});
export const changePasswordSchema = z.object({
currentPassword: z.string()
.min(1, 'Current password is required'),
newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
});
// ==========================================
// Stripe Schemas
// ==========================================
export const createCheckoutSchema = z.object({
priceId: z.string().min(1, 'Price ID is required'),
});
// ==========================================
// Newsletter Schemas
// ==========================================
export const newsletterSubscribeSchema = z.object({
email: z.string()
.email('Invalid email format')
.toLowerCase()
.trim()
.max(255, 'Email must be less than 255 characters'),
});
// ==========================================
// Helper: Format Zod Errors
// ==========================================
export function formatZodError(error: z.ZodError) {
return {
error: 'Validation failed',
details: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
})),
};
}
// ==========================================
// Helper: Validate with Zod
// ==========================================
export async function validateRequest<T>(
schema: z.ZodSchema<T>,
data: unknown
): Promise<{ success: true; data: T } | { success: false; error: any }> {
try {
const validatedData = schema.parse(data);
return { success: true, data: validatedData };
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, error: formatZodError(error) };
}
return { success: false, error: { error: 'Invalid request data' } };
}
}