[dev]brands' campaign page

This commit is contained in:
susie-laptop 2025-05-11 20:30:02 -04:00
parent cfb82c19b7
commit 7b0d0a109e
18 changed files with 1219 additions and 204 deletions

18
package-lock.json generated
View File

@ -20,7 +20,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.508.0",
"react": "^19.1.0",
"react-bootstrap": "^2.10.1",
"react-bootstrap": "^2.10.9",
"react-bootstrap-range-slider": "^3.0.8",
"react-dom": "^19.1.0",
"react-redux": "^9.2.0",
@ -1814,6 +1814,11 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="
},
"node_modules/@types/react": {
"version": "19.1.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.3.tgz",
@ -3928,13 +3933,14 @@
}
},
"node_modules/react-bootstrap": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.1.tgz",
"integrity": "sha512-J3OpRZIvCTQK+Tg/jOkRUvpYLHMdGeU9KqFUBQrV0d/Qr/3nsINpiOJyZMWnM5SJ3ctZdhPA6eCIKpEJR3Ellg==",
"version": "2.10.9",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz",
"integrity": "sha512-TJUCuHcxdgYpOqeWmRApM/Dy0+hVsxNRFvq2aRFQuxhNi/+ivOxC5OdWIeHS3agxvzJ4Ev4nDw2ZdBl9ymd/JQ==",
"dependencies": {
"@babel/runtime": "^7.22.5",
"@babel/runtime": "^7.24.7",
"@restart/hooks": "^0.4.9",
"@restart/ui": "^1.6.6",
"@restart/ui": "^1.9.4",
"@types/prop-types": "^15.7.12",
"@types/react-transition-group": "^4.4.6",
"classnames": "^2.3.2",
"dom-helpers": "^5.2.1",

View File

@ -22,7 +22,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.508.0",
"react": "^19.1.0",
"react-bootstrap": "^2.10.1",
"react-bootstrap": "^2.10.9",
"react-bootstrap-range-slider": "^3.0.8",
"react-dom": "^19.1.0",
"react-redux": "^9.2.0",

View File

@ -4,7 +4,7 @@ import { fetchBrands } from '../store/slices/brandsSlice';
import { Card } from 'react-bootstrap';
import { Folders, Hash, Link, Users } from 'lucide-react';
export default function BrandsList() {
export default function BrandsList({ openBrandDetail }) {
const brands = useSelector((state) => state.brands.brands);
const dispatch = useDispatch();
@ -14,8 +14,8 @@ export default function BrandsList() {
return (
<div className='brands-list'>
{brands.map((brand) => (
<div className='brand-card shadow-xs' key={brand.id}>
{brands?.length > 0 && brands.map((brand) => (
<div className='brand-card shadow-xs' key={brand.id} onClick={() => openBrandDetail(brand)}>
<Card.Body>
<Card.Title className='text-primary fw-bold'>
<span className='card-logo'>{brand.name.slice(0, 1)}</span>

View File

@ -0,0 +1,91 @@
import { ChartNoAxesColumnIncreasing, CircleDollarSign, Edit, Eye, Folders, Hash, Layers, Tag, TrendingUp, UserRoundCheck } from 'lucide-react';
import { useSelector } from 'react-redux';
export default function CampaignInfo() {
const { selectedCampaign } = useSelector((state) => state.brands);
return (
<div className='campaign-detail-info shadow-xs'>
<div className='campaign-info-top'>
<div className='campaign-name'>{selectedCampaign.name}</div>
<div className='campaign-descp'>{selectedCampaign.description || '--'}</div>
<div className='campaign-edit'>
<Edit size={18} />
Edit
</div>
</div>
<div className='campaign-info-bottom'>
<div className='campaign-info-item'>
<div className='campaign-info-item-label'>
<Layers size={18} />
Service
</div>
<div className='campaign-info-item-value'>{selectedCampaign?.service || '--'}</div>
</div>
<div className='campaign-info-item'>
<div className='campaign-info-item-label'>
<Folders size={18} />
Category
</div>
<div className='campaign-info-item-value'>
{selectedCampaign?.category?.length > 0 &&
selectedCampaign.category.map((cat) => <span className='category-tag'>{cat}</span>)}
</div>
</div>
<div className='campaign-info-item'>
<div className='campaign-info-item-label'>
<UserRoundCheck size={18} />
Followers
</div>
<div className='campaign-info-item-value'>{selectedCampaign?.followers || '--'}</div>
</div>
<div className='campaign-info-item'>
<div className='campaign-info-item-label'>
<Tag size={18} />
Creator Category
</div>
<div className='campaign-info-item-value'>{selectedCampaign?.creator_category || '--'}</div>
</div>
<div className='campaign-info-item'>
<div className='campaign-info-item-label'>
<TrendingUp size={18} />
GMV
</div>
<div className='campaign-info-item-value'>{selectedCampaign?.gmv || '--'}</div>
</div>
<div className='campaign-info-item'>
<div className='campaign-info-item-label'>
<CircleDollarSign size={18} />
Pricing
</div>
<div className='campaign-info-item-value'>{selectedCampaign?.pricing || '--'}</div>
</div>
<div className='campaign-info-item'>
<div className='campaign-info-item-label'>
<ChartNoAxesColumnIncreasing size={18} />
Creator Level
</div>
<div className='campaign-info-item-value'>
{selectedCampaign?.creator_level?.length > 0 &&
selectedCampaign.creator_level.map((level) => (
<span className='creator-level-tag'>{level}</span>
))}
</div>
</div>
<div className='campaign-info-item'>
<div className='campaign-info-item-label'>
<Eye size={18} />
Views
</div>
<div className='campaign-info-item-value'>{selectedCampaign?.views || '--'}</div>
</div>
<div className='campaign-info-item'>
<div className='campaign-info-item-label'>
<Hash size={18} />
Creators
</div>
<div className='campaign-info-item-value'>{selectedCampaign?.creators || '--'}</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,70 @@
import React from 'react';
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { ChartNoAxesColumnIncreasing, CircleDollarSign, Edit, Eye, Folders, Hash, Layers, Tag, TrendingUp, UserRoundCheck } from 'lucide-react';
export default function CampaignList() {
const { selectedBrand } = useSelector((state) => state.brands);
useEffect(() => {
console.log(selectedBrand);
}, [selectedBrand]);
return (
<div className='campaigns-list'>
{selectedBrand?.campaigns?.length > 0 &&
selectedBrand.campaigns.map((campaign) => (
<div className='campaign-info'>
<Link to={`/brands/${selectedBrand.id}/campaigns/${campaign.id}`} className='campaign-title'>
{campaign.name}
</Link>
<div className='campaign-item'>
<div className='campaign-item-label'><Layers size={18} />Service</div>
<div className='campaign-item-value'>{campaign.service}</div>
</div>
<div className='campaign-item'>
<div className='campaign-item-label'>Creator Type</div>
<div className='campaign-item-value'>{campaign.creatorType}</div>
</div>
<div className='campaign-item'>
<div className='campaign-item-label'><Hash size={18} />Creators</div>
<div className='campaign-item-value'>{campaign.creators}</div>
</div>
<div className='campaign-item'>
<div className='campaign-item-label'><ChartNoAxesColumnIncreasing size={18} />Creator Level</div>
<div className='campaign-item-value'>
{campaign.creator_level &&
campaign.creator_level.map((level) => (
<span className='creator-level-tag'>{level}</span>
))}
</div>
</div>
<div className='campaign-item'>
<div className='campaign-item-label'><Folders size={18} />Category</div>
<div className='campaign-item-value'>
{campaign.category &&
campaign.category.map((cat) => <span className='category-tag'>{cat}</span>)}
</div>
</div>
<div className='campaign-item'>
<div className='campaign-item-label'><TrendingUp size={18} />GMV</div>
<div className='campaign-item-value'>{campaign.gmv}</div>
</div>
<div className='campaign-item'>
<div className='campaign-item-label'><UserRoundCheck size={18} />Followers</div>
<div className='campaign-item-value'>{campaign.followers}</div>
</div>
<div className='campaign-item'>
<div className='campaign-item-label'><Eye size={18} />Views</div>
<div className='campaign-item-value'>{campaign.views}</div>
</div>
<div className='campaign-item'>
<div className='campaign-item-label'><CircleDollarSign size={18} />Budget</div>
<div className='campaign-item-value'>{campaign.budget}</div>
</div>
</div>
))}
</div>
);
}

View File

@ -10,6 +10,7 @@ import {
} from '../store/slices/creatorsSlice';
import { setSortBy } from '../store/slices/filtersSlice';
import '../styles/DatabaseList.scss';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
export default function DatabaseList({ path }) {
const dispatch = useDispatch();
@ -175,7 +176,7 @@ export default function DatabaseList({ path }) {
<td className='text-center'>
{creator.hasTiktok && (
<div className='social-icon tiktok-icon mx-auto'>
<i className='fab fa-tiktok'></i>
<FontAwesomeIcon icon='fa-brands fa-tiktok' />
</div>
)}
</td>

View File

@ -144,15 +144,15 @@ export default function Sidebar() {
return (
<div className={`sidebar ${collapsed ? 'sidebar-collapsed' : ''}`}>
<div className='sidebar-header p-3 d-flex align-items-center'>
<Link to='/' className='sidebar-header p-3 d-flex align-items-center'>
<img src={logo} alt='OOIN Logo' width={48} height={48} className='me-2' />
{!collapsed && (
<div>
<div className='fw-bold'>OOIN Media</div>
<div className='text-black fw-bold'>OOIN Media</div>
<div className='small text-muted'>Creator Center</div>
</div>
)}
</div>
</Link>
<Nav className='flex-column sidebar-nav'>
{menuItems.map((item) => {

View File

@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import { Table, Form } from 'react-bootstrap';
import { useSelector, useDispatch } from 'react-redux';
import { useParams } from 'react-router-dom';
import '../styles/Products.scss';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
export default function ProductsList() {
const { brandId } = useParams();
const { brands } = useSelector((state) => state.brands);
const [products, setProducts] = useState([]);
const [selectedProducts, setSelectedProducts] = useState([]);
const [sortField, setSortField] = useState(null);
const [sortDirection, setSortDirection] = useState('asc');
useEffect(() => {
if (brands.length > 0) {
const brand = brands.find((b) => b.id.toString() === brandId);
if (brand && brand.products) {
setProducts(brand.products);
}
}
}, [brands, brandId]);
const handleSort = (field) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const renderSortIcon = (field) => {
if (sortField !== field) return null;
return sortDirection === 'asc' ? '↑' : '↓';
};
const handleSelectAll = (e) => {
if (e.target.checked) {
setSelectedProducts(products.map((product) => product.id));
} else {
setSelectedProducts([]);
}
};
const handleSelectProduct = (productId) => {
if (selectedProducts.includes(productId)) {
setSelectedProducts(selectedProducts.filter((id) => id !== productId));
} else {
setSelectedProducts([...selectedProducts, productId]);
}
};
return (
<div className='products-list'>
<Table responsive hover className='bg-white shadow-xs rounded overflow-hidden'>
<thead>
<tr>
<th className='selector' style={{ width: '40px' }}>
<Form.Check
type='checkbox'
checked={selectedProducts.length === products.length && products.length > 0}
onChange={handleSelectAll}
/>
</th>
<th className='product' onClick={() => handleSort('name')}>
Product {renderSortIcon('name')}
</th>
<th className='commission text-center' onClick={() => handleSort('commission')}>
Commission Rate {renderSortIcon('commission')}
</th>
<th className='samples text-center' onClick={() => handleSort('availableSamples')}>
Available Samples {renderSortIcon('availableSamples')}
</th>
<th className='price text-center' onClick={() => handleSort('price')}>
Sales Price {renderSortIcon('price')}
</th>
<th className='stock text-center' onClick={() => handleSort('stock')}>
Stock {renderSortIcon('stock')}
</th>
<th className='sold text-center' onClick={() => handleSort('sold')}>
Items Sold {renderSortIcon('sold')}
</th>
<th className='rating text-center' onClick={() => handleSort('rating')}>
Product Rating {renderSortIcon('rating')}
</th>
<th className='creators text-center' onClick={() => handleSort('collabCreators')}>
Collab. Creators {renderSortIcon('collabCreators')}
</th>
<th className='tiktokShop text-center' onClick={() => handleSort('tiktokShop')}>
TikTok Shop {renderSortIcon('tiktokShop')}
</th>
</tr>
</thead>
<tbody>
{products.length === 0 ? (
<tr>
<td colSpan='10' className='text-center py-4'>
No products found for this brand.
</td>
</tr>
) : (
products.map((product) => (
<tr key={product.id} className={selectedProducts.includes(product.id) ? 'selected' : ''}>
<td>
<Form.Check
type='checkbox'
checked={selectedProducts.includes(product.id)}
onChange={() => handleSelectProduct(product.id)}
/>
</td>
<td className='product-cell'>
<div className='d-flex align-items-center'>
<div className='product-logo'>{product.name.slice(0, 1)}</div>
<div className='product-name'>{product.name}</div>
</div>
</td>
<td className='text-center' >
<div>{product.commission}</div>
<div className='small text-muted'>Open collab. {product.openCollab}</div>
</td>
<td className='text-center'>{product.availableSamples}</td>
<td className='text-center'>{product.price}</td>
<td className='text-center'>{product.stock}</td>
<td className='text-center'>{product.sold}</td>
<td className='text-center'>
<div>{product.rating}</div>
<div className='small text-muted'>{product.reviews} Reviews</div>
</td>
<td className='text-center'>{product.collabCreators}</td>
<td className='text-center'>{product.tiktokShop && <FontAwesomeIcon icon='fa-brands fa-tiktok' />}</td>
</tr>
))
)}
</tbody>
</Table>
</div>
);
}

View File

@ -1,21 +1,39 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import BrandsList from '../components/BrandsList';
import SearchBar from '../components/SearchBar';
import { Button, Modal, Form } from 'react-bootstrap';
import '../styles/Brands.scss';
import { useNavigate } from 'react-router-dom';
import { Plus } from 'lucide-react';
import { useDispatch } from 'react-redux';
import { selectBrand } from '../store/slices/brandsSlice';
export default function Brands() {
const navigate = useNavigate();
const dispatch = useDispatch();
const [showAddBrandModal, setShowAddBrandModal] = useState(false);
useEffect(() => {}, [dispatch]);
const openBrandDetail = async (item) => {
console.log(item);
await dispatch(selectBrand(item));
navigate(`/brands/${item.id}`);
};
return (
<React.Fragment>
<div className='function-bar'>
<SearchBar />
<Button onClick={() => setShowAddBrandModal(true)}>+ Add Brand</Button>
<Button onClick={() => setShowAddBrandModal(true)}>
<Plus />
Add Brand
</Button>
</div>
<div className='breadcrumb'>
<div className='breadcrumb-item'>Brands</div>
</div>
<BrandsList />
<BrandsList openBrandDetail={openBrandDetail} />
<AddBrandModal show={showAddBrandModal} onHide={() => setShowAddBrandModal(false)} />
</React.Fragment>
);
@ -45,7 +63,7 @@ function AddBrandModal({ show, onHide }) {
};
return (
<Modal show={show} onHide={onHide}>
<Modal show={show} onHide={onHide} backdropClassName='modal-backdrop' container={document.body}>
<Modal.Header>
<Modal.Title>Add Brand</Modal.Title>
</Modal.Header>

121
src/pages/BrandsDetail.jsx Normal file
View File

@ -0,0 +1,121 @@
import React, { useEffect, useState } from 'react';
import SearchBar from '../components/SearchBar';
import { Button } from 'react-bootstrap';
import { Folders, Hash, LinkIcon, Plus, Users } from 'lucide-react';
import { useDispatch, useSelector } from 'react-redux';
import { Link, useParams } from 'react-router-dom';
import CampaignList from '../components/CampaignList';
import ProductsList from '../components/ProductsList';
import { findBrandById } from '../store/slices/brandsSlice';
export default function BrandsDetail() {
const { id } = useParams();
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState('campaigns');
const { selectedBrand } = useSelector((state) => state.brands);
useEffect(() => {
if (id) {
console.log(id);
dispatch(findBrandById(id));
}
}, [dispatch, id]);
return (
selectedBrand?.id && (
<React.Fragment>
<div className='function-bar'>
<SearchBar />
{activeTab === 'campaigns' && (
<Button>
<Plus />
Add Campaign
</Button>
)}
{activeTab === 'products' && (
<Button>
<Plus />
Add Product
</Button>
)}
</div>
<div className='breadcrumb'>
<Link to={'/brands'} className='breadcrumb-item'>
Brands
</Link>
<div className='breadcrumb-item'>{selectedBrand.name}</div>
</div>
<div className='brand-detail-info'>
<div className='brand-logo shadow-xs'>{selectedBrand.name.toUpperCase()}</div>
<div className='brand-info shadow-xs'>
<div className='brand-info-top'>
<div className='info-item'>
<div className='info-name'>
<Folders size={20} />
商家分类
</div>
<div className='info-value'>{selectedBrand.category}</div>
</div>
<div className='info-item'>
<div className='info-name'>
<LinkIcon size={20} />
Source
</div>
<div className='info-value'>{selectedBrand.source}</div>
</div>
<div className='info-item'>
<div className='info-name'>
<Hash size={20} />
Collab.
</div>
<div className='info-value'>{selectedBrand.collab}</div>
</div>
<div className='info-item'>
<div className='info-name'>
<Users size={20} />
Creators
</div>
<div className='info-value'>{selectedBrand.creators}</div>
</div>
</div>
<div className='brand-info-bottom'>
<div className='info-item'>
<div className='info-value'>{selectedBrand.collab}</div>
<div className='info-name'>Total Collab. Creators</div>
</div>
<div className='info-item'>
<div className='info-value'>{selectedBrand.collab}</div>
<div className='info-name'>Total GMV Achieved</div>
</div>
<div className='info-item'>
<div className='info-value'>{selectedBrand.collab}</div>
<div className='info-name'>Total Views Achieved</div>
</div>
<div className='info-item'>
<div className='info-value'>{selectedBrand.collab}</div>
<div className='info-name'>Shop Overall Rating</div>
</div>
</div>
</div>
</div>
<div className='brand-tab-switches tab-switches'>
<div
className={`tab-switch-item ${activeTab === 'campaigns' ? 'active' : ''}`}
onClick={() => setActiveTab('campaigns')}
>
Campaigns
</div>
<div
className={`tab-switch-item ${activeTab === 'products' ? 'active' : ''}`}
onClick={() => setActiveTab('products')}
>
Products
</div>
</div>
{activeTab === 'campaigns' && <CampaignList />}
{activeTab === 'products' && <ProductsList />}
</React.Fragment>
)
);
}

View File

@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import SearchBar from '../components/SearchBar';
import { Button, Form } from 'react-bootstrap';
import { useSelector, useDispatch } from 'react-redux';
import { fetchBrands, findCampaignById } from '../store/slices/brandsSlice';
import CampaignInfo from '../components/CampaignInfo';
import { ChevronRight, Send } from 'lucide-react';
import ProductsList from '../components/ProductsList';
export default function CampaignDetail() {
const { brandId, campaignId } = useParams();
const dispatch = useDispatch();
const { brands, selectedBrand, selectedCampaign } = useSelector((state) => state.brands);
const progressList = ['Find', 'Review', 'Confirmed', 'Draft Ready', 'Published'];
const [progressIndex, setProgressIndex] = useState(2);
const [activeTab, setActiveTab] = useState('products');
useEffect(() => {
dispatch(fetchBrands());
}, [dispatch]);
useEffect(() => {
if (brandId && campaignId) {
dispatch(findCampaignById({ brandId, campaignId }));
}
}, [brandId, campaignId]);
return (
selectedCampaign?.id && (
<div className='campaign-detail'>
<div className='function-bar'>
<SearchBar />
<Button>
<Send size={18} />
Email
</Button>
</div>
<div className='breadcrumb'>
<Link to={'/brands'} className='breadcrumb-item'>
Brands
</Link>
<Link to={`/brands/${brandId}`} className='breadcrumb-item'>{selectedBrand.name}</Link>
<div className='breadcrumb-item'>{selectedCampaign.name}</div>
</div>
<CampaignInfo />
<Form className='campaign-requirements shadow-xs'>
<Form.Group className='mb-3 additional_requirements' controlId='additional_requirements'>
<Form.Label>Additional Requirements</Form.Label>
<Form.Control type='text' placeholder='xxx' />
</Form.Group>
<Form.Group className='mb-3 creator_requirements' controlId='creator_requirements'>
<Form.Label>Creators</Form.Label>
<Form.Control type='number' />
</Form.Group>
<Button type='submit' variant='primary-subtle'>
Match Creators
</Button>
</Form>
<div className='campaign-progress shadow-xs'>
{progressList.map((item, index) =>
index < progressList.length - 1 ? (
<>
<div className={`campaign-progress-item ${progressIndex === index ? 'active' : ''}`} key={index}>
<div className='campaign-progress-item-index'>{index + 1}</div>
<div className='campaign-progress-item-label'>{item}</div>
<div className='campaign-progress-item-desc'>xx Creators</div>
</div>
<ChevronRight />
</>
) : (
<div className={`campaign-progress-item ${progressIndex === index ? 'active' : ''}`} key={index}>
<div className='campaign-progress-item-index'>{index + 1}</div>
<div className='campaign-progress-item-label'>{item}</div>
<div className='campaign-progress-item-desc'>xx Creators</div>
</div>
)
)}
</div>
<div className='campaign-tab-switches tab-switches'>
<div
className={`tab-switch-item ${activeTab === 'products' ? 'active' : ''}`}
onClick={() => setActiveTab('products')}
>
Products
</div>
<div
className={`tab-switch-item ${activeTab === 'accepted_creators' ? 'active' : ''}`}
onClick={() => setActiveTab('accepted_creators')}
>
Accepted Creators
</div>
<div
className={`tab-switch-item ${activeTab === 'matching_result' ? 'active' : ''}`}
onClick={() => setActiveTab('matching_result')}
>
Matching Result
</div>
<div
className={`tab-switch-item ${activeTab === 'email_draft' ? 'active' : ''}`}
onClick={() => setActiveTab('email_draft')}
>
Email Draft
</div>
</div>
{activeTab === 'products' && <ProductsList />}
</div>
)
);
}

View File

@ -3,8 +3,10 @@ import Home from '../pages/Home';
import Database from '../pages/Database';
import MainLayout from '../components/Layouts/MainLayout';
import Brands from '../pages/Brands';
import CreatorInbox from '../pages/CreatorInbox';
import DividLayout from '../components/Layouts/DividLayout';
import CreatorInbox from '@/pages/CreatorInbox';
import DividLayout from '@/components/Layouts/DividLayout';
import BrandsDetail from '@/pages/BrandsDetail';
import CampaignDetail from '../pages/CampaignDetail';
// Routes configuration object
const routes = [
@ -49,7 +51,14 @@ const routes = [
path: '/brands',
element: <Brands />,
},
{
path: '/brands/:id',
element: <BrandsDetail />,
},
{
path: '/brands/:brandId/campaigns/:campaignId',
element: <CampaignDetail />,
},
{
path: '/settings',
element: <Home />,

View File

@ -1,4 +1,78 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
const mockCampaigns = [
{
id: 1,
name: 'SUNLINK拍拍灯',
service: '达人短视频-付费',
creators: 10,
creator_type: '带货类达人',
creator_level: ['L2', 'L3'],
category: ['家居', '生活', '数码'],
gmv: '$4k - $10k',
followers: 10000,
views: '500 - 10.5k',
budget: '$10 - $150',
},
{
id: 2,
name: 'MINISO',
service: '达人短视频-付费',
creators: 10,
creator_type: '带货类达人',
creator_level: ['L2', 'L3'],
category: ['家居', '生活', '数码'],
gmv: '$4k - $10k',
followers: 10000,
views: '500 - 10.5k',
budget: '$10 - $150',
},
];
const mockProducts = [
{
id: 1,
name: 'Name',
commission: '15%',
openCollab: '12%',
availableSamples: 10,
price: '$78.90 - $118.90',
stock: 884,
sold: 732,
rating: 4.2,
reviews: 58,
collabCreators: 40,
tiktokShop: true,
},
{
id: 2,
name: 'Name',
commission: '15%',
openCollab: '12%',
availableSamples: 10,
price: '$78.90 - $118.90',
stock: 884,
sold: 732,
rating: 4.2,
reviews: 58,
collabCreators: 40,
tiktokShop: true,
},
{
id: 3,
name: 'Name',
commission: '15%',
openCollab: '12%',
availableSamples: 10,
price: '$78.90 - $118.90',
stock: 884,
sold: 732,
rating: 4.2,
reviews: 58,
collabCreators: 40,
tiktokShop: true,
},
];
const mockBrands = [
{
@ -10,6 +84,8 @@ const mockBrands = [
source: 'TKS Official',
description: 'Description 1',
website: 'https://www.brand1.com',
campaigns: mockCampaigns,
products: mockProducts,
},
{
id: 2,
@ -20,12 +96,15 @@ const mockBrands = [
source: 'TKS Official',
description: 'Description 1',
website: 'https://www.brand1.com',
campaigns: mockCampaigns,
products: mockProducts,
},
];
export const fetchBrands = createAsyncThunk('brands/fetchBrands', async () => {
// const response = await fetch('https://api.example.com/brands');
await new Promise((resolve) => setTimeout(resolve, 500));
console.log('fetchBrands');
return mockBrands;
});
@ -35,6 +114,7 @@ const initialState = {
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
selectedBrand: {},
selectedCampaign: {},
};
const brandsSlice = createSlice({
@ -44,6 +124,18 @@ const brandsSlice = createSlice({
selectBrand: (state, action) => {
state.selectedBrand = action.payload;
},
findBrandById: (state, action) => {
state.selectedBrand = state.brands.find((brand) => brand.id.toString() === action.payload);
},
findCampaignById: (state, action) => {
const { brandId, campaignId } = action.payload;
console.log(brandId, campaignId);
console.log(state.brands);
const brand = state.brands?.find((b) => b.id.toString() === brandId);
state.selectedBrand = brand;
state.selectedCampaign = brand?.campaigns?.find((c) => c.id.toString() === campaignId) || {};
},
},
extraReducers: (builder) => {
builder
@ -61,6 +153,6 @@ const brandsSlice = createSlice({
},
});
export const { selectBrand } = brandsSlice.actions;
export const { selectBrand, findBrandById, findCampaignById } = brandsSlice.actions;
export default brandsSlice.reducer;

View File

@ -58,3 +58,324 @@
justify-content: flex-end;
}
}
.brand-detail-info {
display: flex;
flex-flow: row wrap;
gap: 1rem;
.brand-logo {
height: 12.5rem;
max-width: 25rem;
flex-grow: 1;
background-color: #fff;
color: $primary;
font-size: 2rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
border-radius: 0.375rem;
}
.brand-info {
flex-grow: 1;
font-size: 0.875rem;
background-color: #fff;
border-radius: 0.375rem;
.brand-info-top {
padding: 1.5rem;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid $neutral-200;
.info-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 0.375rem;
.info-name {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
}
}
.brand-info-bottom {
padding: 1.5rem;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
.info-item {
display: flex;
flex-flow: column nowrap;
align-items: center;
gap: 0.5rem;
background-color: $neutral-150;
border-radius: 0.5rem;
padding: 1rem 0;
flex: 1;
.info-value {
font-weight: 800;
}
.info-name {
font-weight: 600;
color: $neutral-600;
}
}
}
}
}
.tab-switches {
width: 100%;
display: flex;
margin: 1rem 0;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
border-radius: 0.375rem;
.tab-switch-item {
flex: 1;
text-align: center;
padding: 0.5rem 0;
background-color: $neutral-200;
transition: 0.25s;
&:first-child {
border-top-left-radius: 0.375rem;
border-bottom-left-radius: 0.375rem;
}
&:last-child {
border-top-right-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
}
&.active {
background-color: $primary;
color: white;
&:hover {
opacity: 0.8;
color: white;
background-color: $primary;
}
}
&:hover {
background-color: $neutral-350;
}
}
}
.campaigns-list {
display: flex;
gap: 0.875rem;
.campaign-info {
background-color: #fff;
border-radius: 0.375rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
padding: 1rem;
margin-bottom: 1rem;
border: 2px solid transparent;
transition: 0.25s;
width: 350px;
display: flex;
flex-flow: column nowrap;
gap: 0.5rem;
// &:hover {
// border: 2px solid $violet-400;
// }
.campaign-title {
font-size: 1.25rem;
font-weight: 800;
margin-bottom: .5rem;
color: $primary;
cursor: pointer;
}
.campaign-item {
display: flex;
align-items: flex-start;
}
.campaign-item-label {
width: 120px;
color: #666;
font-size: 14px;
display: flex;
align-items: center;
}
.campaign-item-value {
flex: 1;
font-weight: 500;
color: #333;
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.creator-level-tag {
background-color: #e8f0fe;
color: #1967d2;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.category-tag {
color: $primary;
padding: 2px 8px;
border-radius: 16px;
font-size: 12px;
border: 1px solid $primary;
background-color: transparent;
}
}
}
.campaign-detail {
display: flex;
flex-flow: row wrap;
gap: 1rem;
.campaign-detail-info {
width: 100%;
background-color: #fff;
padding: 1.5rem;
border-radius: 0.375rem;
.campaign-info-top {
position: relative;
display: flex;
flex-flow: column nowrap;
align-items: flex-start;
gap: 0.25rem;
.campaign-name {
font-size: 1.25rem;
font-weight: 800;
color: $primary;
}
.campaign-descp {
}
.campaign-edit {
position: absolute;
right: 0;
top: 0;
color: $primary;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
}
}
.campaign-info-bottom {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
width: 100%;
.campaign-info-item {
width: 30%;
flex-shrink: 0;
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
.campaign-info-item-label {
color: $neutral-700;
width: 8.5rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
}
}
}
.campaign-requirements {
width: 30%;
max-width: 380px;
background-color: #fff;
padding: 1rem 1.5rem;
border-radius: 0.375rem;
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
.additional_requirements {
width: 100%;
}
.creator_requirements {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 0.5rem;
width: 50%;
.form-label {
margin: 0;
}
}
}
.campaign-progress {
flex: 1;
background-color: #fff;
padding: 1rem 1.5rem;
border-radius: 0.375rem;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
.campaign-progress-item {
display: flex;
flex-flow: column nowrap;
align-items: center;
gap: 0.375rem;
flex: 1;
border-bottom: 4px solid transparent;
padding: .375rem 0;
.campaign-progress-item-index {
width: 1.75rem;
height: 1.75rem;
text-align: center;
line-height: 1.75rem;
border-radius: 50%;
border: 1px solid $neutral-600;
color: $neutral-700;
}
.campaign-progress-item-desc {
font-size: 0.75rem;
color: $neutral-600;
}
&.active {
border-color: $primary;
.campaign-progress-item-index {
border-color: $primary;
background-color: $primary;
color: white;
}
}
}
}
}

View File

@ -1,186 +1,131 @@
@import './custom-theme.scss';
.creator-database-table {
.table {
border-collapse: separate;
border-spacing: 0;
th {
background-color: #f8f9fa;
border-top: none;
border-bottom: 1px solid #dee2e6;
padding: 1rem 0.75rem;
font-weight: 600;
font-size: 0.875rem;
color: #495057;
cursor: pointer;
position: relative;
vertical-align: middle;
&:first-child {
border-top-left-radius: 0.5rem;
}
&:last-child {
border-top-right-radius: 0.5rem;
}
&:hover {
background-color: #f1f3f5;
}
}
td {
padding: 0.75rem;
vertical-align: middle;
border-bottom: 1px solid #f1f3f4;
font-size: 0.875rem;
}
tbody tr {
&:hover {
background-color: rgba(0, 0, 0, 0.02);
}
&.selected {
background-color: rgba(99, 102, 241, 0.05);
&:hover {
background-color: rgba(99, 102, 241, 0.1);
.creator-cell {
.creator-avatar {
position: relative;
width: 36px;
height: 36px;
margin-right: 12px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e2e8f0;
}
.verified-badge {
position: absolute;
bottom: 0;
right: 0;
background-color: #10b981;
color: white;
border-radius: 50%;
width: 10px;
height: 10px;
font-size: 8px;
display: flex;
align-items: center;
justify-content: center;
}
}
.creator-name {
font-weight: 500;
}
}
&:last-child td {
border-bottom: none;
}
}
}
.creator-cell {
.creator-avatar {
position: relative;
width: 36px;
height: 36px;
margin-right: 12px;
img {
width: 100%;
height: 100%;
.category-pill {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
background-color: #f8f9fa;
font-size: 0.75rem;
color: #495057;
white-space: nowrap;
&.phones {
background-color: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
&.women {
background-color: rgba(244, 114, 182, 0.1);
color: #f472b6;
}
&.sports {
background-color: rgba(52, 211, 153, 0.1);
color: #34d399;
}
&.food {
background-color: rgba(251, 146, 60, 0.1);
color: #fb923c;
}
&.health {
background-color: rgba(56, 189, 248, 0.1);
color: #38bdf8;
}
&.kitchen {
background-color: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
}
.level-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
white-space: nowrap;
&.ecommerce-level {
background-color: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
&.exposure-level {
background-color: rgba(14, 165, 233, 0.1);
color: #0ea5e9;
&[data-level^='KOC'] {
background-color: rgba(52, 211, 153, 0.1);
color: #34d399;
}
&[data-level^='KOL'] {
background-color: rgba(251, 113, 133, 0.1);
color: #fb7185;
}
}
}
.colored-dot {
width: 8px;
height: 8px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e2e8f0;
}
.verified-badge {
position: absolute;
bottom: 0;
right: 0;
background-color: #10b981;
color: white;
border-radius: 50%;
width: 10px;
height: 10px;
font-size: 8px;
display: inline-block;
&.blue {
background-color: #6366f1;
}
}
.social-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
&.tiktok-icon {
font-size: 14px;
color: #000000;
}
}
.creator-name {
font-weight: 500;
}
}
.category-pill {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
background-color: #f8f9fa;
font-size: 0.75rem;
color: #495057;
white-space: nowrap;
&.phones {
background-color: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
&.women {
background-color: rgba(244, 114, 182, 0.1);
color: #f472b6;
}
&.sports {
background-color: rgba(52, 211, 153, 0.1);
color: #34d399;
}
&.food {
background-color: rgba(251, 146, 60, 0.1);
color: #fb923c;
}
&.health {
background-color: rgba(56, 189, 248, 0.1);
color: #38bdf8;
}
&.kitchen {
background-color: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
}
.level-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
white-space: nowrap;
&.ecommerce-level {
background-color: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
&.exposure-level {
background-color: rgba(14, 165, 233, 0.1);
color: #0ea5e9;
&[data-level^="KOC"] {
background-color: rgba(52, 211, 153, 0.1);
color: #34d399;
}
&[data-level^="KOL"] {
background-color: rgba(251, 113, 133, 0.1);
color: #fb7185;
}
}
}
.colored-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
&.blue {
background-color: #6366f1;
}
}
.social-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
&.tiktok-icon {
font-size: 14px;
color: #000000;
}
}
}
}

16
src/styles/Products.scss Normal file
View File

@ -0,0 +1,16 @@
.products-list {
width: 100%;
.product-logo {
position: relative;
width: 3rem;
height: 3rem;
line-height: 3rem;
text-align: center;
border-radius: 0.5rem;
background-color: #7f55e0;
color: white;
font-size: 0.875rem;
margin-right: .25rem;
}
}

View File

@ -15,10 +15,11 @@ $indigo-100: #e0e7ff;
$indigo-500: #6366f1;
$violet-50: #f5f3ff;
$violet-100: #ede9fe;
$violet-150: #E0E1FAFF;
$neutral-150: #f8f9faFF;
$neutral-200: #F3F4F6FF;
$neutral-350: #CFD2DAFF;
$violet-150: #e0e1faff;
$violet-400: #a78bfa;
$neutral-150: #f8f9faff;
$neutral-200: #f3f4f6ff;
$neutral-350: #cfd2daff;
$neutral-600: #565e6cff;
$neutral-700: #323842ff;
$neutral-900: #171a1fff;
@ -37,7 +38,7 @@ $box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
:root {
--bs-breadcrumb-font-size: 1.5rem;
--bs-body-color: #171A1FFF;
--bs-body-color: #171a1fff;
--bs-btn-color: white !important;
--bs-btn-hover-color: white !important;
}
@ -51,7 +52,17 @@ $box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-btn-hover-color: white !important;
}
.btn-primary-subtle {
background-color: $violet-150;
color: $indigo-500;
&:hover {
background-color: $primary !important;
color: white;
}
}
#root {
font-weight: 500;
background-color: #f5f3ff;
}
@ -61,3 +72,62 @@ a {
text-decoration: none !important;
}
}
.shadow-xs {
box-shadow: 0px 0px 1px #171a1f12, 0px 0px 2px #171a1f1f; /* shadow-xs */
}
.table {
border-collapse: separate;
border-spacing: 0;
th {
background-color: #f8f9fa;
border-top: none;
border-bottom: 1px solid #dee2e6;
padding: 1rem 0.75rem;
font-weight: 600;
font-size: 0.875rem;
color: #495057;
cursor: pointer;
position: relative;
vertical-align: middle;
&:first-child {
border-top-left-radius: 0.5rem;
}
&:last-child {
border-top-right-radius: 0.5rem;
}
&:hover {
background-color: #f1f3f5;
}
}
td {
padding: 0.75rem;
vertical-align: middle;
border-bottom: 1px solid #f1f3f4;
font-size: 0.875rem;
}
tbody tr {
&:hover {
background-color: rgba(0, 0, 0, 0.02);
}
&.selected {
background-color: rgba(99, 102, 241, 0.05);
&:hover {
background-color: rgba(99, 102, 241, 0.1);
}
}
&:last-child td {
border-bottom: none;
}
}
}

View File

@ -10,7 +10,13 @@
.function-bar {
display: flex;
flex-flow: row wrap;
align-items: center;
gap: 1rem;
justify-content: flex-end;
float: right;
button {
display: flex;
align-items: center;
gap: 0.25rem;
}
}