initial commit
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-19 20:58:15 +03:30
commit dacbd3a328
112 changed files with 19956 additions and 0 deletions

662
src/lib/api.ts Normal file
View File

@@ -0,0 +1,662 @@
import type * as Types from './types';
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') || 'https://api.east-guilan-ce.ir';
type ApiErrorBody = {
error?: string;
detail?: string;
message?: string;
};
class ApiClient {
private baseUrl: string;
private isRefreshing = false;
private refreshSubscribers: Array<(token: string) => void> = [];
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('access_token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
private async refreshAccessToken(): Promise<string> {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await fetch(`${this.baseUrl}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
throw new Error('Session expired. Please login again.');
}
const data: Types.TokenSchema = await response.json();
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
return data.access_token;
}
private onRefreshed(token: string) {
this.refreshSubscribers.forEach(callback => callback(token));
this.refreshSubscribers = [];
}
private addRefreshSubscriber(callback: (token: string) => void) {
this.refreshSubscribers.push(callback);
}
async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const config: RequestInit = {
...options,
headers: {
...this.getAuthHeaders(),
...options.headers,
},
};
const response = await fetch(url, config);
// Handle 401 with automatic token refresh
if (response.status === 401 && localStorage.getItem('refresh_token')) {
if (!this.isRefreshing) {
this.isRefreshing = true;
try {
const newToken = await this.refreshAccessToken();
this.isRefreshing = false;
this.onRefreshed(newToken);
// After you obtained `newToken` successfully:
const retryConfig: RequestInit = {
...options,
headers: {
...(options.headers || {}),
Authorization: `Bearer ${newToken}`, // put last so it can't be overwritten
},
};
const retryResponse = await fetch(url, retryConfig);
if (!retryResponse.ok) {
const err = await retryResponse.json().catch(() => ({}));
throw new Error(err.error || err.detail || 'Request failed');
}
return retryResponse.json();
} catch (error) {
this.isRefreshing = false;
throw error;
}
} else {
return new Promise((resolve, reject) => {
this.addRefreshSubscriber(async (token: string) => {
try {
const retryConfig: RequestInit = {
...options,
headers: {
...(options.headers || {}),
Authorization: `Bearer ${token}`,
},
};
const retryResponse = await fetch(url, retryConfig);
if (!retryResponse.ok) {
const err = (await retryResponse.json().catch(() => ({}))) as ApiErrorBody;
reject(new Error(err.error || err.detail || 'Request failed after refresh'));
} else {
resolve(retryResponse.json());
}
} catch (e) {
reject(e);
}
});
});
}
}
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
const message =
body?.error || body?.detail || body?.message || 'خطای ناشناخته رخ داد';
throw new Error(message);
}
return response.json() as Promise<T>;
}
// ============= Auth Endpoints =============
async register(data: Types.UserRegistrationSchema) {
return this.request<Types.MessageSchema>('/api/auth/register', {
method: 'POST',
body: JSON.stringify(data),
});
}
async login(data: Types.UserLoginSchema) {
return this.request<Types.TokenSchema>('/api/auth/login', {
method: 'POST',
body: JSON.stringify(data),
});
}
async refreshToken(data: Types.TokenRefreshIn) {
return this.request<Types.TokenSchema>('/api/auth/refresh', {
method: 'POST',
body: JSON.stringify(data),
});
}
async verifyEmail(token: string): Promise<Types.MessageSchema> {
const url = `${this.baseUrl}/api/auth/verify-email/${encodeURIComponent(token)}`;
const response = await fetch(url, { method: 'GET' });
if (response.ok) {
return response.json() as Promise<Types.MessageSchema>;
}
const data = (await response.json().catch(() => ({}))) as ApiErrorBody;
const errMsg: string =
(data && (data.error || data.detail)) || 'خطای ناشناخته رخ داد';
throw new Error(errMsg);
}
async resendVerification(email: string) {
return this.request<Types.MessageSchema>(
`/api/auth/resend-verification?email=${encodeURIComponent(email)}`,
{ method: 'POST' }
);
}
async getProfile() {
const token = localStorage.getItem('access_token');
return this.request<Types.UserProfileSchema>('/api/auth/profile'
);
}
async updateProfile(data: Types.UserUpdateSchema) {
return this.request<Types.UserProfileSchema>('/api/auth/profile', {
method: 'PUT',
body: JSON.stringify(data),
});
}
async uploadProfilePicture(file: File) {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('access_token');
const response = await fetch(`${this.baseUrl}/api/auth/profile/picture`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: formData,
});
if (!response.ok) {
const error: Types.ErrorSchema = await response.json().catch(() => ({
detail: 'خطای آپلود تصویر',
}));
throw new Error(error.detail);
}
return response.json() as Promise<Types.MessageSchema>;
}
async deleteProfilePicture() {
return this.request<Types.MessageSchema>('/api/auth/profile/picture', {
method: 'DELETE',
});
}
async requestPasswordReset(email: string) {
return this.request<Types.MessageSchema>('/api/auth/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
}
async resetPasswordConfirm(token: string, new_password: string) {
return this.request<Types.MessageSchema>('/api/auth/reset-password-confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, new_password }),
});
}
async checkUsername(username: string) {
return this.request<Types.UsernameCheckSchema>(
`/api/auth/check-username?username=${encodeURIComponent(username)}`
);
}
// Admin auth endpoints
async listDeletedUsers() {
return this.request<Types.UserProfileSchema[]>('/api/auth/users/deleted');
}
async restoreUser(userId: number) {
return this.request<Types.MessageSchema>(`/api/auth/users/${userId}/restore`, {
method: 'POST',
});
}
async listUsers(params?: {
search?: string;
role?: 'staff' | 'superuser';
student_id?: string;
university?: string;
major?: string;
is_active?: 'true' | 'false';
limit?: number;
offset?: number;
}) {
const query = new URLSearchParams();
if (params?.search) query.set('search', params.search);
if (params?.role) query.set('role', params.role);
if (params?.student_id) query.set('student_id', params.student_id);
if (params?.university) query.set('university', params.university);
if (params?.major) query.set('major', params.major);
if (params?.is_active) query.set('is_active', params.is_active);
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
}
// ============= Blog Endpoints =============
async getPosts(params?: {
page?: number;
limit?: number;
category?: string;
tag?: string;
search?: string;
featured?: boolean;
author?: string;
}) {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.category) queryParams.append('category', params.category);
if (params?.tag) queryParams.append('tag', params.tag);
if (params?.search) queryParams.append('search', params.search);
if (params?.featured !== undefined) queryParams.append('featured', params.featured.toString());
if (params?.author) queryParams.append('author', params.author);
const query = queryParams.toString();
return this.request<Types.PostListSchema[]>(`/api/blog/posts${query ? `?${query}` : ''}`);
}
async getPost(slug: string) {
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${slug}`);
}
async createPost(data: Types.PostCreateSchema) {
return this.request<Types.PostDetailSchema>('/api/blog/posts', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updatePost(slug: string, data: Types.PostCreateSchema) {
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${slug}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deletePost(slug: string) {
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}`, {
method: 'DELETE',
});
}
async listDeletedPosts() {
return this.request<Types.PostListSchema[]>('/api/blog/deleted/posts');
}
async restorePost(postId: number) {
return this.request<Types.MessageSchema>(`/api/blog/deleted/posts/${postId}/restore`, {
method: 'POST',
});
}
// Comments
async getComments(slug: string) {
return this.request<Types.CommentSchema[]>(`/api/blog/posts/${slug}/comments`);
}
async createComment(slug: string, data: Types.CommentCreateSchema) {
return this.request<Types.CommentSchema>(`/api/blog/posts/${slug}/comments`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async listDeletedComments() {
return this.request<Types.CommentSchema[]>('/api/blog/deleted/comments');
}
async restoreComment(commentId: number) {
return this.request<Types.MessageSchema>(`/api/blog/deleted/comments/${commentId}/restore`, {
method: 'POST',
});
}
// Likes
async toggleLike(slug: string) {
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}/like`, {
method: 'POST',
});
}
async getLikesCount(slug: string) {
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}/likes`);
}
// Categories
async getCategories() {
return this.request<Types.CategorySchema[]>('/api/blog/categories');
}
async getCategory(slug: string) {
return this.request<Types.CategorySchema>(`/api/blog/categories/${slug}`);
}
async listDeletedCategories() {
return this.request<Types.CategorySchema[]>('/api/blog/deleted/categories');
}
async restoreCategory(categoryId: number) {
return this.request<Types.MessageSchema>(`/api/blog/deleted/categories/${categoryId}/restore`, {
method: 'POST',
});
}
// Tags
async getTags() {
return this.request<Types.TagSchema[]>('/api/blog/tags');
}
async getTag(slug: string) {
return this.request<Types.TagSchema>(`/api/blog/tags/${slug}`);
}
async listDeletedTags() {
return this.request<Types.TagSchema[]>('/api/blog/deleted/tags');
}
async restoreTag(tagId: number) {
return this.request<Types.MessageSchema>(`/api/blog/deleted/tags/${tagId}/restore`, {
method: 'POST',
});
}
// ============= Events Endpoints =============
async getEvents(params: {
status?: 'draft' | 'published' | 'cancelled' | 'completed';
statuses?: Array<'draft' | 'published' | 'cancelled' | 'completed'>; // جدید: چندتا وضعیت
event_type?: 'online' | 'on_site' | 'hybrid';
search?: string;
limit?: number;
offset?: number;
} = {}) {
const q = new URLSearchParams();
if (params.statuses?.length) {
params.statuses.forEach(s => q.append('status', s));
} else if (params.status) {
q.set('status', params.status);
}
if (params.event_type) q.set('event_type', params.event_type);
if (params.search) q.set('search', params.search);
if (params.limit != null) q.set('limit', String(params.limit));
if (params.offset != null) q.set('offset', String(params.offset));
const url = `/api/events/${q.toString() ? `?${q.toString()}` : ''}`;
return this.request<Types.EventListItemSchema[]>(url, { method: 'GET' });
}
async getEventBySlug(slug: string) {
return this.request<Types.EventDetailSchema>(`/api/events/slug/${encodeURIComponent(slug)}`, { method: 'GET' });
}
async getEventAdminDetail(eventId: number) {
return this.request<Types.EventAdminDetailSchema>(`/api/events/${eventId}/admin-detail`);
}
async listEventRegistrationsAdmin(
eventId: number,
params?: {
statuses?: string[];
university?: string;
major?: string;
search?: string;
limit?: number;
offset?: number;
}
) {
const query = new URLSearchParams();
if (params?.statuses?.length) {
params.statuses.forEach((status) => query.append('status', status));
}
if (params?.university) query.set('university', params.university);
if (params?.major) query.set('major', params.major);
if (params?.search) query.set('search', params.search);
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
return this.request<Types.PaginatedResponse<Types.RegistrationAdminSchema>>(
`/api/events/${eventId}/admin-registrations${query.toString() ? `?${query.toString()}` : ''}`
);
}
async updateEvent(eventId: number, data: Types.EventUpdateSchema) {
return this.request<Types.EventSchema>(`/api/events/${eventId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
async deleteEvent(eventId: number) {
return this.request<Types.MessageSchema>(`/api/events/${eventId}`, {
method: 'DELETE',
});
}
async registerForEvent(eventId: number, discountCode?: string | null) {
const payload = (discountCode ?? '').trim();
const init: RequestInit = { method: 'POST' };
if (payload) {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify({ discount_code: payload });
}
return this.request<Types.EventRegistrationSchema>(`/api/events/${eventId}/register`, init);
}
async ChangeRegistrationStatus(registrationId: number, status: string) {
return this.request(
`/api/events/registrations/${registrationId}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: status }),
}
);
}
async listEventRegistrations(eventId: number, limit = 20, offset = 0) {
const url = `/api/events/${eventId}/registrations?limit=${limit}&offset=${offset}`;
return this.request<Types.EventRegistrationSchema[]>(url, { method: 'GET' });
}
async cancelEventRegistration(eventId: number) {
return this.request<Types.MessageSchema>(`/api/events/${eventId}/register`, {
method: 'DELETE',
});
}
async verifyMyRegistration(ticket_id: string) {
return this.request<{
event_image: string;
event_title: string;
event_type: string;
ticket_id: string;
status: string;
registered_at: string;
success_markdown: string;
}>(`/api/events/registerations/verify/${ticket_id}`, {method: 'GET'});
}
async getMyRegistrations() {
return this.request<Types.MyEventRegistrationSchema[]>(
`/api/events/my-registrations`,
{ method: 'GET' }
);
}
async getRegistrationStatus(eventId: number) {
return this.request<Types.RegistrationStatusSchema>(
`/api/events/${eventId}/is-registered`,
{ method: 'GET' }
);
}
// ============= Payment Endpoints =============
async createPayment(input: {
event_id: number;
description: string;
discount_code?: string | null;
mobile?: string | null;
email?: string | null;
}) {
return this.request<Types.CreatePaymentOut>(
'/api/payments/create',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}
);
}
async getPaymentByRef(refId: string) {
return this.request<{
ref_id: string;
authority: string;
base_amount: number;
discount_amount: number;
amount: number;
status: 'INIT' | 'PENDING' | 'PAID' | 'FAILED' | 'CANCELED';
verified_at?: string | null;
event: {
id: number;
title: string;
slug: string;
image_url?: string | null;
success_markdown?: string | null;
};
}>(`/api/payments/by-ref/${encodeURIComponent(refId)}`, { method: 'GET' });
}
async checkDiscountCode(event_id: number, code: string) {
return this.request<{
discount_amount: number;
final_price: number;
}>(
`/api/payments/coupon/check`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({code: code, event_id: event_id}),
}
);
}
// ============= Gallery Endpoints =============
async getGalleryImages(params?: {
page?: number;
limit?: number;
tag?: string;
}) {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.tag) queryParams.append('tag', params.tag);
const query = queryParams.toString();
return this.request<Types.GalleryImageSchema[]>(`/api/gallery/images${query ? `?${query}` : ''}`);
}
async uploadGalleryImage(file: File, data: Types.GalleryImageCreateSchema) {
const formData = new FormData();
formData.append('file', file);
formData.append('title', data.title);
if (data.description) formData.append('description', data.description);
if (data.tag_ids) formData.append('tag_ids', JSON.stringify(data.tag_ids));
const token = localStorage.getItem('access_token');
const response = await fetch(`${this.baseUrl}/api/gallery/images`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: formData,
});
if (!response.ok) {
const error: Types.ErrorSchema = await response.json().catch(() => ({
detail: 'خطای آپلود تصویر',
}));
throw new Error(error.detail);
}
return response.json() as Promise<Types.GalleryImageSchema>;
}
async deleteGalleryImage(imageId: number) {
return this.request<Types.MessageSchema>(`/api/gallery/images/${imageId}`, {
method: 'DELETE',
});
}
async getMajors(): Promise<Types.MajorOption[]> {
return this.request('/api/meta/majors', { method: 'GET' });
}
async getUniversities(): Promise<Types.MajorOption[]> {
return this.request('/api/meta/universities', { method: 'GET' });
}
async subscribeNewsletter(email: string) {
return this.request<{ message: string, success: boolean }>(
`/api/communications/newsletter/subscribe/`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email }),
}
);
}
}
export const api = new ApiClient(API_BASE_URL);

1
src/lib/swagger.json Normal file

File diff suppressed because one or more lines are too long

354
src/lib/types.ts Normal file
View File

@@ -0,0 +1,354 @@
// API Response Types based on Swagger schema
export interface MessageSchema {
message: string;
}
export interface ErrorSchema {
detail: string;
}
// Auth Types
export interface TokenSchema {
access_token: string;
refresh_token: string;
}
export interface MajorOption {
code: string;
label: string;
}
export interface UserProfileSchema {
id: number;
email: string;
username: string;
first_name: string;
last_name: string;
profile_picture?: string;
bio?: string;
student_id?: string | null;
year_of_study?: number;
university?: string;
major?: string;
date_joined: string;
is_email_verified?: boolean;
is_active?: boolean;
is_staff?: boolean;
is_superuser?: boolean;
is_committee?: boolean;
is_deleted?: boolean;
deleted_at?: string | null;
}
export interface UserListSchema {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
full_name?: string | null;
is_active: boolean;
is_staff: boolean;
is_superuser: boolean;
date_joined: string;
}
export interface UserRegistrationSchema {
email: string;
password: string;
username: string;
first_name: string;
last_name: string;
student_id: string;
year_of_study: number;
major: string;
university: string;
}
export type UserUpdateSchema = {
first_name?: string | null;
last_name?: string | null;
bio?: string | null;
year_of_study?: number | null;
major?: string | null;
university?: string | null;
student_id?: number | null;
};
export interface UserLoginSchema {
email: string;
password: string;
}
export interface TokenRefreshIn {
refresh_token: string;
}
export interface UsernameCheckSchema {
available: boolean;
}
export interface PasswordResetRequestSchema {
email: string;
}
export interface PasswordResetConfirmSchema {
token: string;
password: string;
}
// Blog Types
export interface PostListSchema {
id: number;
title: string;
slug: string;
excerpt?: string;
featured_image?: string;
author: {
id: number;
username: string;
first_name: string;
last_name: string;
profile_picture?: string;
};
category?: {
id: number;
name: string;
slug: string;
description?: string;
};
tags: Array<{
id: number;
name: string;
slug: string;
}>;
status: string;
published_at?: string;
created_at: string;
is_featured: boolean;
reading_time?: number;
}
export interface PostDetailSchema extends PostListSchema {
content: string;
updated_at: string;
views_count?: number;
}
export interface PostCreateSchema {
title: string;
content: string;
summary: string;
category_id?: number;
tag_ids?: number[];
featured_image?: string;
is_featured?: boolean;
status?: 'draft' | 'published';
}
export interface CommentSchema {
id: number;
content: string;
author: {
id: number;
username: string;
first_name: string;
last_name: string;
};
post_id: number;
post_title: string;
post_slug: string;
parent_id?: number;
created_at: string;
is_approved: boolean;
}
export interface CommentCreateSchema {
content: string;
parent_id?: number;
}
export interface CategorySchema {
id: number;
name: string;
slug: string;
description?: string;
created_at: string;
}
export interface TagSchema {
id: number;
name: string;
slug: string;
created_at: string;
}
// Events Types
export interface EventListItemSchema {
id: number;
title: string;
slug: string;
description: string;
featured_image?: string | null;
absolute_featured_image_url?: string | null;
event_type: 'online' | 'on_site' | 'hybrid';
address?: string | null;
location?: string | null;
online_link?: string | null;
start_time: string; // ISO
end_time: string; // ISO
registration_start_date?: string | null;
registration_end_date?: string | null;
capacity?: number | null;
price?: number | null;
status: 'draft' | 'published' | 'cancelled' | 'completed';
registration_count: number;
created_at: string;
}
export interface EventGalleryItem {
id: number;
title: string;
description: string;
absolute_image_url?: string | null;
width?: number;
height?: number;
}
export interface EventDetailSchema extends EventListItemSchema {
description_html: string;
gallery_images: EventGalleryItem[];
updated_at: string;
registration_success_markdown: string;
}
export interface EventCreateSchema {
title: string;
description: string;
start_date: string;
end_date?: string;
location: string;
capacity?: number;
event_image?: string;
requirements?: string;
is_registration_open?: boolean;
}
export interface PaymentAdminSchema {
id: number;
authority?: string | null;
ref_id?: string | null;
status: number;
status_label: string;
base_amount: number;
discount_amount: number;
amount: number;
verified_at?: string | null;
created_at: string;
discount_code?: string | null;
}
export interface RegistrationAdminSchema {
id: number;
ticket_id: string;
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
status_label: string;
registered_at: string;
final_price?: number | null;
discount_amount?: number | null;
user: {
id: number;
username: string;
first_name: string;
last_name: string;
email: string;
};
payments: PaymentAdminSchema[];
}
export interface EventAdminDetailSchema extends EventDetailSchema {
registrations: RegistrationAdminSchema[];
}
export interface EventUpdateSchema {
title?: string;
description?: string;
event_type?: 'online' | 'on_site' | 'hybrid';
address?: string | null;
location?: string | null;
online_link?: string | null;
start_time?: string;
end_time?: string | null;
registration_start_date?: string | null;
registration_end_date?: string | null;
capacity?: number | null;
price?: number | null;
status?: 'draft' | 'published' | 'cancelled' | 'completed';
gallery_image_ids?: number[] | null;
}
export interface EventRegistrationSchema {
id: number;
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
ticket_id: string;
registered_at: string;
created_at: string;
updated_at: string;
user: {
id: number;
username: string;
first_name: string;
last_name: string;
};
event_id: number;
}
export interface RegistrationStatusSchema {
is_registered: boolean;
}
export interface MyEventRegistrationSchema {
id: number;
created_at: string;
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
event: EventListItemSchema;
}
// Gallery Types
export interface GalleryImageSchema {
id: number;
title: string;
description?: string;
image: string;
uploaded_by: {
id: number;
username: string;
};
created_at: string;
tags: TagSchema[];
}
export interface GalleryImageCreateSchema {
title: string;
description?: string;
tag_ids?: number[];
}
// Pagination
export interface PaginatedResponse<T> {
results: T[];
count: number;
next?: string;
previous?: string;
}
// payment
export interface CreatePaymentOut {
start_pay_url: string;
authority: string;
base_amount: number;
discount_amount: number;
amount: number;
}

99
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,99 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import type * as Types from '@/lib/types';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatJalali(iso?: string, withTime: boolean = true): string {
if (!iso) return '—';
try {
const d = new Date(iso);
// آیا تقویم Persian در محیط کاربر پشتیبانی می‌شود؟
const hasPersian = Intl.DateTimeFormat.supportedLocalesOf(['fa-IR-u-ca-persian']).length > 0;
const locale = hasPersian ? 'fa-IR-u-ca-persian' : 'fa-IR';
const weekday = new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(d);
const day = new Intl.DateTimeFormat(locale, { day: 'numeric' }).format(d);
const month = new Intl.DateTimeFormat(locale, { month: 'long' }).format(d);
const year = new Intl.DateTimeFormat(locale, { year: 'numeric' }).format(d);
const datePart = `${weekday}، ${day} ${month} ${year}`;
if (!withTime) return datePart;
const timePart = new Intl.DateTimeFormat('fa-IR', {
hour: '2-digit',
minute: '2-digit',
// اگر 24ساعته می‌خوای: hourCycle: 'h23'
}).format(d);
return `${datePart}, ساعت ${timePart}`;
} catch {
return '—';
}
}
const DEFAULT_THUMB = '/images/event-placeholder.svg';
export const getThumbUrl = (e: Types.EventListItemSchema) =>
e.absolute_featured_image_url ||
e.featured_image ||
DEFAULT_THUMB;
const PERSIAN_DIGITS = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
export function toPersianDigits(value?: string | number | null) {
if (value == null) return '—';
return String(value).replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] ?? digit);
}
export function formatNumberPersian(value?: number | string | null) {
if (value == null) return '—';
const num = Number(value);
if (!Number.isFinite(num)) return '—';
return toPersianDigits(num.toLocaleString('en-US'));
}
export function formatToman(value?: number | null) {
if (value == null) return '—';
const amount = Math.floor(Number(value) / 10);
if (!Number.isFinite(amount)) return '—';
return `${toPersianDigits(amount.toLocaleString('en-US'))} تومان`;
}
type ApiErrorLike = {
error?: string;
detail?: string;
message?: string;
};
const resolveMessageFromRecord = (record?: ApiErrorLike) => {
if (!record) return undefined;
return record.error || record.detail || record.message;
};
export function resolveErrorMessage(error: unknown, fallback = 'خطایی رخ داد. لطفاً دوباره تلاش کنید.') {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === 'string' && error.trim()) {
return error;
}
if (typeof error === 'object' && error !== null) {
const err = error as {
response?: { data?: ApiErrorLike };
data?: ApiErrorLike;
error?: string;
detail?: string;
message?: string;
};
return (
resolveMessageFromRecord(err.response?.data) ||
resolveMessageFromRecord(err.data) ||
resolveMessageFromRecord(err) ||
fallback
);
}
return fallback;
}