[dev]add campaign

This commit is contained in:
susie-laptop 2025-05-26 11:43:09 -04:00
parent 0fa465a2c6
commit ce1b944103
17 changed files with 580 additions and 74 deletions

View File

@ -4,7 +4,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { fetchCampaigns } from '../store/slices/brandsSlice'; import { fetchCampaigns } from '../store/slices/brandsSlice';
import SpinningComponent from './Spinning'; import SpinningComponent from './Spinning';
import { addCreatorsToCampaign } from '../store/slices/creatorsSlice'; import { addCreatorsToCampaign } from '../store/slices/creatorsSlice';
import { setNotificationBarMessage } from '../store/slices/notificationBarSlice';
export default function AddToCampaign({ show, onHide }) { export default function AddToCampaign({ show, onHide }) {
const dispatch = useDispatch(); const dispatch = useDispatch();

View File

@ -47,6 +47,9 @@ export default function DatabaseFilter({ path, pageType = 'database' }) {
// //
const discreteValues = [0, 100, 1000, 10000, 100000, 250000, 500000]; const discreteValues = [0, 100, 1000, 10000, 100000, 250000, 500000];
const discretePricingValues = [0, 200, 400, 600, 800, 1000, 3000]; const discretePricingValues = [0, 200, 400, 600, 800, 1000, 3000];
const marks = ['0', '100', '1k', '10k', '100k', '250k', '500k+'];
const PricingMarks = ['0', '200', '400', '600', '800', '1000', '3000'];
// //
const findClosestDiscreteIndex = (value) => { const findClosestDiscreteIndex = (value) => {
let closestIndex = 0; let closestIndex = 0;
@ -319,8 +322,7 @@ export default function DatabaseFilter({ path, pageType = 'database' }) {
<h5 className='filter-title'>Views</h5> <h5 className='filter-title'>Views</h5>
<div className='filter-options filter-views filter-range-slider'> <div className='filter-options filter-views filter-range-slider'>
<RangeSlider <RangeSlider
min={0} discreteValues={discreteValues}
max={500000}
value={filters.views_range} value={filters.views_range}
onChange={handleViewsRangeChange} onChange={handleViewsRangeChange}
/> />
@ -359,8 +361,7 @@ export default function DatabaseFilter({ path, pageType = 'database' }) {
<h5 className='filter-title'>Pricing</h5> <h5 className='filter-title'>Pricing</h5>
<div className='filter-options filter-pricing filter-range-slider'> <div className='filter-options filter-pricing filter-range-slider'>
<RangeSlider <RangeSlider
min={0} discreteValues={discretePricingValues}
max={500000}
value={filters.pricing} value={filters.pricing}
onChange={handlePricingRangeChange} onChange={handlePricingRangeChange}
/> />

View File

@ -1,7 +1,8 @@
import { Check, CircleAlert, Info, X } from 'lucide-react'; import { Check, CircleAlert, Info, ShieldAlert, X } from 'lucide-react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { resetNotificationBar } from '../store/slices/notificationBarSlice'; import { resetNotificationBar } from '../store/slices/notificationBarSlice';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Alert } from 'react-bootstrap';
export default function NotificationBar() { export default function NotificationBar() {
const { message, type, show } = useSelector((state) => state.notificationBar); const { message, type, show } = useSelector((state) => state.notificationBar);
@ -24,18 +25,17 @@ export default function NotificationBar() {
return ( return (
show && ( show && (
<div <Alert
className={`snackbar alert alert-${type} d-flex align-items-center justify-content-between position-fixed top-5 start-50 translate-middle w-50 gap-2`}
role='alert' role='alert'
style={{ zIndex: 999999, top: '15%' }} variant={type === 'error' ? 'danger' : type}
style={{ zIndex: 999999, top: '10%', position: 'fixed', left: '50%', width: '60%', transform: 'translateX(-50%)', display: 'flex' }}
> >
{type === 'success' && <Check />} {type === 'success' && <Check />}
{type === 'warning' && <CircleAlert />} {type === 'warning' && <CircleAlert />}
{type === 'error' && <X />} {type === 'error' && <ShieldAlert />}
{type === 'info' && <Info />} {type === 'info' && <Info />}
<div className='flex-fill'>{message}</div> <div className='flex-fill'>{message}</div>
<button type='button' className='btn-close flex-end' onClick={handleClose} aria-label='Close'></button> </Alert>
</div>
) )
); );
} }

View File

@ -1,19 +1,15 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Table, Form } from 'react-bootstrap'; import { Table, Form } from 'react-bootstrap';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { useParams } from 'react-router-dom';
import '../styles/Products.scss'; import '../styles/Products.scss';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Spinning from './Spinning'; import Spinning from './Spinning';
export default function ProductsList({ onShowProductDetail }) { export default function ProductsList({ products, setSelectedProduct, onShowProductDetail }) {
const { brandId } = useParams();
const { selectedBrand } = useSelector((state) => state.brands); const { selectedBrand } = useSelector((state) => state.brands);
const [products, setProducts] = useState([]);
const [selectedProducts, setSelectedProducts] = useState([]);
const [sortField, setSortField] = useState(null); const [sortField, setSortField] = useState(null);
const [sortDirection, setSortDirection] = useState('asc'); const [sortDirection, setSortDirection] = useState('asc');
const [selectedProducts, setSelectedProducts] = useState([]);
const handleSort = (field) => { const handleSort = (field) => {
return; return;
@ -60,7 +56,7 @@ export default function ProductsList({ onShowProductDetail }) {
<th className='selector' style={{ width: '40px' }}> <th className='selector' style={{ width: '40px' }}>
<Form.Check <Form.Check
type='checkbox' type='checkbox'
checked={selectedProducts.length === selectedBrand?.products?.length && selectedBrand?.products?.length > 0} checked={selectedProducts.length === products?.length && products?.length > 0}
onChange={handleSelectAll} onChange={handleSelectAll}
/> />
</th> </th>
@ -94,14 +90,14 @@ export default function ProductsList({ onShowProductDetail }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{selectedBrand?.products?.length === 0 ? ( {products?.length === 0 ? (
<tr> <tr>
<td colSpan='10' className='text-center py-4'> <td colSpan='10' className='text-center py-4'>
No products found for this brand. No products found for this brand.
</td> </td>
</tr> </tr>
) : ( ) : (
selectedBrand?.products?.map((product) => ( products?.map((product) => (
<tr key={product.id} className={selectedProducts.includes(product.id) ? 'selected' : ''}> <tr key={product.id} className={selectedProducts.includes(product.id) ? 'selected' : ''}>
<td> <td>
<Form.Check <Form.Check
@ -112,7 +108,8 @@ export default function ProductsList({ onShowProductDetail }) {
</td> </td>
<td className='product-cell'> <td className='product-cell'>
<div className='d-flex align-items-center' onClick={() => onShowProductDetail(product)} style={{cursor: 'pointer'}}> <div className='d-flex align-items-center' onClick={() => onShowProductDetail(product)} style={{cursor: 'pointer'}}>
<div className='product-logo'>{product.name.slice(0, 1)}</div> <img className='product-logo' src={product.image_url} alt={product.name} />
{/* <div className='product-logo'>{product.name.slice(0, 1)}</div> */}
<div className='product-name'>{product.name}</div> <div className='product-name'>{product.name}</div>
</div> </div>
</td> </td>

View File

@ -2,9 +2,9 @@ import React, { useState, useEffect, useRef } from 'react';
import '../styles/RangeSlider.scss'; import '../styles/RangeSlider.scss';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
export default function RangeSlider({ min = 0, max = 100, value, onChange }) { export default function RangeSlider({ value, onChange, discreteValues }) {
// //
const discreteValues = [0, 100, 1000, 10000, 100000, 250000, 500000]; // const discreteValues = [0, 100, 1000, 10000, 100000, 250000, 500000];
const marks = ['0', '100', '1k', '10k', '100k', '250k', '500k+']; const marks = ['0', '100', '1k', '10k', '100k', '250k', '500k+'];
// 使 // 使
@ -170,8 +170,8 @@ export default function RangeSlider({ min = 0, max = 100, value, onChange }) {
</div> </div>
</div> </div>
<div className='range-slider-marks'> <div className='range-slider-marks'>
{marks.map((mark, index) => ( {discreteValues.map((mark, index) => (
<span key={index}>{mark}</span> <span key={index}>{formatValue(mark)}</span>
))} ))}
</div> </div>
{/* 显示当前选中的值 */} {/* 显示当前选中的值 */}

153
src/lib/constant.js Normal file
View File

@ -0,0 +1,153 @@
export const BRAND_SOURCES = [
{
value: 'TKS Official',
name: 'TKS Official',
},
{
value: 'Third-party Agency',
name: 'Third-party Agency',
},
{
value: 'Offline Event',
name: 'Offline Event',
},
{
value: 'Social Media',
name: 'Social Media',
},
];
export const CAMPAIGN_SERVICES = [
{
value: 'fufei',
name: '达人短视频(付费)',
},
{
value: 'chunyong',
name: '达人短视频(纯佣)',
},
{
value: 'dai',
name: '直播(代播)',
},
{
value: 'dabao',
name: '直播(达播)',
},
{
value: 'chun',
name: '纯素材短视频',
},
];
export const CREATOR_TYPES = [
{
value: 'dai',
name: '带货类达人',
},
{
value: 'exposure',
name: '曝光类达人',
},
];
export const GMV_RANGES = [
{
value: '0-5k',
name: '$0 - $5K',
},
{
value: '5k-25k',
name: '$5K - $25K',
},
{
value: '25k-60k',
name: '$25K - $60K',
},
{
value: '60k-150k',
name: '$60K - $150K',
},
{
value: '150k-400k',
name: '$150K - $400K',
},
{
value: '400k-1500k',
name: '$400K - $1500K',
},
{
value: '1500k+',
name: '$1500K+',
},
];
export const CREATOR_LEVELS = [
{
value: 'L1',
name: 'L1',
},
{
value: 'L2',
name: 'L2',
},
{
value: 'L3',
name: 'L3',
},
{
value: 'L4',
name: 'L4',
},
{
value: 'L5',
name: 'L5',
},
{
value: 'L6',
name: 'L6',
},
{
value: 'L7',
name: 'L7',
},
];
export const CREATOR_CATEGORIES = [
{
value: 'Phones & Electronics',
name: 'Phones & Electronics',
},
{
value: 'Womenswear & Underwear',
name: 'Womenswear & Underwear',
},
{
value: 'Sports & Outdoor',
name: 'Sports & Outdoor',
},
{
value: 'Food & Beverage',
name: 'Food & Beverage',
},
{
value: 'Health',
name: 'Health',
},
{
value: 'Kitchenware',
name: 'Kitchenware',
},
{
value: 'Household Appliances',
name: 'Household Appliances',
},
{
value: 'Womensware & Underwear',
name: 'Womensware & Underwear',
},
{
value: 'Other',
name: 'Other',
},
];

View File

@ -8,6 +8,7 @@ import { Plus } from 'lucide-react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createBrandThunk, fetchBrands, selectBrand } from '../store/slices/brandsSlice'; import { createBrandThunk, fetchBrands, selectBrand } from '../store/slices/brandsSlice';
import SpinningComponent from '../components/Spinning'; import SpinningComponent from '../components/Spinning';
import { BRAND_SOURCES } from '../lib/constant';
export default function Brands() { export default function Brands() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -48,13 +49,6 @@ function AddBrandModal({ show, onHide }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { status, error } = useSelector((state) => state.brands); const { status, error } = useSelector((state) => state.brands);
const sourceOptions = [
{ value: '1', label: 'TKS Official' },
{ value: '2', label: 'Third Party Agency' },
{ value: '3', label: 'Official Event' },
{ value: '4', label: 'Social Media' },
];
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
const form = document.getElementById('brandForm'); const form = document.getElementById('brandForm');
if (form.checkValidity() === false) { if (form.checkValidity() === false) {
@ -112,7 +106,7 @@ function AddBrandModal({ show, onHide }) {
<Form.Group className='mb-3' controlId='formBasicSource'> <Form.Group className='mb-3' controlId='formBasicSource'>
<Form.Label>Source</Form.Label> <Form.Label>Source</Form.Label>
<Form.Select value={brandSource} onChange={(e) => setBrandSource(e.target.value)} required> <Form.Select value={brandSource} onChange={(e) => setBrandSource(e.target.value)} required>
{sourceOptions.map((option) => ( {BRAND_SOURCES.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
</option> </option>

View File

@ -1,14 +1,23 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import SearchBar from '../components/SearchBar'; import SearchBar from '../components/SearchBar';
import { Button } from 'react-bootstrap'; import { Alert, Button, Form, Modal } from 'react-bootstrap';
import { Folders, Hash, LinkIcon, Plus, Users } from 'lucide-react'; import { Folders, Hash, LinkIcon, Plus, Users } from 'lucide-react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import CampaignList from '../components/CampaignList'; import CampaignList from '../components/CampaignList';
import ProductsList from '../components/ProductsList'; import ProductsList from '../components/ProductsList';
import { fetchBrandDetail, fetchBrandCampaigns, fetchBrandProducts, setSelectedProduct } from '../store/slices/brandsSlice'; import {
fetchBrandDetail,
fetchBrandCampaigns,
fetchBrandProducts,
setSelectedProduct,
createCampaignThunk,
} from '../store/slices/brandsSlice';
import SlidePanel from '../components/SlidePanel'; import SlidePanel from '../components/SlidePanel';
import ProductDetail from '../components/ProductDetail'; import ProductDetail from '../components/ProductDetail';
import { CAMPAIGN_SERVICES, CREATOR_CATEGORIES, CREATOR_LEVELS, CREATOR_TYPES, GMV_RANGES } from '../lib/constant';
import RangeSlider from '../components/RangeSlider';
import SpinningComponent from '../components/Spinning';
export default function BrandsDetail() { export default function BrandsDetail() {
const { id } = useParams(); const { id } = useParams();
@ -16,6 +25,7 @@ export default function BrandsDetail() {
const [activeTab, setActiveTab] = useState('campaigns'); const [activeTab, setActiveTab] = useState('campaigns');
const { selectedBrand } = useSelector((state) => state.brands); const { selectedBrand } = useSelector((state) => state.brands);
const [showProductDetail, setShowProductDetail] = useState(false); const [showProductDetail, setShowProductDetail] = useState(false);
const [showAddCampaignModal, setShowAddCampaignModal] = useState(false);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
@ -30,14 +40,13 @@ export default function BrandsDetail() {
setShowProductDetail(true); setShowProductDetail(true);
}; };
return ( return (
selectedBrand?.id && ( selectedBrand?.id && (
<React.Fragment> <React.Fragment>
<div className='function-bar'> <div className='function-bar'>
<SearchBar /> <SearchBar />
{activeTab === 'campaigns' && ( {activeTab === 'campaigns' && (
<Button> <Button onClick={() => setShowAddCampaignModal(true)}>
<Plus /> <Plus />
Add Campaign Add Campaign
</Button> </Button>
@ -125,7 +134,10 @@ export default function BrandsDetail() {
{activeTab === 'campaigns' && <CampaignList />} {activeTab === 'campaigns' && <CampaignList />}
{activeTab === 'products' && ( {activeTab === 'products' && (
<> <>
<ProductsList onShowProductDetail={handleShowProductDetail} /> <ProductsList
products={selectedBrand?.products}
onShowProductDetail={handleShowProductDetail}
/>
<SlidePanel <SlidePanel
show={showProductDetail} show={showProductDetail}
onClose={() => setShowProductDetail(false)} onClose={() => setShowProductDetail(false)}
@ -136,7 +148,223 @@ export default function BrandsDetail() {
</SlidePanel> </SlidePanel>
</> </>
)} )}
<AddCampaignModal show={showAddCampaignModal} onHide={() => setShowAddCampaignModal(false)} />
</React.Fragment> </React.Fragment>
) )
); );
} }
export function AddCampaignModal({ show, onHide }) {
const dispatch = useDispatch();
const [formData, setFormData] = useState({
name: '',
description: '',
service: '',
link_product: '',
creator_type: '',
creator_level: '',
creator_count: 0,
budget: [0, 200],
creator_category: '',
followers: [0, 100],
gmv: '',
views: [0, 100000],
});
const [validated, setValidated] = useState(false);
const budgetDiscreteValues = [0, 200, 400, 600, 800, 1000, 3000];
const followersDiscreteValues = [0, 100, 1000, 10000, 250000, 500000];
const viewsDiscreteValues = [0, 50000, 100000, 300000, 500000, 1000000, 5000000];
const [errors, setErrors] = useState({});
const { status } = useSelector((state) => state.brands);
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = (e) => {
e.preventDefault();
const form = document.getElementById('addCampaignForm');
if (form.checkValidity() === false) {
e.preventDefault();
e.stopPropagation();
setValidated(true);
return;
}
console.log(formData);
dispatch(createCampaignThunk(formData));
};
const handleViewsChange = (views) => {
console.log(views);
};
const handleClose = () => {
setFormData({
name: '',
description: '',
service: '',
link_product: '',
creator_type: '',
creator_level: '',
creator_count: 0,
budget: [200, 600],
creator_category: '',
followers: [0, 100],
gmv: '',
views: [0, 100000],
});
setValidated(false);
onHide();
};
return (
<Modal show={show} onHide={handleClose} >
<Modal.Header>
<Modal.Title>Add Campaign</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form id='addCampaignForm' noValidate validated={validated} onSubmit={handleSubmit}>
<Form.Group>
<Form.Label>Campaign Name *</Form.Label>
<Form.Control required type='text' name='name' value={formData.name} onChange={handleChange} />
<Form.Control.Feedback type='invalid'>Please enter campaign name.</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Label>Campaign Description *</Form.Label>
<Form.Control
required
type='text'
name='description'
isInvalid={!!errors.description}
value={formData.description}
onChange={handleChange}
/>
<Form.Control.Feedback type='invalid'>Please enter campaign description.</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Label>Service *</Form.Label>
<Form.Select required name='service' value={formData.service} onChange={handleChange}>
<option value='' disabled>
Select Service
</option>
{CAMPAIGN_SERVICES.map((service) => (
<option key={service.value} value={service.value}>
{service.name}
</option>
))}
</Form.Select>
<Form.Control.Feedback type='invalid'>Please select a service.</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Label>Link Product *</Form.Label>
<Form.Control
type='text'
name='link_product'
value={formData.link_product}
onChange={handleChange}
/>
</Form.Group>
<Form.Group>
<Form.Label>Creator Type *</Form.Label>
<Form.Select required name='creator_type' value={formData.creator_type} onChange={handleChange}>
<option value='' disabled>
Select Creator Type
</option>
{CREATOR_TYPES.map((creatorType) => (
<option key={creatorType.value} value={creatorType.value}>
{creatorType.name}
</option>
))}
</Form.Select>
<Form.Control.Feedback type='invalid'>Please select a creator type.</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Label>Creator Level *</Form.Label>
<Form.Select
required
name='creator_level'
value={formData.creator_level}
onChange={handleChange}
>
<option value='' disabled>
Select Creator Level
</option>
{CREATOR_LEVELS.map((creatorLevel) => (
<option key={creatorLevel.value} value={creatorLevel.value}>
{creatorLevel.name}
</option>
))}
</Form.Select>
</Form.Group>
<Form.Group>
<Form.Label># Creators *</Form.Label>
<Form.Control
required
type='number'
name='creator_count'
value={formData.creator_count}
onChange={handleChange}
/>
<Form.Control.Feedback type='invalid'>
Please enter the number of creators.
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Label>Budget *</Form.Label>
<RangeSlider
discreteValues={budgetDiscreteValues}
value={formData.budget}
onChange={handleChange}
/>
</Form.Group>
<Form.Group>
<Form.Label>Creator Category</Form.Label>
<Form.Select name='creator_category' value={formData.creator_category} onChange={handleChange}>
<option value=''>Select Creator Category</option>
{CREATOR_CATEGORIES.map((creatorCategory) => (
<option key={creatorCategory.value} value={creatorCategory.value}>
{creatorCategory.name}
</option>
))}
</Form.Select>
</Form.Group>
<Form.Group>
<Form.Label>Followers</Form.Label>
<RangeSlider
discreteValues={followersDiscreteValues}
value={formData.followers}
onChange={handleChange}
/>
</Form.Group>
<Form.Group>
<Form.Label>GMV</Form.Label>
<Form.Select name='gmv' value={formData.gmv} onChange={handleChange}>
<option value=''>Select GMV</option>
{GMV_RANGES.map((gmv) => (
<option key={gmv.value} value={gmv.value}>
{gmv.name}
</option>
))}
</Form.Select>
</Form.Group>
<Form.Group>
<Form.Label>Avg. Video Views</Form.Label>
<RangeSlider
discreteValues={viewsDiscreteValues}
value={formData.views}
onChange={handleViewsChange}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant='outline-primary' className='border-0' onClick={handleClose}>
Cancel
</Button>
<Button variant='primary' type='submit' form='addCampaignForm'>
Add Campaign
</Button>
</Modal.Footer>
</Modal>
);
}

View File

@ -9,6 +9,7 @@ import { ChevronRight, Send, Plus } from 'lucide-react';
import ProductsList from '../components/ProductsList'; import ProductsList from '../components/ProductsList';
import SlidePanel from '../components/SlidePanel'; import SlidePanel from '../components/SlidePanel';
import CampaignScript from './CampaignScript'; import CampaignScript from './CampaignScript';
import { addProductToCampaign, fetchProducts } from '../store/slices/productSlice';
export default function CampaignDetail() { export default function CampaignDetail() {
const { brandId, campaignId } = useParams(); const { brandId, campaignId } = useParams();
@ -29,6 +30,7 @@ export default function CampaignDetail() {
dispatch(fetchCampaignDetail(campaignId)); dispatch(fetchCampaignDetail(campaignId));
} }
} }
dispatch(fetchProducts());
}, [dispatch, brandId, campaignId]); }, [dispatch, brandId, campaignId]);
const handleShowProductDetail = (product) => { const handleShowProductDetail = (product) => {
@ -132,7 +134,7 @@ export default function CampaignDetail() {
<Plus size={18} /> <Plus size={18} />
Add Product Add Product
</Button> </Button>
<ProductsList onShowProductDetail={handleShowProductDetail} /> <ProductsList products={selectedCampaign?.link_product_details} onShowProductDetail={handleShowProductDetail} />
<SlidePanel <SlidePanel
show={showProductDetail} show={showProductDetail}
onClose={() => setShowProductDetail(false)} onClose={() => setShowProductDetail(false)}
@ -141,7 +143,7 @@ export default function CampaignDetail() {
> >
<CampaignScript /> <CampaignScript />
</SlidePanel> </SlidePanel>
<AddProductModal show={showAddProductModal} onHide={() => setShowAddProductModal(false)} /> <AddProductModal campaignId={campaignId} show={showAddProductModal} onHide={() => setShowAddProductModal(false)} />
</> </>
)} )}
</div> </div>
@ -149,9 +151,26 @@ export default function CampaignDetail() {
); );
} }
function AddProductModal({ show, onHide }) { function AddProductModal({ campaignId, show, onHide }) {
const [selectedProduct, setSelectedProduct] = useState(null);
const { products } = useSelector((state) => state.products);
const dispatch = useDispatch();
const handleCancel = () => {
onHide();
setSelectedProduct(null);
};
const handleSubmit = async () => {
if (!selectedProduct) return;
console.log(selectedProduct);
await dispatch(addProductToCampaign({ campaignId, productId: selectedProduct })).unwrap();
dispatch(fetchCampaignDetail(campaignId));
handleCancel();
};
return ( return (
<Modal show={show} onHide={onHide}> <Modal show={show} onHide={handleCancel}>
<Modal.Header closeButton className='fw-bold'> <Modal.Header closeButton className='fw-bold'>
Add Product Add Product
</Modal.Header> </Modal.Header>
@ -159,18 +178,26 @@ function AddProductModal({ show, onHide }) {
<Form className='add-product-form'> <Form className='add-product-form'>
<Form.Group className='mb-3'> <Form.Group className='mb-3'>
<Form.Label>Product PID</Form.Label> <Form.Label>Product PID</Form.Label>
<Form.Select aria-label='Default select example'> <Form.Select
aria-label='Default select example'
value={selectedProduct}
onChange={(e) => setSelectedProduct(e.target.value)}
>
<option>Select</option> <option>Select</option>
<option value='1'>One</option> {products?.length > 0 && products.map((product) => (
<option value='2'>Two</option> <option key={product.id} value={product.id}>
<option value='3'>Three</option> {product.name}
</option>
))}
</Form.Select> </Form.Select>
</Form.Group> </Form.Group>
<div className='button-group'> <div className='button-group'>
<Button variant='outline-light' className='text-primary'> <Button variant='outline-light' className='text-primary' onClick={handleCancel}>
Cancel Cancel
</Button> </Button>
<Button variant='primary'>Create</Button> <Button variant='primary' onClick={handleSubmit}>
Create
</Button>
</div> </div>
</Form> </Form>
</Modal.Body> </Modal.Body>

View File

@ -5,6 +5,7 @@ import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { addTemplateApi, editTemplateApi, fetchTemplates } from '../store/slices/inboxSlice'; import { addTemplateApi, editTemplateApi, fetchTemplates } from '../store/slices/inboxSlice';
import TemplateList from '../components/TemplateList'; import TemplateList from '../components/TemplateList';
import { CAMPAIGN_SERVICES } from '../lib/constant';
export default function InboxTemplate() { export default function InboxTemplate() {
const [activeTab, setActiveTab] = useState('all'); const [activeTab, setActiveTab] = useState('all');
@ -168,11 +169,9 @@ function AddTemplateModal({ show, formData, setFormData, handleClose, type = 'ad
<option value='' disabled> <option value='' disabled>
Select service Select service
</option> </option>
<option value='fufei'>达人短视频付费</option> {CAMPAIGN_SERVICES.map((service) => (
<option value='chunyong'>达人短视频纯佣</option> <option value={service.value}>{service.name}</option>
<option value='dai'>直播代播</option> ))}
<option value='dabao'>直播达播</option>
<option value='chun'>纯素材短视频</option>
</Form.Select> </Form.Select>
</Form.Group> </Form.Group>
<Form.Group className='mb-3' controlId='formBasicEmail'> <Form.Group className='mb-3' controlId='formBasicEmail'>

View File

@ -8,6 +8,7 @@ import inboxReducer from './slices/inboxSlice';
import authReducer from './slices/authSlice'; import authReducer from './slices/authSlice';
import discoveryReducer from './slices/discoverySlice'; import discoveryReducer from './slices/discoverySlice';
import notificationBarReducer from './slices/notificationBarSlice'; import notificationBarReducer from './slices/notificationBarSlice';
import productReducer from './slices/productSlice';
const authPersistConfig = { const authPersistConfig = {
key: 'auth', key: 'auth',
@ -22,6 +23,7 @@ const rootReducer = combineReducers({
discovery: discoveryReducer, discovery: discoveryReducer,
auth: persistReducer(authPersistConfig, authReducer), auth: persistReducer(authPersistConfig, authReducer),
notificationBar: notificationBarReducer, notificationBar: notificationBarReducer,
products: productReducer,
}); });
const store = configureStore({ const store = configureStore({

View File

@ -1,5 +1,6 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import api from '@/services/api'; import api from '@/services/api';
import { setNotificationBarMessage } from './notificationBarSlice';
const mockProducts = [ const mockProducts = [
{ {
id: 1, id: 1,
@ -191,6 +192,20 @@ export const createBrandThunk = createAsyncThunk('brands/createBrand', async (br
} }
}); });
export const createCampaignThunk = createAsyncThunk('brands/createCampaign', async (campaign, { rejectWithValue, dispatch }) => {
try {
const response = await api.post('/campaigns/', campaign);
console.log(response);
if (response.code !== 201 && response.code !== 200) {
throw new Error(response.message);
}
} catch (error) {
console.log(error);
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
return rejectWithValue(error.message);
}
});
const initialState = { const initialState = {
brands: [], brands: [],
campaigns: [], campaigns: [],
@ -299,6 +314,16 @@ const brandsSlice = createSlice({
.addCase(fetchCampaignDetail.rejected, (state, action) => { .addCase(fetchCampaignDetail.rejected, (state, action) => {
state.status = 'failed'; state.status = 'failed';
state.error = action.error.message; state.error = action.error.message;
})
.addCase(createCampaignThunk.pending, (state) => {
state.status = 'loading';
})
.addCase(createCampaignThunk.fulfilled, (state, action) => {
state.status = 'succeeded';
})
.addCase(createCampaignThunk.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
}); });
}, },
}); });

View File

@ -0,0 +1,69 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import api from '@/services/api';
import { setNotificationBarMessage } from './notificationBarSlice';
export const fetchProducts = createAsyncThunk('products/fetchProducts', async (_, { rejectWithValue, dispatch }) => {
try {
const response = await api.get('/products/');
if (response.code !== 200) {
throw new Error(response.message);
}
return response.data;
} catch (error) {
return rejectWithValue(error.message);
}
});
export const addProductToCampaign = createAsyncThunk('products/addProductToCampaign', async (formData, { rejectWithValue, dispatch }) => {
try {
const { campaignId, productId } = formData;
const response = await api.post(`/campaigns/${campaignId}/add_product/`, { product_id:productId });
if (response.code !== 201 && response.code !== 200) {
throw new Error(response.message);
}
console.log(response);
return response.data;
} catch (error) {
dispatch(setNotificationBarMessage({ message: error.message, type: 'error' }));
return rejectWithValue(error.message);
}
});
const initialState = {
products: [],
loading: false,
error: null,
};
const productSlice = createSlice({
name: 'products',
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchProducts.pending, (state) => {
state.loading = true;
});
builder.addCase(fetchProducts.fulfilled, (state, action) => {
state.products = action.payload;
state.loading = false;
})
builder.addCase(fetchProducts.rejected, (state, action) => {
state.error = action.payload;
state.loading = false;
})
builder.addCase(addProductToCampaign.pending, (state) => {
state.loading = true;
})
builder.addCase(addProductToCampaign.fulfilled, (state, action) => {
state.products = action.payload;
state.loading = false;
})
builder.addCase(addProductToCampaign.rejected, (state, action) => {
state.error = action.payload;
state.loading = false;
})
},
});
export default productSlice.reducer;

View File

@ -217,7 +217,7 @@
.campaign-title { .campaign-title {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 800; font-weight: 800;
margin-bottom: .5rem; margin-bottom: 0.5rem;
color: $primary; color: $primary;
cursor: pointer; cursor: pointer;
} }
@ -268,8 +268,6 @@
flex-flow: row wrap; flex-flow: row wrap;
gap: 1rem; gap: 1rem;
justify-content: space-between; justify-content: space-between;
height: 100%;
overflow-y: auto;
.campaign-detail-info { .campaign-detail-info {
width: 100%; width: 100%;
@ -290,7 +288,6 @@
color: $primary; color: $primary;
} }
.campaign-descp { .campaign-descp {
} }
.campaign-edit { .campaign-edit {
position: absolute; position: absolute;
@ -328,7 +325,6 @@
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
} }
} }
} }
} }
@ -375,7 +371,7 @@
gap: 0.375rem; gap: 0.375rem;
flex: 1; flex: 1;
border-bottom: 4px solid transparent; border-bottom: 4px solid transparent;
padding: .375rem 0; padding: 0.375rem 0;
.campaign-progress-item-index { .campaign-progress-item-index {
width: 1.75rem; width: 1.75rem;
@ -402,3 +398,16 @@
} }
} }
} }
#addCampaignForm {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
div {
flex: 1;
.range-slider {
width: 90%;
}
}
}

View File

@ -147,8 +147,6 @@
.table-container { .table-container {
position: relative; position: relative;
max-height: calc(100% - 455px); // Adjust this value based on your layout
overflow-y: auto;
.sticky-header { .sticky-header {
position: sticky; position: sticky;

View File

@ -18,7 +18,7 @@ select:focus {
box-shadow: 0 0 0 0.2rem rgba(99, 102, 241, 0.25) !important; box-shadow: 0 0 0 0.2rem rgba(99, 102, 241, 0.25) !important;
outline: none; outline: none;
} }
input:focus { .form-control:valid:focus {
box-shadow: 0 0 0 0.2rem rgba(99, 102, 241, 0.25) !important; box-shadow: 0 0 0 0.2rem rgba(99, 102, 241, 0.25) !important;
outline: none; outline: none;
} }
@ -134,3 +134,8 @@ a {
box-shadow: 0px 0px 1px #171a1f12, 0px 0px 2px #171a1f1f; /* shadow-xs */ box-shadow: 0px 0px 1px #171a1f12, 0px 0px 2px #171a1f1f; /* shadow-xs */
padding: 1rem; padding: 1rem;
} }
.modal-content {
width: max-content;
max-width: 60vw;
}

View File

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