diff --git a/.env b/.env index 8e6f5f8..4ca20c7 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -VITE_API_BASE_URL=http://localhost:1338 +VITE_API_BASE_URL=http://127.0.0.1:1338 # VITE_API_BASE_URL=https://giving-cabbage-d8e17e469b.strapiapp.com VITE_API_REGISTER=/api/auth/local/register @@ -47,4 +47,5 @@ VITE_RECAPATCH=6LfLqG0sAAAAAIVBgjd_WSzhhcbIA8Z_boHtbtUv VITE_VAPID_PUBLIC_KEY=BJmu1K4k02ru__di6cZa40gJD1b8wRy_K2oivy6LIzAf-EMBYl3u2fjTH1yUKp2bp2YZIPqhE0A0GsaczxqAJ9A -VITE_CMS_ANALYTICS_ENDPOINT=/api/cms-analytics \ No newline at end of file +VITE_CMS_ANALYTICS_ENDPOINT=/api/cms-analytics +VITE_LISTEN_ALL=true \ No newline at end of file diff --git a/src/domain/useCase/useAdminWallet.js b/src/domain/useCase/useAdminWallet.js new file mode 100644 index 0000000..dfc84b3 --- /dev/null +++ b/src/domain/useCase/useAdminWallet.js @@ -0,0 +1,75 @@ +import { useState, useCallback, useMemo } from 'react'; +import { WalletRepository } from '@infrastructure/repository/WalletRepository'; + +export const useAdminWallet = () => { + const [platformWallet, setPlatformWallet] = useState(null); + const [allWallets, setAllWallets] = useState([]); + const [walletsMeta, setWalletsMeta] = useState(null); + + const [loadingPlatform, setLoadingPlatform] = useState(false); + const [loadingAll, setLoadingAll] = useState(false); + const [updatingCommission, setUpdatingCommission] = useState(false); + + const [error, setError] = useState(null); + + const repository = useMemo(() => new WalletRepository(), []); + + const fetchPlatformWallet = useCallback(async () => { + setLoadingPlatform(true); + setError(null); + try { + const data = await repository.getPlatformWallet(); + setPlatformWallet(data); + } catch (err) { + setError(err.response?.data?.error?.message || 'Failed to fetch platform wallet'); + } finally { + setLoadingPlatform(false); + } + }, [repository]); + + const fetchAllWallets = useCallback(async (params = { page: 1, pageSize: 25 }) => { + setLoadingAll(true); + setError(null); + try { + const result = await repository.getAllWallets(params); + setAllWallets(result.data); + setWalletsMeta(result.meta); + } catch (err) { + setError(err.response?.data?.error?.message || 'Failed to fetch all wallets'); + } finally { + setLoadingAll(false); + } + }, [repository]); + + const updateCommission = useCallback(async (walletId, newRate) => { + setUpdatingCommission(true); + setError(null); + try { + await repository.updateCommissionRate(walletId, newRate); + // After successful update, re-fetch the list to show updated values + await fetchAllWallets(); + return true; + } catch (err) { + const msg = err.response?.data?.error?.message || 'Failed to update commission rate'; + setError(msg); + throw new Error(msg); + } finally { + setUpdatingCommission(false); + } + }, [repository, fetchAllWallets]); + + return { + platformWallet, + allWallets, + walletsMeta, + + loadingPlatform, + loadingAll, + updatingCommission, + error, + + fetchPlatformWallet, + fetchAllWallets, + updateCommission + }; +}; diff --git a/src/domain/useCase/useFetchAdminPayouts.js b/src/domain/useCase/useFetchAdminPayouts.js new file mode 100644 index 0000000..45a678f --- /dev/null +++ b/src/domain/useCase/useFetchAdminPayouts.js @@ -0,0 +1,45 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { PayoutAdminRepository } from '../../infrastructure/repository/PayoutAdminRepository'; +import { useMemo, useEffect, useCallback, useState } from 'react'; + +/** + * UseCase hook for fetching and managing payouts (Admin/CMS view). + * Supports server-side pagination & search. + */ +export const useFetchAdminPayouts = () => { + const repository = useMemo(() => new PayoutAdminRepository(), []); + const [statusFilter, setStatusFilter] = useState(null); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const pageSize = 10; + + const fetchPayouts = useCallback(async () => { + return await repository.getAll(statusFilter, page, pageSize, search); + }, [repository, statusFilter, page, search]); + + const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchPayouts); + + useEffect(() => { + execute(); + }, [execute]); + + const updateStatus = useCallback(async (id, status, adminNotes = '') => { + await repository.updateStatus(id, status, adminNotes); + await execute(); // Refresh list + }, [repository, execute]); + + return { + payouts: returnedData?.items || [], + totalItems: returnedData?.meta?.pagination?.total || 0, + totalPages: Math.max(1, Math.ceil((returnedData?.meta?.pagination?.total || 0) / pageSize)), + currentPage: page, + setPage, + setSearch, + isLoading: inProgress, + error, + fetch: execute, + updateStatus, + statusFilter, + setStatusFilter + }; +}; diff --git a/src/domain/useCase/useInitiatePayment.js b/src/domain/useCase/useInitiatePayment.js new file mode 100644 index 0000000..0a58f14 --- /dev/null +++ b/src/domain/useCase/useInitiatePayment.js @@ -0,0 +1,25 @@ +import { useState, useCallback } from 'react'; +import { PaymentRepository } from '@infrastructure/repository/PaymentRepository'; + +export const useInitiatePayment = () => { + const [inProgress, setInProgress] = useState(false); + const [error, setError] = useState(null); + const paymentRepo = new PaymentRepository(); + + const initiatePayment = useCallback(async (itemId, contentType) => { + setInProgress(true); + setError(null); + try { + const data = await paymentRepo.initiateCheckout(itemId, contentType); + return data; + } catch (err) { + const message = err.response?.data?.error?.message || 'Failed to initiate payment. Please try again.'; + setError(message); + throw err; + } finally { + setInProgress(false); + } + }, [paymentRepo]); + + return { initiatePayment, inProgress, error }; +}; diff --git a/src/domain/useCase/useWallet.js b/src/domain/useCase/useWallet.js new file mode 100644 index 0000000..6e4138f --- /dev/null +++ b/src/domain/useCase/useWallet.js @@ -0,0 +1,50 @@ +import { useState, useCallback, useMemo } from 'react'; +import { WalletRepository } from '@infrastructure/repository/WalletRepository'; + +export const useWallet = () => { + const [wallet, setWallet] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [requestingPayout, setRequestingPayout] = useState(false); + + const repository = useMemo(() => new WalletRepository(), []); + + const fetchWallet = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await repository.getMyWallet(); + setWallet(data); + } catch (err) { + setError(err.response?.data?.error?.message || 'Failed to fetch wallet data'); + } finally { + setLoading(false); + } + }, [repository]); + + const requestPayout = useCallback(async (amount, method, details) => { + setRequestingPayout(true); + setError(null); + try { + const result = await repository.requestPayout({ amount, method, details }); + // Refresh wallet to show updated balance + await fetchWallet(); + return result; + } catch (err) { + const msg = err.response?.data?.error?.message || 'Payout request failed'; + setError(msg); + throw new Error(msg); + } finally { + setRequestingPayout(false); + } + }, [repository, fetchWallet]); + + return { + wallet, + loading, + error, + requestingPayout, + fetchWallet, + requestPayout + }; +}; diff --git a/src/infrastructure/DTO/WalletDTO.js b/src/infrastructure/DTO/WalletDTO.js new file mode 100644 index 0000000..79fc297 --- /dev/null +++ b/src/infrastructure/DTO/WalletDTO.js @@ -0,0 +1,75 @@ +/** + * Data Transfer Object for Wallet responses + */ +export class WalletDTO { + constructor(data) { + if (!data) return; + + this.id = data.id; + this.documentId = data.documentId; + this.ownerType = data.owner_type; + this.balance = Number(data.balance || 0); + this.pendingBalance = Number(data.pending_balance || 0); + this.currency = data.currency || 'EGP'; + this.commissionRate = data.commission_rate ? Number(data.commission_rate) : 0; + this.isActive = Boolean(data.is_active); + + // Owner info (can be populated) + this.owner = data.owner ? { + id: data.owner.id, + username: data.owner.username, + email: data.owner.email, + } : null; + + this.transactions = Array.isArray(data.transactions) ? data.transactions.map(t => new TransactionDTO(t)) : []; + this.payouts = Array.isArray(data.payouts) ? data.payouts.map(p => new PayoutDTO(p)) : []; + + this.stats = data.stats || null; + + this.createdAt = data.createdAt ? new Date(data.createdAt) : null; + } +} + +export class TransactionDTO { + constructor(data) { + if (!data) return; + + this.id = data.id; + this.type = data.type; // 'credit' | 'debit' | 'commission' | 'withdrawal' | 'refund' + this.amount = Number(data.amount || 0); + this.description = data.description || ''; + this.referenceType = data.reference_type; + this.referenceId = data.reference_id; + this.metadata = data.metadata || {}; + this.status = data.status; // 'PENDING' | 'COMPLETED' | 'FAILED' | 'REVERSED' + this.createdAt = data.createdAt ? new Date(data.createdAt) : null; + } +} + +export class PayoutDTO { + constructor(data) { + if (!data) return; + + this.id = data.id; + this.documentId = data.documentId; + this.amount = Number(data.amount || 0); + this.status = data.status; // 'PENDING' | 'PAID' | 'REJECTED' + this.method = data.method; + this.details = data.details || {}; + this.admin_notes = data.admin_notes; + this.createdAt = data.createdAt ? new Date(data.createdAt) : null; + + // Populate Wallet owner if available + if (data.wallet) { + this.wallet = { + id: data.wallet.id, + balance: data.wallet.balance, + owner: data.wallet.owner ? { + id: data.wallet.owner.id, + username: data.wallet.owner.username, + email: data.wallet.owner.email + } : null + }; + } + } +} diff --git a/src/infrastructure/repository/GlobalTagRepository.js b/src/infrastructure/repository/GlobalTagRepository.js index 0c9dcb4..14167be 100644 --- a/src/infrastructure/repository/GlobalTagRepository.js +++ b/src/infrastructure/repository/GlobalTagRepository.js @@ -30,4 +30,13 @@ export class GlobalTagRepository extends BaseRepository { async deleteTag(id) { return await this.delete(`${this.endpoint}/${id}`); } + + /** + * Fetches tag audience map: real user interest counts per tag. + * @returns {Promise<{tags: Array, totalUsers: number}>} + */ + async getTagAudience() { + const response = await this.get('/api/recommendations/tag-audience'); + return response?.data || { tags: [], totalUsers: 0 }; + } } diff --git a/src/infrastructure/repository/PaymentRepository.js b/src/infrastructure/repository/PaymentRepository.js new file mode 100644 index 0000000..3216747 --- /dev/null +++ b/src/infrastructure/repository/PaymentRepository.js @@ -0,0 +1,28 @@ +import { repositoryRegistry } from './RepositoryRegistry'; + +export class PaymentRepository { + constructor(apiClient = repositoryRegistry.apiClient) { + this.apiClient = apiClient; + } + + /** + * Initiates a checkout process for a specific item (Event or Course) + * @param {string} itemId - The documentId or id of the item + * @param {string} contentType - 'event' or 'course' + * @returns {Promise<{payment_key: string, order_id: string, iframe_url: string}>} + */ + async initiateCheckout(itemId, contentType = 'event') { + try { + // Using BaseRepository.post which handles path prefixing and wrapping + const response = await this.apiClient.post('/api/payments/initiate', { + itemId, + contentType + }, true); + + return response.data; + } catch (error) { + console.error('PaymentRepository initiateCheckout failed', error); + throw error; + } + } +} diff --git a/src/infrastructure/repository/PayoutAdminRepository.js b/src/infrastructure/repository/PayoutAdminRepository.js new file mode 100644 index 0000000..4bd5748 --- /dev/null +++ b/src/infrastructure/repository/PayoutAdminRepository.js @@ -0,0 +1,77 @@ +import { repositoryRegistry } from './RepositoryRegistry'; +import { PayoutDTO } from '../DTO/WalletDTO'; + +export class PayoutAdminRepository { + constructor(apiClient = repositoryRegistry.apiClient) { + this.apiClient = apiClient; + } + + /** + * Gets all payouts with pagination and filtering (Admin only) + * @param {string} statusFilter 'PENDING', 'PAID', 'REJECTED', or null + * @param {number} page + * @param {number} pageSize + * @param {string} search + * @returns {Promise<{items: PayoutDTO[], meta: Object}>} + */ + async getAll(statusFilter, page = 1, pageSize = 10, search = '') { + try { + const queryParams = new URLSearchParams(); + queryParams.append('pagination[page]', page); + queryParams.append('pagination[pageSize]', pageSize); + queryParams.append('sort', 'createdAt:desc'); + + // Populate wallet and owner + queryParams.append('populate[wallet][populate]', 'owner'); + + if (statusFilter) { + queryParams.append('filters[status][$eq]', statusFilter.toUpperCase()); + } + + // Simple search on details or method if search exists + if (search) { + queryParams.append('filters[$or][0][details][$containsi]', search); + queryParams.append('filters[$or][1][method][$containsi]', search); + } + + const queryString = queryParams.toString() ? `?${queryParams.toString()}` : ''; + const response = await this.apiClient.get(`/api/payouts${queryString}`); + + const rawData = response?.data || []; + const meta = response?.meta || {}; + + return { + items: Array.isArray(rawData) ? rawData.map(p => new PayoutDTO(p)) : [], + meta + }; + } catch (error) { + console.error('[PayoutAdminRepository] getAll failed:', error); + throw error; + } + } + + /** + * Updates payout status + * @param {string} documentId Payout document ID + * @param {string} status 'PAID' or 'REJECTED' + * @param {string} adminNotes Optional notes for rejection + * @returns {Promise} + */ + async updateStatus(documentId, status, adminNotes = '') { + try { + const payload = { + status: status.toUpperCase() + }; + if (adminNotes) { + payload.admin_notes = adminNotes; + } + + // BaseRepository.put wraps the payload in { data: ... } by default + const response = await this.apiClient.put('/api/payouts', documentId, payload); + return new PayoutDTO(response?.data); + } catch (error) { + console.error('[PayoutAdminRepository] updateStatus failed:', error); + throw error; + } + } +} diff --git a/src/infrastructure/repository/WalletRepository.js b/src/infrastructure/repository/WalletRepository.js new file mode 100644 index 0000000..17940d9 --- /dev/null +++ b/src/infrastructure/repository/WalletRepository.js @@ -0,0 +1,100 @@ +import { repositoryRegistry } from './RepositoryRegistry'; +import { WalletDTO } from '../DTO/WalletDTO'; + +export class WalletRepository { + constructor(apiClient = repositoryRegistry.apiClient) { + this.apiClient = apiClient; + } + + /** + * Gets the current user's wallet + * @returns {Promise} + */ + async getMyWallet() { + try { + const response = await this.apiClient.get('/api/wallet/me'); + const data = response?.data || response; + return new WalletDTO(data); + } catch (error) { + console.error('[WalletRepository] getMyWallet failed:', error); + throw error; + } + } + + /** + * Gets the platform's commission wallet (Admin only) + * @returns {Promise} + */ + async getPlatformWallet() { + try { + const response = await this.apiClient.get('/api/wallet/platform'); + const data = response?.data || response; + return new WalletDTO(data); + } catch (error) { + console.error('[WalletRepository] getPlatformWallet failed:', error); + throw error; + } + } + + /** + * Gets all wallets with pagination (Admin only) + * @param {Object} params { page, pageSize, owner_type, is_active } + * @returns {Promise<{data: WalletDTO[], meta: Object}>} + */ + async getAllWallets(params = {}) { + try { + const queryParams = new URLSearchParams(); + if (params.page) queryParams.append('page', params.page); + if (params.pageSize) queryParams.append('pageSize', params.pageSize); + if (params.owner_type) queryParams.append('owner_type', params.owner_type); + if (params.is_active !== undefined) queryParams.append('is_active', params.is_active); + + const queryString = queryParams.toString() ? `?${queryParams.toString()}` : ''; + const response = await this.apiClient.get(`/api/wallets${queryString}`); + + const rawData = response?.data || []; + const meta = response?.meta || {}; + + return { + data: Array.isArray(rawData) ? rawData.map(w => new WalletDTO(w)) : [], + meta + }; + } catch (error) { + console.error('[WalletRepository] getAllWallets failed:', error); + throw error; + } + } + + /** + * Updates a publisher's commission rate (Admin only) + * @param {number|string} id Wallet ID + * @param {number} commissionRate Number between 0 and 1 + * @returns {Promise} + */ + async updateCommissionRate(id, commissionRate) { + try { + // Using endpoint prefix without trailing slash, id, payload, and wrap=false + const response = await this.apiClient.put('/api/wallets', `${id}/commission`, { commission_rate: commissionRate }, false); + return response?.data || response; + } catch (error) { + console.error('[WalletRepository] updateCommissionRate failed:', error); + throw error; + } + } + + /** + * Requests a payout from the publisher's wallet + * @param {Object} payload { amount, method, details } + * @returns {Promise} + */ + async requestPayout({ amount, method, details }) { + try { + // Send unwrapped payload to match custom backend controller + const response = await this.apiClient.post('/api/payouts/request', { amount, method, details }, false); + return response?.data || response; + } catch (error) { + console.error('[WalletRepository] requestPayout failed:', error); + throw error; + } + } +} diff --git a/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx b/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx index 60c822e..bfbe59c 100644 --- a/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx +++ b/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx @@ -30,8 +30,8 @@ export const CMSCreateCategorizationModal = ({ /> {/* Modal Content */} -
-
+
+
@@ -49,7 +49,7 @@ export const CMSCreateCategorizationModal = ({
-
+