From 99d29bbce56303581d07654fddf04480163c6e0a Mon Sep 17 00:00:00 2001 From: AmirmahdiNourkazemi Date: Mon, 23 Feb 2026 21:04:18 +0330 Subject: [PATCH] feat: promotion feature added --- app/admin/dashboard/page.tsx | 10 + app/admin/promotions/page.tsx | 174 ++++++++ components/admin/promotions/PromotionForm.tsx | 395 ++++++++++++++++++ components/admin/promotions/PromotionList.tsx | 199 +++++++++ lib/api/promotions.ts | 65 +++ types/promotion.ts | 69 +++ 6 files changed, 912 insertions(+) create mode 100644 app/admin/promotions/page.tsx create mode 100644 components/admin/promotions/PromotionForm.tsx create mode 100644 components/admin/promotions/PromotionList.tsx create mode 100644 lib/api/promotions.ts create mode 100644 types/promotion.ts diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index 18167a3..da114fd 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -3,6 +3,7 @@ import { useAuth } from '@/contexts/AuthContext'; import Link from 'next/link'; import { UsersIcon, CubeIcon, TagIcon , BellIcon } from '@heroicons/react/24/outline'; +import { MegaphoneIcon } from 'lucide-react'; // import { BellIcon } from 'lucide-react'; export default function Dashboard() { @@ -67,6 +68,15 @@ export default function Dashboard() {

+ +
+ +

مدیریت تبلیغات

+

+ ایجاد و مدیریت تبلیغات، بنرها و اسلایدرها +

+
+ diff --git a/app/admin/promotions/page.tsx b/app/admin/promotions/page.tsx new file mode 100644 index 0000000..13a1c71 --- /dev/null +++ b/app/admin/promotions/page.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useAuth } from '@/contexts/AuthContext'; +import { promotionsApi } from '@/lib/api/promotions'; +import PromotionList from '@/components/admin/promotions/PromotionList'; +import PromotionForm from '@/components/admin/promotions/PromotionForm'; +import { Promotion, CreatePromotionData, UpdatePromotionData } from '@/types/promotion'; +import { PlusIcon } from '@heroicons/react/24/outline'; + +export default function PromotionsPage() { + const { token } = useAuth(); + const [promotions, setPromotions] = useState([]); + const [selectedPromotion, setSelectedPromotion] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isFormVisible, setIsFormVisible] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const initialLoadRef = useRef(false); + + // Load promotions on mount + useEffect(() => { + if (token && !initialLoadRef.current) { + initialLoadRef.current = true; + loadPromotions(); + } + }, [token]); + + const loadPromotions = async () => { + setIsLoading(true); + setError(null); + try { + const data = await promotionsApi.getPromotions(token!); + setPromotions(data); + } catch (err) { + setError('خطا در دریافت لیست تبلیغات'); + } finally { + setIsLoading(false); + } + }; + + const handleCreate = () => { + setSelectedPromotion(null); + setIsFormVisible(true); + }; + + const handleEdit = (promotion: Promotion) => { + setSelectedPromotion(promotion); + setIsFormVisible(true); + }; + + const handleDelete = async (promotion: Promotion) => { + if (!confirm(`آیا از حذف تبلیغ "${promotion.title || 'بدون عنوان'}" اطمینان دارید؟`)) { + return; + } + + setIsLoading(true); + setError(null); + setSuccess(null); + + try { + await promotionsApi.deletePromotion(promotion.id, token!); + setSuccess('تبلیغ با موفقیت حذف شد'); + await loadPromotions(); + } catch (err) { + setError(err instanceof Error ? err.message : 'خطا در حذف تبلیغ'); + } finally { + setIsLoading(false); + } + }; + + const handleToggleActive = async (promotion: Promotion) => { + setIsLoading(true); + setError(null); + setSuccess(null); + + try { + await promotionsApi.toggleActive(promotion.id, !promotion.is_active, token!); + setSuccess(`تبلیغ با موفقیت ${!promotion.is_active ? 'فعال' : 'غیرفعال'} شد`); + await loadPromotions(); + } catch (err) { + setError(err instanceof Error ? err.message : 'خطا در تغییر وضعیت تبلیغ'); + } finally { + setIsLoading(false); + } + }; + + const handleSubmit = async (data: CreatePromotionData | UpdatePromotionData) => { + setIsLoading(true); + setError(null); + setSuccess(null); + + try { + if (selectedPromotion) { + // Update existing promotion + await promotionsApi.updatePromotion(selectedPromotion.id, data, token!); + setSuccess('تبلیغ با موفقیت به‌روزرسانی شد'); + } else { + // Create new promotion + await promotionsApi.createPromotion(data as CreatePromotionData, token!); + setSuccess('تبلیغ با موفقیت ایجاد شد'); + } + + await loadPromotions(); + setIsFormVisible(false); + setSelectedPromotion(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'خطا در ذخیره تبلیغ'); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + setIsFormVisible(false); + setSelectedPromotion(null); + setError(null); + setSuccess(null); + }; + + return ( +
+
+
+

+ مدیریت تبلیغات +

+ {!isFormVisible && ( + + )} +
+ + {/* Messages */} + {error && ( +
+

{error}

+
+ )} + + {success && ( +
+

{success}

+
+ )} + + {/* Form or List */} + {isFormVisible ? ( + + ) : ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/components/admin/promotions/PromotionForm.tsx b/components/admin/promotions/PromotionForm.tsx new file mode 100644 index 0000000..7a31171 --- /dev/null +++ b/components/admin/promotions/PromotionForm.tsx @@ -0,0 +1,395 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Promotion, CreatePromotionData, UpdatePromotionData, PROMOTION_TYPES } from '@/types/promotion'; +import { PhotoIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import Image from 'next/image'; + +interface PromotionFormProps { + promotion?: Promotion | null; + onSubmit: (data: CreatePromotionData | UpdatePromotionData) => Promise; + onCancel: () => void; + isLoading: boolean; +} + +export default function PromotionForm({ promotion, onSubmit, onCancel, isLoading }: PromotionFormProps) { + const [formData, setFormData] = useState({ + type: 'slider', + title: '', + subtitle: '', + action_text: '', + url_myket: '', + url_bazzar: '', + url_google_play: '', + url_site: '', + priority: 0, + start_at: '', + end_at: '', + }); + + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [isActive, setIsActive] = useState(true); + + useEffect(() => { + if (promotion) { + // Format dates for input fields + const startAt = promotion.start_at ? promotion.start_at.slice(0, 16) : ''; + const endAt = promotion.end_at ? promotion.end_at.slice(0, 16) : ''; + + setFormData({ + type: promotion.type || 'slider', + title: promotion.title || '', + subtitle: promotion.subtitle || '', + action_text: promotion.action_text || '', + url_myket: promotion.url_myket || '', + url_bazzar: promotion.url_bazzar || '', + url_google_play: promotion.url_google_play || '', + url_site: promotion.url_site || '', + priority: promotion.priority || 0, + start_at: startAt, + end_at: endAt, + }); + + setIsActive(promotion.is_active); + + if (promotion.image_url) { + setImagePreview(promotion.image_url); + } + } else { + // Set default priority + setFormData(prev => ({ ...prev, priority: 0 })); + } + }, [promotion]); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value === '' ? null : value, + })); + }; + + const handleNumberChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value === '' ? null : parseInt(value, 10), + })); + }; + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setImageFile(file); + setFormData(prev => ({ ...prev, image: file })); + + // Create preview + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const removeImage = () => { + setImageFile(null); + setImagePreview(null); + setFormData(prev => ({ ...prev, image: null })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Format dates properly + let submitData: any = { ...formData }; + + if (submitData.start_at) { + submitData.start_at = submitData.start_at.replace('T', ' ') + ':00'; + } + + if (submitData.end_at) { + submitData.end_at = submitData.end_at.replace('T', ' ') + ':00'; + } + + // Add is_active for updates + if (promotion) { + submitData.is_active = isActive; + } + + await onSubmit(submitData); + }; + + return ( +
+
+

+ {promotion ? 'ویرایش تبلیغ' : 'ایجاد تبلیغ جدید'} +

+
+ +
+ {/* Type */} +
+ + +
+ + {/* Title */} +
+ + +
+ + {/* Subtitle */} +
+ + +
+ + {/* Action Text */} +
+ + +
+ + {/* Priority */} +
+ + +
+ + {/* Start Date */} +
+ + +
+ + {/* End Date */} +
+ + +
+
+ + {/* URLs Section */} +
+

لینک‌ها

+
+ {/* Myket URL */} +
+ + +
+ + {/* Bazaar URL */} +
+ + +
+ + {/* Google Play URL */} +
+ + +
+ + {/* Site URL */} +
+ + +
+
+
+ + {/* Image Upload */} +
+ +
+ + {imagePreview && ( + + )} +
+ {imagePreview && ( +
+ Preview +
+ )} +

+ حداکثر حجم: ۲ مگابایت +

+
+ + {/* Active Status (only for edit) */} + {promotion && ( +
+ setIsActive(e.target.checked)} + className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" + /> + +
+ )} + + {/* Form Actions */} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/components/admin/promotions/PromotionList.tsx b/components/admin/promotions/PromotionList.tsx new file mode 100644 index 0000000..cd36982 --- /dev/null +++ b/components/admin/promotions/PromotionList.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { Promotion } from '@/types/promotion'; +import { getPromotionTypeLabel } from '@/types/promotion'; +import { PencilIcon, TrashIcon, EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'; +import { formatDate } from '@/lib/utils'; +import Image from 'next/image'; +import { useState } from 'react'; + +interface PromotionListProps { + promotions: Promotion[]; + onEdit: (promotion: Promotion) => void; + onDelete: (promotion: Promotion) => void; + onToggleActive: (promotion: Promotion) => void; + isLoading: boolean; +} + +export default function PromotionList({ promotions, onEdit, onDelete, onToggleActive, isLoading }: PromotionListProps) { + const [imageErrors, setImageErrors] = useState>({}); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!promotions || promotions.length === 0) { + return ( +
+

هیچ تبلیغاتی یافت نشد

+
+ ); + } + + // Sort promotions by priority (higher priority first) and then by creation date + const sortedPromotions = [...promotions].sort((a, b) => { + if (a.priority && b.priority) { + return b.priority - a.priority; + } + if (a.priority) return -1; + if (b.priority) return 1; + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); + + const handleImageError = (id: number) => { + setImageErrors(prev => ({ ...prev, [id]: true })); + }; + + return ( +
+
+

+ لیست تبلیغات +

+
+
    + {sortedPromotions.map((promotion) => ( +
  • +
    +
    +
    + {/* Promotion Image - Using unoptimized Image component */} + {promotion.image_url && !imageErrors[promotion.id] && ( +
    +
    + {promotion.title handleImageError(promotion.id)} + unoptimized={true} // This disables image optimization + /> +
    +
    + )} + + {/* Promotion Details */} +
    +
    +
    +

    + {promotion.title || 'بدون عنوان'} +

    + + {getPromotionTypeLabel(promotion.type)} + + {promotion.is_active ? ( + + فعال + + ) : ( + + غیرفعال + + )} +
    +
    + + {promotion.subtitle && ( +

    + {promotion.subtitle} +

    + )} + + {promotion.action_text && ( +

    + متن دکمه: {promotion.action_text} +

    + )} + +
    + {promotion.priority && ( +
    + اولویت: {promotion.priority} +
    + )} + + {promotion.start_at && ( +
    + شروع: {formatDate(promotion.start_at)} +
    + )} + + {promotion.end_at && ( +
    + پایان: {formatDate(promotion.end_at)} +
    + )} +
    + + {/* URLs */} +
    + {promotion.url_site && ( +
    + وبسایت: {promotion.url_site} +
    + )} + {promotion.url_myket && ( +
    + مایکت: {promotion.url_myket} +
    + )} + {promotion.url_bazzar && ( +
    + بازار: {promotion.url_bazzar} +
    + )} + {promotion.url_google_play && ( +
    + گوگل‌پلی: {promotion.url_google_play} +
    + )} +
    + +
    + آخرین به‌روزرسانی: {formatDate(promotion.updated_at)} +
    +
    +
    +
    + + {/* Action Buttons */} +
    + + + +
    +
    +
  • + ))} +
+
+ ); +} \ No newline at end of file diff --git a/lib/api/promotions.ts b/lib/api/promotions.ts new file mode 100644 index 0000000..81b0783 --- /dev/null +++ b/lib/api/promotions.ts @@ -0,0 +1,65 @@ +import { apiClient } from './client'; +import { Promotion, CreatePromotionData, UpdatePromotionData, ApiResponse } from '@/types/promotion'; + +export const promotionsApi = { + // Get all promotions + getPromotions: async (token: string): Promise => { + return apiClient.get('/promotions', token); + }, + + // Get single promotion + getPromotion: async (id: number, token: string): Promise => { + return apiClient.get(`/promotions/${id}`, token); + }, + + // Create new promotion + createPromotion: async (data: CreatePromotionData, token: string): Promise> => { + const formData = new FormData(); + + // Append all fields to FormData + Object.entries(data).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + if (key === 'image' && value instanceof File) { + formData.append('image', value); + } else { + formData.append(key, String(value)); + } + } + }); + + return apiClient.post>('/promotions', formData, token); + }, + + // Update promotion + updatePromotion: async (id: number, data: UpdatePromotionData, token: string): Promise> => { + const formData = new FormData(); + + // Add method spoofing for Laravel + // formData.append('_method', 'PUT'); + + // Append all fields to FormData + Object.entries(data).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + if (key === 'image' && value instanceof File) { + formData.append('image', value); + } else if (key === 'is_active') { + formData.append(key, value ? '1' : '0'); + } else { + formData.append(key, String(value)); + } + } + }); + + return apiClient.post>(`/promotions/${id}`, formData, token); + }, + + // Delete promotion + deletePromotion: async (id: number, token: string): Promise> => { + return apiClient.delete>(`/promotions/${id}`, undefined, token); + }, + + // Toggle promotion active status + toggleActive: async (id: number, isActive: boolean, token: string): Promise> => { + return apiClient.put>(`/promotions/${id}`, { is_active: isActive }, token); + } +}; \ No newline at end of file diff --git a/types/promotion.ts b/types/promotion.ts new file mode 100644 index 0000000..e96843c --- /dev/null +++ b/types/promotion.ts @@ -0,0 +1,69 @@ +export type PromotionType = 'slider' | 'action' | 'paid_app' | 'banner'; + +export const PROMOTION_TYPES = { + slider: 'اسلایدر', + action: 'اکشن', + paid_app: 'اپلیکیشن پولی', + banner: 'بنر' +} as const; + +export interface Promotion { + id: number; + type: PromotionType; + title: string | null; + image_url:string | null; + subtitle: string | null; + image: string | null; + action_text: string | null; + url_myket: string | null; + url_bazzar: string | null; + url_google_play: string | null; + url_site: string | null; + is_active: boolean; + priority: number | null; + start_at: string | null; + end_at: string | null; + created_at: string; + updated_at: string; +} + +export interface CreatePromotionData { + type: PromotionType; + title?: string | null; + subtitle?: string | null; + image?: File | null; + action_text?: string | null; + url_myket?: string | null; + url_bazzar?: string | null; + url_google_play?: string | null; + url_site?: string | null; + priority?: number | null; + start_at?: string | null; + end_at?: string | null; +} + +export interface UpdatePromotionData { + type?: PromotionType; + title?: string | null; + subtitle?: string | null; + image?: File | null; + action_text?: string | null; + url_myket?: string | null; + url_bazzar?: string | null; + url_google_play?: string | null; + url_site?: string | null; + is_active?: boolean; + priority?: number | null; + start_at?: string | null; + end_at?: string | null; +} + +export interface ApiResponse { + data: T; + message?: string; + status?: number; +} + +export const getPromotionTypeLabel = (type: PromotionType): string => { + return PROMOTION_TYPES[type] || type; +}; \ No newline at end of file