Compare commits

...

2 Commits

Author SHA1 Message Date
2be18a979d Update CreatorList.jsx 2025-05-22 20:55:51 -04:00
166c5f5271 [dev]creator list 2025-05-22 20:53:43 -04:00
22 changed files with 1041 additions and 327 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
VITE_PROD = false
VITE_API_URL = "http://81.69.223.133:58099"

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
VITE_PROD = false
VITE_API_URL = "http://81.69.223.133:58099"

2
.env.production Normal file
View File

@ -0,0 +1,2 @@
VITE_PROD = false
VITE_API_URL = "http://81.69.223.133:58099"

View File

@ -81,12 +81,12 @@ OOIN Creator Center is a React application built with Vite that allows users to
name: string,
avatar: string,
category: string,
ecommerceLevel: string,
exposureLevel: string,
e_commerce_level: string,
exposure_level: string,
followers: string,
gmv: string,
soldPercentage: string,
avgViews: string,
avg_video_views: string,
hasEcommerce: boolean,
hasTiktok: boolean,
hasInstagram: boolean, // optional

164
package-lock.json generated
View File

@ -15,6 +15,8 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@reduxjs/toolkit": "^2.8.1",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.9.0",
"bootstrap": "^5.3.3",
"chart.js": "^4.4.9",
"date-fns": "^4.1.0",
@ -1771,6 +1773,37 @@
"tslib": "^2.8.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1963,6 +1996,11 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
@ -1971,6 +2009,16 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -2089,7 +2137,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
@ -2208,6 +2255,17 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@ -2329,6 +2387,14 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -2370,7 +2436,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
@ -2405,7 +2470,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@ -2414,7 +2478,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@ -2423,7 +2486,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0"
},
@ -2431,6 +2493,20 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
@ -2875,6 +2951,58 @@
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -2911,7 +3039,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@ -2929,7 +3056,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
@ -2953,7 +3079,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
@ -2990,7 +3115,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@ -3011,7 +3135,20 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
@ -3023,7 +3160,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.2"
},
@ -3564,7 +3700,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@ -3937,6 +4072,11 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@ -17,6 +17,8 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@reduxjs/toolkit": "^2.8.1",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.9.0",
"bootstrap": "^5.3.3",
"chart.js": "^4.4.9",
"date-fns": "^4.1.0",

BIN
src/assets/placeholder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,7 +1,7 @@
import { Send, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectCreator } from '../store/slices/creatorsSlice';
import { fetchCreatorDetail } from '../store/slices/creatorsSlice';
import { Button, Form } from 'react-bootstrap';
export default function ChatDetails({ onCloseChatDetails }) {
@ -16,7 +16,7 @@ export default function ChatDetails({ onCloseChatDetails }) {
};
useEffect(() => {
dispatch(selectCreator(selectedChat.id));
dispatch(fetchCreatorDetail({ creatorId: selectedChat.id }));
}, [dispatch, selectedChat]);
return (

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useRef, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Table, Form, Spinner } from 'react-bootstrap';
import { ChevronUp, ChevronDown } from 'lucide-react';
@ -7,27 +7,50 @@ import {
toggleCreatorSelection,
selectAllCreators,
clearCreatorSelection,
fetchPrivateCreators,
setCreators,
resetCreators,
} from '../store/slices/creatorsSlice';
import { setSortBy } from '../store/slices/filtersSlice';
import '../styles/DatabaseList.scss';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom';
export default function CreatorList({ path, pageType = 'database' }) {
export default function CreatorList({ path }) {
const dispatch = useDispatch();
const { creators, status, selectedCreators } = useSelector((state) => state.creators);
const {
publicCreators = [],
status,
selectedCreators = [],
hasMore,
isLoadingMore,
pagination,
} = useSelector((state) => state.creators);
const { sortBy, sortDirection } = useSelector((state) => state.filters);
const observer = useRef();
const loadingRef = useCallback(
(node) => {
if (isLoadingMore) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
const nextPage = pagination?.current_page + 1;
dispatch(fetchCreators({ path, page: nextPage }));
}
});
if (node) observer.current.observe(node);
},
[isLoadingMore, hasMore, pagination, path, dispatch]
);
//
useEffect(() => {
if (status === 'idle') {
dispatch(fetchCreators({ path }));
}
}, [dispatch, status]);
dispatch(fetchCreators({ path, page: 1 }));
}, [path, dispatch]);
useEffect(() => {
// console.log(creators);
}, [creators]);
}, [publicCreators]);
// /
const handleSelectAll = (e) => {
if (e.target.checked) {
@ -69,8 +92,8 @@ export default function CreatorList({ path, pageType = 'database' }) {
return categoryMap[category] || '';
};
//
if (status === 'loading') {
//
if (status === 'loading' && (!publicCreators || publicCreators.length === 0)) {
return (
<div className='text-center p-5'>
<Spinner animation='border' role='status' variant='primary'>
@ -87,13 +110,14 @@ export default function CreatorList({ path, pageType = 'database' }) {
return (
<div className='creator-database-table'>
<div className='table-container'>
<Table responsive hover className='bg-white shadow-xs rounded overflow-hidden'>
<thead>
<thead className='sticky-header'>
<tr>
<th className='selector' style={{ width: '40px' }}>
<Form.Check
type='checkbox'
checked={selectedCreators.length === creators.length && creators.length > 0}
checked={selectedCreators.length === publicCreators.length && publicCreators.length > 0}
onChange={handleSelectAll}
/>
</th>
@ -107,11 +131,11 @@ export default function CreatorList({ path, pageType = 'database' }) {
>
Category {renderSortIcon('category')}
</th>
<th className='e-commerce-level text-center' onClick={() => handleSort('ecommerceLevel')}>
E-commerce Level {renderSortIcon('ecommerceLevel')}
<th className='e-commerce-level text-center' onClick={() => handleSort('e_commerce_level')}>
E-commerce Level {renderSortIcon('e_commerce_level')}
</th>
<th className='exposure-level text-center' onClick={() => handleSort('exposureLevel')}>
Exposure Level {renderSortIcon('exposureLevel')}
<th className='exposure-level text-center' onClick={() => handleSort('exposure_level')}>
Exposure Level {renderSortIcon('exposure_level')}
</th>
<th className='followers text-center' onClick={() => handleSort('followers')}>
Followers {renderSortIcon('followers')}
@ -119,44 +143,40 @@ export default function CreatorList({ path, pageType = 'database' }) {
<th className='gmv text-center' onClick={() => handleSort('gmv')}>
GMV {renderSortIcon('gmv')}
</th>
<th className='views text-center' onClick={() => handleSort('avgViews')}>
Avg. Video Views {renderSortIcon('avgViews')}
<th className='views text-center' onClick={() => handleSort('avg_video_views')}>
Avg. Video Views {renderSortIcon('avg_video_views')}
</th>
{pageType === 'private' && (
<>
<th className='pricing text-center'>Pricing</th>
<th className='collab-count text-center'># Collab</th>
<th className='latest-collab text-center'>Latest Collab.</th>
</>
)}
<th className='e-commerce text-center'>E-commerce</th>
<th className='profile text-center'>Profile</th>
</tr>
</thead>
<tbody>
{creators.length === 0 ? (
{!publicCreators || publicCreators.length <= 0 ? (
<tr>
<td colSpan='10' className='text-center py-4'>
No creators found matching your filters.
</td>
</tr>
) : (
creators.map((creator) => (
<tr key={creator.id} className={selectedCreators.includes(creator.id) ? 'selected' : ''}>
publicCreators.map((creator) => (
<tr
key={creator.creator_id}
className={selectedCreators.includes(creator.creator_id) ? 'selected' : ''}
>
<td>
<Form.Check
type='checkbox'
checked={selectedCreators.includes(creator.id)}
onChange={() => handleSelectCreator(creator.id)}
checked={selectedCreators.includes(creator.creator_id)}
onChange={() => handleSelectCreator(creator.creator_id)}
/>
</td>
<td className='creator-cell'>
<div className='d-flex align-items-center'>
<div className='creator-avatar'>
<img src={creator.avatar} alt={creator.name} />
{creator.verified && <span className='verified-badge'></span>}
{creator.status && <span className='verified-badge'></span>}
</div>
<Link to={`/creator/${creator.id}`} className='creator-name'>
<Link to={`/creator/${creator.creator_id}`} className='creator-name'>
{creator.name}
</Link>
</div>
@ -167,11 +187,14 @@ export default function CreatorList({ path, pageType = 'database' }) {
</span>
</td>
<td className='text-center'>
<span className='level-badge ecommerce-level'>{creator.ecommerceLevel}</span>
<span className='level-badge ecommerce-level'>{creator.e_commerce_level}</span>
</td>
<td className='text-center'>
<span className='level-badge exposure-level' data-level={creator.exposureLevel}>
{creator.exposureLevel}
<span
className='level-badge exposure-level'
data-level={creator.exposure_level}
>
{creator.exposure_level}
</span>
</td>
<td className='text-nowrap text-center'>{creator.followers}</td>
@ -179,14 +202,7 @@ export default function CreatorList({ path, pageType = 'database' }) {
<div>{creator.gmv}</div>
<div className='small text-muted'>Items Sold: {creator.soldPercentage}</div>
</td>
<td className='text-nowrap text-center'>{creator.avgViews}</td>
{pageType === 'private' && (
<>
<td className='text-center'>{creator.pricing}</td>
<td className='text-center'>{creator.collabCount}</td>
<td className='text-center'>{creator.latestCollab}</td>
</>
)}
<td className='text-nowrap text-center'>{creator.avg_video_views}</td>
<td className='text-center'>
{creator.hasEcommerce ? <div className='colored-dot blue mx-auto'></div> : null}
</td>
@ -202,6 +218,14 @@ export default function CreatorList({ path, pageType = 'database' }) {
)}
</tbody>
</Table>
{hasMore && (
<div ref={loadingRef} className='text-center p-3'>
<Spinner animation='border' role='status' variant='primary' size='sm'>
<span className='visually-hidden'>Loading more...</span>
</Spinner>
</div>
)}
</div>
</div>
);
}

View File

@ -78,8 +78,8 @@ export default function DatabaseFilter({ path, pageType = 'database' }) {
//
useEffect(() => {
dispatch(fetchCreators({ path }));
}, [dispatch, filters]);
}, [dispatch, filters, pageType, path]);
//
const handleCategorySelect = (category) => {

View File

@ -0,0 +1,230 @@
import React, { useEffect, useRef, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Table, Form, Spinner } from 'react-bootstrap';
import { ChevronUp, ChevronDown } from 'lucide-react';
import {
toggleCreatorSelection,
selectAllCreators,
clearCreatorSelection,
fetchPrivateCreators,
} from '../store/slices/creatorsSlice';
import { setSortBy } from '../store/slices/filtersSlice';
import '../styles/DatabaseList.scss';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom';
export default function PrivateCreatorList({ path }) {
const dispatch = useDispatch();
const { privateCreators, status, selectedCreators, hasMore, isLoadingMore, pagination } = useSelector(
(state) => state.creators
);
const { sortBy, sortDirection } = useSelector((state) => state.filters);
const observer = useRef();
const loadingRef = useCallback(
(node) => {
if (isLoadingMore) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
const nextPage = pagination?.current_page + 1;
dispatch(fetchPrivateCreators({ path, page: nextPage }));
}
});
if (node) observer.current.observe(node);
},
[isLoadingMore, hasMore, pagination, path, dispatch]
);
//
useEffect(() => {
dispatch(fetchPrivateCreators({ path, page: 1 }));
}, [path,dispatch]);
useEffect(() => {
console.log(privateCreators);
}, [privateCreators]);
// /
const handleSelectAll = (e) => {
if (e.target.checked) {
dispatch(selectAllCreators());
} else {
dispatch(clearCreatorSelection());
}
};
//
const handleSelectCreator = (creatorId) => {
dispatch(toggleCreatorSelection(creatorId));
};
//
const handleSort = (field) => {
dispatch(setSortBy(field));
};
//
const renderSortIcon = (field) => {
if (sortBy === field) {
return sortDirection === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />;
}
return null;
};
//
const getCategoryClassName = (category) => {
const categoryMap = {
'Phones & Electronics': 'phones',
'Womenswear & Underwear': 'women',
'Sports & Outdoor': 'sports',
'Food & Beverage': 'food',
Health: 'health',
Kitchenware: 'kitchen',
};
return categoryMap[category] || '';
};
//
if (status === 'loading' && (!privateCreators || privateCreators.length === 0)) {
return (
<div className='text-center p-5'>
<Spinner animation='border' role='status' variant='primary'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
);
}
//
if (status === 'failed') {
return <div className='alert alert-danger'>Failed to load creators. Please try again later.</div>;
}
return (
<div className='creator-database-table'>
<div className='table-container'>
<Table responsive hover className='bg-white shadow-xs rounded overflow-hidden'>
<thead className='sticky-header'>
<tr>
<th className='selector' style={{ width: '40px' }}>
<Form.Check
type='checkbox'
checked={selectedCreators.length === privateCreators.length && privateCreators.length > 0}
onChange={handleSelectAll}
/>
</th>
<th className='creator' onClick={() => handleSort('name')} style={{ width: '180px' }}>
Creator {renderSortIcon('name')}
</th>
<th
className='category text-center'
onClick={() => handleSort('category')}
style={{ width: '180px' }}
>
Category {renderSortIcon('category')}
</th>
<th className='e-commerce-level text-center' onClick={() => handleSort('e_commerce_level')}>
E-commerce Level {renderSortIcon('e_commerce_level')}
</th>
<th className='exposure-level text-center' onClick={() => handleSort('exposure_level')}>
Exposure Level {renderSortIcon('exposure_level')}
</th>
<th className='followers text-center' onClick={() => handleSort('followers')}>
Followers {renderSortIcon('followers')}
</th>
<th className='gmv text-center' onClick={() => handleSort('gmv')}>
GMV {renderSortIcon('gmv')}
</th>
<th className='views text-center' onClick={() => handleSort('avg_video_views')}>
Avg. Video Views {renderSortIcon('avg_video_views')}
</th>
<th className='pricing text-center'>Pricing</th>
<th className='collab-count text-center'># Collab</th>
<th className='latest-collab text-center'>Latest Collab.</th>
<th className='e-commerce text-center'>E-commerce</th>
<th className='profile text-center'>Profile</th>
</tr>
</thead>
<tbody>
{!privateCreators || privateCreators.length <= 0 ? (
<tr>
<td colSpan='13' className='text-center py-4'>
No creators found matching your filters.
</td>
</tr>
) : (
privateCreators.map((creator) => (
<tr
key={creator.creator_id}
className={selectedCreators.includes(creator.creator_id) ? 'selected' : ''}
>
<td>
<Form.Check
type='checkbox'
checked={selectedCreators.includes(creator.creator_id)}
onChange={() => handleSelectCreator(creator.creator_id)}
/>
</td>
<td className='creator-cell'>
<div className='d-flex align-items-center'>
<div className='creator-avatar'>
<img src={creator.avatar} alt={creator.name} />
{creator.status && <span className='verified-badge'></span>}
</div>
<Link to={`/creator/${creator.creator_id}`} className='creator-name'>
{creator.name}
</Link>
</div>
</td>
<td>
<span className={`category-pill ${getCategoryClassName(creator.category)}`}>
{creator.category}
</span>
</td>
<td className='text-center'>
<span className='level-badge ecommerce-level'>{creator.e_commerce_level}</span>
</td>
<td className='text-center'>
<span
className='level-badge exposure-level'
data-level={creator.exposure_level}
>
{creator.exposure_level}
</span>
</td>
<td className='text-nowrap text-center'>{creator.followers}</td>
<td className='text-center'>
<div>{creator.gmv}</div>
<div className='small text-muted'>Items Sold: {creator.soldPercentage}</div>
</td>
<td className='text-nowrap text-center'>{creator.avg_video_views}</td>
<td className='text-center'>{creator.pricing}</td>
<td className='text-center'>{creator.collab_count}</td>
<td className='text-center'>{creator.latestCollab}</td>
<td className='text-center'>
{creator.hasEcommerce ? <div className='colored-dot blue mx-auto'></div> : null}
</td>
<td className='text-center'>
{creator.hasTiktok && (
<div className='social-icon tiktok-icon mx-auto'>
<FontAwesomeIcon icon='fa-brands fa-tiktok' />
</div>
)}
</td>
</tr>
))
)}
</tbody>
</Table>
{hasMore && (
<div ref={loadingRef} className='text-center p-3'>
<Spinner animation='border' role='status' variant='primary' size='sm'>
<span className='visually-hidden'>Loading more...</span>
</Spinner>
</div>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,3 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store';
@ -7,9 +6,7 @@ import './index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>
);

View File

@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { Card, Table } from 'react-bootstrap';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
import { selectCreator, clearCreator } from '../store/slices/creatorsSlice';
import { clearCreator, fetchCreatorDetail } from '../store/slices/creatorsSlice';
import { Bar, Doughnut } from 'react-chartjs-2';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@ -57,19 +57,67 @@ export default function CreatorDetail({}) {
const { id } = useParams();
const navigate = useNavigate();
const dispatch = useDispatch();
const { selectedCreator } = useSelector((state) => state.creators);
const { selectedCreator, status } = useSelector((state) => state.creators);
const [activeTab, setActiveTab] = useState('basic');
const handleBack = () => {
navigate(-1);
};
useEffect(() => {
dispatch(selectCreator(id));
dispatch(fetchCreatorDetail({ creatorId: id }));
return () => {
dispatch(clearCreator());
};
}, [dispatch, id]);
console.log(selectedCreator);
const processChartData = (data, chart) => {
switch (chart) {
case 'channel':
return {
labels: Object.keys(data),
datasets: [
{
label: 'GMV',
data: Object.values(data),
backgroundColor: [
'rgba(217, 107, 139)',
'rgba(101, 105, 225)',
'rgba(93, 200, 179)',
'rgba(122, 87, 218)',
],
},
],
};
case 'category':
return {
labels: Object.keys(data),
datasets: [
{
label: 'Sold',
data: Object.values(data),
backgroundColor: [
'rgba(217, 107, 139)',
'rgba(101, 105, 225)',
'rgba(93, 200, 179)',
'rgba(122, 87, 218)',
],
},
],
};
}
};
if (status === 'loading') {
return <div>Loading...</div>;
}
if (status === 'failed') {
return <div>Failed to load creator detail</div>;
}
if (!selectedCreator) {
return <div>No creator found</div>;
}
return (
<div className='creator-detail-page'>
<div className='back-button' onClick={handleBack}>
@ -80,35 +128,50 @@ export default function CreatorDetail({}) {
<div className='creator-info-container card'>
<div className='creator-info-1'>
<div className='creator-avatar'>
<img src={selectedCreator.avatar} alt={selectedCreator.name} />
<img src={selectedCreator.creator.avatar} alt={selectedCreator.creator.name} />
</div>
<div className='creator-info-right'>
<div className='creator-name'>{selectedCreator.name}</div>
<div className='creator-desc'>{selectedCreator.description || '--'}</div>
<div className='creator-category'>{selectedCreator.category || '--'}</div>
<div className='creator-name'>{selectedCreator.creator.name}</div>
<div className='creator-desc'>{selectedCreator?.description || '--'}</div>
<div className='creator-category'>{selectedCreator.business?.category || '--'}</div>
<div className='creator-location'>
<MapPin size={16} />
{selectedCreator.location || ' --'}
{selectedCreator.creator.location || ' --'}
</div>
<div className='creator-live-time'>{selectedCreator.liveTime || '--'}</div>
<div className='creator-live-time'>{selectedCreator.live_schedule || '--'}</div>
</div>
</div>
<div className='creator-info-2'>
<div className='creator-info-item'>
<div className='creator-info-item category-info'>
<div className='creator-info-label'>Category</div>
<div className='creator-info-value'>{selectedCreator.category}</div>
<div className='creator-info-value'>
{selectedCreator.business?.categories.map((item, index) => (
<span className='category-item badge' key={index}>
{item}
</span>
))}
</div>
</div>
<div className='creator-info-item'>
<div className='creator-info-label'>MCN</div>
<div className='creator-info-value'>{selectedCreator.mcn || '--'}</div>
<div className='creator-info-value'>{selectedCreator.business?.mcn || '--'}</div>
</div>
<div className='creator-info-item'>
<div className='creator-info-label'>Pricing</div>
<div className='creator-info-value'>{selectedCreator.pricing}</div>
<div className='creator-info-value'>
{selectedCreator.business?.pricing?.range || '--'}
</div>
<div className='creator-info-item'>
</div>
<div className='creator-info-item collab-info'>
<div className='creator-info-label'>Collab.</div>
<div className='creator-info-value'>{selectedCreator.collab || '--'}</div>
<div className='creator-info-value'>
<span className='collab-count'>
{selectedCreator.business?.collab_count || '--'}
</span>
<span className='latest-collab badge'>
{selectedCreator.business?.latest_collab || '--'}
</span>
</div>
</div>
</div>
<div className='creator-info-3'>
@ -116,19 +179,23 @@ export default function CreatorDetail({}) {
<div className='creator-info-label mail'>
<Mail size={18} />
</div>
<div className='creator-info-value'>{selectedCreator.email || '--'}</div>
<div className='creator-info-value'>{selectedCreator.creator.email || '--'}</div>
</div>
<div className='creator-info-item'>
<div className='creator-info-label social'>
<Instagram size={18} />
</div>
<div className='creator-info-value'>{selectedCreator.instagram || '--'}</div>
<div className='creator-info-value'>
{selectedCreator.creator.social_accounts?.instagram || '--'}
</div>
</div>
<div className='creator-info-item'>
<div className='creator-info-label link'>
<Link size={18} />
</div>
<div className='creator-info-value'>{selectedCreator.url || '--'}</div>
<div className='creator-info-value'>
{selectedCreator.creator.social_accounts?.tiktok || '--'}
</div>
</div>
</div>
</div>
@ -136,47 +203,53 @@ export default function CreatorDetail({}) {
<div className='levels'>
<div className='level-item'>
<div className='name'>E-commerce Level</div>
<div className='value'>{selectedCreator.ecommerceLevel || '--'}</div>
<div className='value'>{selectedCreator?.metrics?.e_commerce_level || '--'}</div>
</div>
<div className='level-item'>
<div className='name'>Exposure Level</div>
<div className='value'>{selectedCreator.exposureLevel || '--'}</div>
<div className='value'>{selectedCreator?.metrics?.exposure_level || '--'}</div>
</div>
</div>
<div className='data-cards'>
<div className='data-card'>
<div className='value'>{selectedCreator.followers || '--'}</div>
<div className='value'>{selectedCreator?.metrics?.followers || '--'}</div>
<div className='name'>Followers</div>
</div>
<div className='data-card'>
<div className='value'>{selectedCreator.gmv || '--'}</div>
<div className='value'>{selectedCreator?.metrics?.gmv || '--'}</div>
<div className='name'>GMV</div>
</div>
<div className='data-card'>
<div className='value'>{selectedCreator.avgVideoViews || '--'}</div>
<div className='value'>{selectedCreator?.metrics?.avg_video_views || '--'}</div>
<div className='name'>Avg Video Views</div>
</div>
<div className='data-card'>
<div className='value'>{selectedCreator.itemsSold || '--'}</div>
<div className='value'>{selectedCreator?.metrics?.items_sold || '--'}</div>
<div className='name'>Items Sold</div>
</div>
<div className='data-card'>
<div className='value'>{selectedCreator.gpm || '--'}</div>
<div className='value'>{selectedCreator?.metrics?.gpm || '--'}</div>
<div className='name'>GPM</div>
</div>
<div className='data-card'>
<div className='value'>{selectedCreator.gpmPerCustomer || '--'}</div>
<div className='value'>{selectedCreator?.metrics?.gmv_per_customer || '--'}</div>
<div className='name'>GMV per customer</div>
</div>
</div>
<div className='data-charts'>
<div className='data-chart'>
<div className='chart-title'>GMV per sales channel</div>
<Doughnut data={data} options={{ ...options, cutout: 80 }} />
<Doughnut
data={processChartData(selectedCreator?.analytics?.gmv_by_channel, 'channel')}
options={{ ...options, cutout: 50 }}
/>
</div>
<div className='data-chart'>
<div className='chart-title'>GMV by product category</div>
<Doughnut data={data} options={{ ...options, cutout: 80 }} />
<Doughnut
data={processChartData(selectedCreator?.analytics?.gmv_by_category, 'category')}
options={{ ...options, cutout: 50 }}
/>
</div>
</div>
</div>
@ -262,134 +335,134 @@ function CreatorBasicInfo({ selectedCreator }) {
<div className='basic-info-list'>
<div className='basic-info-title'>Collaboration Metrics</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgCommissionRate || '--'}</div>
<div className='value'>{selectedCreator?.avg_commission_rate || '--'}</div>
<div className='name'>Avg. Commission Rate</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.products || '--'}</div>
<div className='value'>{selectedCreator?.products || '--'}</div>
<div className='name'>Products</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.brandCollaborations || '--'}</div>
<div className='value'>{selectedCreator?.brand_collaborations || '--'}</div>
<div className='name'>Brand Collaborations</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.productPrice || '--'}</div>
<div className='value'>{selectedCreator?.product_price || '--'}</div>
<div className='name'>Product Price</div>
</div>
</div>
<div className='basic-info-list'>
<div className='basic-info-title'>
Video<span className='time-range'>{selectedCreator.videoTimeRange || '--'}</span>
Video<span className='time-range'>{selectedCreator?.videoTimeRange || '--'}</span>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgVideoGpm || '--'}</div>
<div className='value'>{selectedCreator?.avgVideoGpm || '--'}</div>
<div className='name'>Video GPM</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.videos.length || '--'}</div>
<div className='value'>{selectedCreator?.videos?.length || '--'}</div>
<div className='name'>Videos</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgVideoViews || '--'}</div>
<div className='value'>{selectedCreator?.avgVideoViews || '--'}</div>
<div className='name'>Avg. Video Views</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgVideoEngagements || '--'}</div>
<div className='value'>{selectedCreator?.avgVideoEngagements || '--'}</div>
<div className='name'>Avg. Video Engagement</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgVideoLikes || '--'}</div>
<div className='value'>{selectedCreator?.avgVideoLikes || '--'}</div>
<div className='name'>Avg. Video Likes</div>
</div>
</div>
<div className='basic-info-list'>
<div className='basic-info-title'>
Shoppable Video<span className='time-range'>{selectedCreator.videoTimeRange || '--'}</span>
Shoppable Video<span className='time-range'>{selectedCreator?.videoTimeRange || '--'}</span>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgVideoGpm || '--'}</div>
<div className='value'>{selectedCreator?.avgVideoGpm || '--'}</div>
<div className='name'>Video GPM</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.videos.length || '--'}</div>
<div className='value'>{selectedCreator?.videos?.length || '--'}</div>
<div className='name'>Videos</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgVideoViews || '--'}</div>
<div className='value'>{selectedCreator?.avgVideoViews || '--'}</div>
<div className='name'>Avg. Video Views</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgVideoEngagements || '--'}</div>
<div className='value'>{selectedCreator?.avgVideoEngagements || '--'}</div>
<div className='name'>Avg. Video Engagement</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgVideoLikes || '--'}</div>
<div className='value'>{selectedCreator?.avgVideoLikes || '--'}</div>
<div className='name'>Avg. Video Likes</div>
</div>
</div>
<div className='basic-info-list'>
<div className='basic-info-title'>
LIVE<span className='time-range'>{selectedCreator.liveTimeRange || '--'}</span>
LIVE<span className='time-range'>{selectedCreator?.liveTimeRange || '--'}</span>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgLiveGpm || '--'}</div>
<div className='value'>{selectedCreator?.avgLiveGpm || '--'}</div>
<div className='name'>LIVE GPM</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.liveVideos || '--'}</div>
<div className='value'>{selectedCreator?.liveVideos || '--'}</div>
<div className='name'>LIVE Videos</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgLiveViews || '--'}</div>
<div className='value'>{selectedCreator?.avgLiveViews || '--'}</div>
<div className='name'>Avg. LIVE Views</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgLiveEngagements || '--'}</div>
<div className='value'>{selectedCreator?.avgLiveEngagements || '--'}</div>
<div className='name'>Avg. LIVE Engagement</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgLiveLikes || '--'}</div>
<div className='value'>{selectedCreator?.avgLiveLikes || '--'}</div>
<div className='name'>Avg. LIVE Likes</div>
</div>
</div>
<div className='basic-info-list'>
<div className='basic-info-title'>
Shoppable LIVE<span className='time-range'>{selectedCreator.liveTimeRange || '--'}</span>
Shoppable LIVE<span className='time-range'>{selectedCreator?.liveTimeRange || '--'}</span>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgLiveGpm || '--'}</div>
<div className='value'>{selectedCreator?.avgLiveGpm || '--'}</div>
<div className='name'>LIVE GPM</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.liveVideos || '--'}</div>
<div className='value'>{selectedCreator?.liveVideos || '--'}</div>
<div className='name'>LIVE Videos</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgLiveViews || '--'}</div>
<div className='value'>{selectedCreator?.avgLiveViews || '--'}</div>
<div className='name'>Avg. LIVE Views</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgLiveEngagements || '--'}</div>
<div className='value'>{selectedCreator?.avgLiveEngagements || '--'}</div>
<div className='name'>Avg. LIVE Engagement</div>
</div>
<div className='basic-info-item'>
<div className='value'>{selectedCreator.avgLiveLikes || '--'}</div>
<div className='value'>{selectedCreator?.avgLiveLikes || '--'}</div>
<div className='name'>Avg. LIVE Likes</div>
</div>
</div>
<div className='basic-info-list'>
<div className='basic-info-title'>
Follwers<span className='time-range'>{selectedCreator.liveTimeRange || '--'}</span>
Follwers<span className='time-range'>{selectedCreator?.liveTimeRange || '--'}</span>
</div>
<div className='followers-data-charts'>
<div className='data-chart'>
<div className='chart-title'>Follower Gender</div>
<Doughnut data={data} options={{ ...options, cutout: 90 }} />
<Doughnut data={data} options={{ ...options, cutout: 60 }} />
</div>
<div className='data-chart'>
<div className='chart-title'>Follower Age</div>
<Doughnut data={data} options={{ ...options, cutout: 90 }} />
<Doughnut data={data} options={{ ...options, cutout: 60 }} />
</div>
<div className='data-chart'>
<div className='chart-title'>Top 5 Locations</div>
@ -399,7 +472,7 @@ function CreatorBasicInfo({ selectedCreator }) {
</div>
<div className='basic-info-list'>
<div className='basic-info-title'>
Trends<span className='time-range'>{selectedCreator.liveTimeRange || '--'}</span>
Trends<span className='time-range'>{selectedCreator?.liveTimeRange || '--'}</span>
</div>
<div className='tab-switches'>
<div

View File

@ -5,7 +5,7 @@ import SearchBar from '../components/SearchBar';
import { Button } from 'react-bootstrap';
export default function Database({ path }) {
return (
<React.Fragment>
<div className='database-page'>
<div className='function-bar'>
<SearchBar />
<Button>+ Add to Campaign</Button>
@ -17,7 +17,7 @@ export default function Database({ path }) {
{path === 'youtube' && <div className='breadcrumb-item'>YouTube</div>}
</div>
<DatabaseFilter path={path} pageType={'database'} />
<CreatorList path={path} pageType={'database'} />
</React.Fragment>
<CreatorList path={path} />
</div>
);
}

View File

@ -2,12 +2,11 @@ import React from 'react';
import SearchBar from '../components/SearchBar';
import { Button } from 'react-bootstrap';
import DatabaseFilter from '../components/DatabaseFilter';
import CreatorList from '../components/CreatorList';
import PrivateCreatorList from '../components/PrivateCreatorList';
export default function PrivateCreator({ path }) {
return (
<React.Fragment>
<div className='private-creator-page'>
<div className='function-bar'>
<SearchBar />
<Button>+ Add to Campaign</Button>
@ -19,7 +18,7 @@ export default function PrivateCreator({ path }) {
{path === 'youtube' && <div className='breadcrumb-item'>YouTube</div>}
</div>
<DatabaseFilter path={path} pageType={'private'} />
<CreatorList path={path} pageType={'private'} />
</React.Fragment>
<PrivateCreatorList path={path} />
</div>
);
}

61
src/services/api.js Normal file
View File

@ -0,0 +1,61 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
withCredentials: true, // Include cookies if needed
});
api.interceptors.request.use(
(config) => {
const token = sessionStorage.getItem('token') || '03d9163150a8bfbbee2a33c4444237f337a35278';
if (token) {
config.headers.Authorization = `Token ${token}`;
}
return config;
},
(error) => {
console.error('Request error:', error);
return Promise.reject(error);
}
);
api.interceptors.response.use(
(response) => {
return response;
},
(error) => {
return Promise.reject(error);
}
);
const get = async (url, params) => {
const response = await api.get(url, params);
return response.data;
};
const post = async (url, data) => {
const response = await api.post(url, data);
return response.data;
};
const put = async (url, data) => {
const response = await api.put(url, data);
return response.data;
};
const del = async (url) => {
const response = await api.delete(url);
return response.data;
};
const upload = async (url, data) => {
const response = await api.post(url, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
};
export default { get, post, put, del, upload };

View File

@ -1,4 +1,5 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import api from '../../services/api';
const mockVideos = [
{
id: 1,
@ -32,12 +33,12 @@ export const mockCreators = [
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=1',
category: 'Phones & Electronics',
ecommerceLevel: 'L2',
exposureLevel: 'KOC-1',
e_commerce_level: 'L2',
exposure_level: 'KOC-1',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
avg_video_views: '1.9k',
hasEcommerce: true,
hasTiktok: true,
verified: true,
@ -49,12 +50,12 @@ export const mockCreators = [
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=2',
category: 'Womenswear & Underwear',
ecommerceLevel: 'L3',
exposureLevel: 'KOL-3',
e_commerce_level: 'L3',
exposure_level: 'KOL-3',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
avg_video_views: '1.9k',
hasEcommerce: false,
hasTiktok: true,
verified: false,
@ -66,12 +67,12 @@ export const mockCreators = [
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=3',
category: 'Sports & Outdoor',
ecommerceLevel: 'L4',
exposureLevel: 'KOC-2',
e_commerce_level: 'L4',
exposure_level: 'KOC-2',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
avg_video_views: '1.9k',
hasEcommerce: true,
hasTiktok: true,
verified: false,
@ -83,12 +84,12 @@ export const mockCreators = [
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=4',
category: 'Food & Beverage',
ecommerceLevel: 'L1',
exposureLevel: 'KOC-2',
e_commerce_level: 'L1',
exposure_level: 'KOC-2',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
avg_video_views: '1.9k',
hasEcommerce: true,
hasTiktok: true,
hasInstagram: true,
@ -102,12 +103,12 @@ export const mockCreators = [
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=5',
category: 'Health',
ecommerceLevel: 'L5',
exposureLevel: 'KOL-2',
e_commerce_level: 'L5',
exposure_level: 'KOL-2',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
avg_video_views: '1.9k',
hasEcommerce: false,
hasTiktok: true,
hasInstagram: true,
@ -121,12 +122,12 @@ export const mockCreators = [
name: 'name',
avatar: 'https://api.dicebear.com/7.x/micah/svg?seed=6',
category: 'Kitchenware',
ecommerceLevel: 'New tag',
exposureLevel: 'New tag',
e_commerce_level: 'New tag',
exposure_level: 'New tag',
followers: '162.2k',
gmv: '$534.1k',
soldPercentage: '18.1%',
avgViews: '1.9k',
avg_video_views: '1.9k',
hasEcommerce: true,
hasTiktok: true,
hasInstagram: true,
@ -138,66 +139,110 @@ export const mockCreators = [
];
// 模拟API获取数据的异步Thunk
export const fetchCreators = createAsyncThunk('creators/fetchCreators', async ({ path }, { getState }) => {
// 模拟API调用延迟
await new Promise((resolve) => setTimeout(resolve, 500));
// export const fetchCreators = createAsyncThunk('creators/fetchCreators', async ({ path }, { getState }) => {
// // 模拟API调用延迟
// await new Promise((resolve) => setTimeout(resolve, 500));
// 获取当前的筛选条件
const state = getState();
const filters = state.filters;
// // 获取当前的筛选条件
// const state = getState();
// const filters = state.filters;
// 应用筛选逻辑(实际项目中可能在服务器端进行)
let filteredCreators = [...mockCreators];
// // 应用筛选逻辑(实际项目中可能在服务器端进行)
// let filteredCreators = [...mockCreators];
// 如果有选定的类别,进行筛选
if (filters.category.length > 0) {
filteredCreators = filteredCreators.filter((creator) => filters.category.includes(creator.category));
}
// // 如果有选定的类别,进行筛选
// if (filters.category.length > 0) {
// filteredCreators = filteredCreators.filter((creator) => filters.category.includes(creator.category));
// }
// 如果有选定的电商评级,进行筛选
if (filters.ecommerceRatings.length > 0) {
filteredCreators = filteredCreators.filter((creator) =>
filters.ecommerceRatings.includes(creator.ecommerceLevel)
);
}
// // 如果有选定的电商评级,进行筛选
// if (filters.ecommerceRatings.length > 0) {
// filteredCreators = filteredCreators.filter((creator) =>
// filters.ecommerceRatings.includes(creator.e_commerce_level)
// );
// }
// 如果有选定的曝光评级,进行筛选
if (filters.exposureRatings.length > 0) {
filteredCreators = filteredCreators.filter((creator) =>
filters.exposureRatings.includes(creator.exposureLevel)
);
}
// // 如果有选定的曝光评级,进行筛选
// if (filters.exposureRatings.length > 0) {
// filteredCreators = filteredCreators.filter((creator) =>
// filters.exposureRatings.includes(creator.exposure_level)
// );
// }
// 筛选观看量范围
if (filters.viewsRange.length === 2) {
const minViews = filters.viewsRange[0];
const maxViews = filters.viewsRange[1];
// // 筛选观看量范围
// if (filters.viewsRange.length === 2) {
// const minViews = filters.viewsRange[0];
// const maxViews = filters.viewsRange[1];
filteredCreators = filteredCreators.filter((creator) => {
// 将带k的字符串转换为数字
const viewsStr = creator.avgViews;
let views = parseFloat(viewsStr);
if (viewsStr.includes('k')) {
views *= 1000;
} else if (viewsStr.includes('M')) {
views *= 1000000;
}
// filteredCreators = filteredCreators.filter((creator) => {
// // 将带k的字符串转换为数字
// const viewsStr = creator.avg_video_views;
// let views = parseFloat(viewsStr);
// if (viewsStr.includes('k')) {
// views *= 1000;
// } else if (viewsStr.includes('M')) {
// views *= 1000000;
// }
return views >= minViews && views <= maxViews;
});
}
// return views >= minViews && views <= maxViews;
// });
// }
return filteredCreators;
});
// return filteredCreators;
// });
const initialState = {
creators: [],
publicCreators: [],
privateCreators: [],
publicTiktokCreators: [],
publicInstagramCreators: [],
publicYoutubeCreators: [],
privateTiktokCreators: [],
privateInstagramCreators: [],
privateYoutubeCreators: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
selectedCreators: [],
selectedCreator: null,
pagination: {
current_page: 1,
total_pages: 0,
total_count: 0,
has_next: false,
has_prev: false,
},
hasMore: true,
isLoadingMore: false,
};
export const fetchCreators = createAsyncThunk('creators/fetchCreators', async ({ path, page = 1 }, { getState }) => {
const state = getState();
const filters = state.filters;
const response = await api.get(`/daren_detail/public/creators`, { params: { page } });
return response;
});
export const fetchPrivateCreators = createAsyncThunk(
'creators/fetchPrivateCreators',
async ({ path, page = 1 }, { getState }) => {
const state = getState();
const filters = state.filters;
const queryParams = { pool_id: 1, page };
const response = await api.get(`/daren_detail/private/pools/creators`, { params: queryParams });
return response;
}
);
export const fetchCreatorDetail = createAsyncThunk(
'creators/fetchCreatorDetail',
async ({ creatorId }, { getState }) => {
const response = await api.get(`/daren_detail/creators/${creatorId}`);
return response;
}
);
const creatorsSlice = createSlice({
name: 'creators',
initialState,
@ -218,32 +263,92 @@ const creatorsSlice = createSlice({
clearCreatorSelection: (state) => {
state.selectedCreators = [];
},
selectCreator: (state, action) => {
const id = action.payload;
state.selectedCreator = state.creators.find((creator) => creator.id.toString() === id.toString());
},
clearCreator: (state) => {
state.selectedCreator = null;
},
resetCreators: (state) => {
state.publicCreators = [];
state.privateCreators = [];
state.pagination = initialState.pagination;
state.hasMore = true;
state.isLoadingMore = false;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchCreators.pending, (state) => {
if (state.creators.length === 0) {
state.status = 'loading';
} else {
state.isLoadingMore = true;
}
})
.addCase(fetchCreators.fulfilled, (state, action) => {
state.status = 'succeeded';
state.creators = action.payload;
state.isLoadingMore = false;
const { data, pagination } = action.payload;
if (pagination.current_page === 1) {
state.publicCreators = data;
} else {
state.publicCreators = [...state.publicCreators, ...data];
}
state.pagination = pagination;
state.hasMore = pagination.has_next;
})
.addCase(fetchCreators.rejected, (state, action) => {
console.log(action);
state.status = 'failed';
state.isLoadingMore = false;
state.error = action.error.message;
})
.addCase(fetchPrivateCreators.pending, (state) => {
if (state.creators?.length === 0) {
state.status = 'loading';
} else {
state.isLoadingMore = true;
}
})
.addCase(fetchPrivateCreators.fulfilled, (state, action) => {
state.status = 'succeeded';
state.isLoadingMore = false;
const { data, pagination } = action.payload;
if (pagination.current_page === 1) {
state.privateCreators = data;
} else {
state.privateCreators = [...state.privateCreators, ...data];
}
state.pagination = pagination;
state.hasMore = pagination.has_next;
})
.addCase(fetchPrivateCreators.rejected, (state, action) => {
console.log('fetchPrivateCreators.rejected', action);
state.status = 'failed';
state.isLoadingMore = false;
state.error = action.error.message;
})
.addCase(fetchCreatorDetail.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchCreatorDetail.fulfilled, (state, action) => {
state.status = 'succeeded';
const { data, pagination } = action.payload;
state.selectedCreator = data;
})
.addCase(fetchCreatorDetail.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
export const { toggleCreatorSelection, selectAllCreators, clearCreatorSelection, selectCreator, clearCreator } =
creatorsSlice.actions;
export const {
toggleCreatorSelection,
selectAllCreators,
clearCreatorSelection,
clearCreator,
setCreators,
resetCreators,
} = creatorsSlice.actions;
export default creatorsSlice.reducer;

View File

@ -53,6 +53,9 @@
}
.creator-detail-page {
display: flex;
flex-flow: column nowrap;
gap: 1rem;
.creator-info-detail-container {
display: flex;
flex-direction: row;
@ -78,6 +81,7 @@
height: 115px;
border-radius: 50%;
overflow: hidden;
background-color: $secondary-200;
}
.creator-info-right {
display: flex;
@ -108,6 +112,28 @@
.creator-info-value {
}
}
.category-info {
.creator-info-value {
display: flex;
flex-flow: row nowrap;
gap: 0.5rem;
}
}
.collab-info {
.creator-info-value {
display: flex;
flex-flow: row nowrap;
gap: 0.5rem;
}
}
.badge {
padding: 6px 8px;
border-radius: 12px;
color: $neutral-600;
background-color: $neutral-200;
font-size: 0.875rem;
font-weight: 500;
}
}
.creator-info-3 {
display: flex;
@ -117,6 +143,7 @@
display: flex;
flex-flow: row nowrap;
color: $neutral-900;
align-items: center;
gap: 0.5rem;
.creator-info-label {
@ -202,10 +229,10 @@
color: $neutral-900;
margin-bottom: 1rem;
}
// canvas {
// max-width: 260px;
// max-height: 260px;
// }
canvas {
max-width: 280px;
max-height: 200px;
}
}
}
}
@ -267,6 +294,8 @@
flex-flow: column nowrap;
justify-content: space-between;
canvas {
max-width: 280px;
max-height: 200px;
}
}
}
@ -290,7 +319,7 @@
display: flex;
flex-flow: column nowrap;
gap: 0.5rem;
font-size: .875rem;
font-size: 0.875rem;
.video-title {
font-weight: 700;
text-overflow: ellipsis;
@ -312,7 +341,6 @@
align-items: center;
}
}
}
}
}

View File

@ -3,17 +3,28 @@
// 导入变量
@import './variables';
.database-page {
height: 100%;
position: relative;
}
.private-creator-page {
height: 100%;
position: relative;
}
.creator-database-table {
height: 100%;
position: relative;
.creator-cell {
.creator-avatar {
position: relative;
width: 36px;
height: 36px;
margin-right: 12px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
display: block;
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e2e8f0;
@ -37,7 +48,8 @@
.creator-name {
font-weight: 700;
color: #090909
color: #090909;
white-space: nowrap;
}
}
@ -132,4 +144,22 @@
color: #000000;
}
}
.table-container {
position: relative;
max-height: calc(100% - 455px); // Adjust this value based on your layout
overflow-y: auto;
.sticky-header {
position: sticky;
top: 0;
z-index: 1;
background: white;
th {
background: white;
border-bottom: 2px solid #dee2e6;
}
}
}
}

View File

@ -32,3 +32,10 @@
display: inline-flex !important;
align-items: center;
}
.back-button {
width: max-content;
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}

View File

@ -151,6 +151,7 @@
transition: all 0.3s ease;
background: #f8f9fa;
border-radius: 8px;
overflow: hidden;
}
// Collapsed sidebar adjustments

View File

@ -1,9 +1,11 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
plugins: [react()],
resolve: {
alias: {
@ -23,4 +25,13 @@ export default defineConfig({
cssCodeSplit: false,
minify: true,
},
server: {
proxy: {
'/api': {
target: env.VITE_API_URL || 'http://81.69.223.133:8008',
changeOrigin: true,
},
},
},
};
});