add footer component
This commit is contained in:
parent
0440f95abf
commit
f19c6a27d5
18 changed files with 926 additions and 9 deletions
|
|
@ -1,8 +1,15 @@
|
|||
from django.urls import path
|
||||
|
||||
from .views import hello, ProductListView
|
||||
from reviews.views import ReviewListView, LatestReviewsView, NewsletterCreateView
|
||||
|
||||
urlpatterns = [
|
||||
path("hello/", hello),
|
||||
path("products/", ProductListView.as_view()),
|
||||
|
||||
# Reviews
|
||||
path("reviews/", ReviewListView.as_view()),
|
||||
path("reviews/latest/", LatestReviewsView.as_view()),
|
||||
|
||||
# Newsletter
|
||||
path("newsletter/", NewsletterCreateView.as_view()),
|
||||
]
|
||||
|
|
@ -38,10 +38,11 @@ INSTALLED_APPS = [
|
|||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
# =============================
|
||||
"corsheaders",
|
||||
"rest_framework",
|
||||
"api",
|
||||
"dressstyle",
|
||||
'corsheaders',
|
||||
'rest_framework',
|
||||
'api',
|
||||
'dressstyle',
|
||||
'reviews',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
|
|
|||
|
|
@ -4,14 +4,16 @@ from django.contrib import admin
|
|||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
path("admin/", admin.site.urls),
|
||||
|
||||
|
||||
path("api/", include("api.urls")),
|
||||
|
||||
|
||||
path("api/dress-styles/", include("dressstyle.urls")),
|
||||
|
||||
|
||||
# Добавляем reviews
|
||||
path("api/reviews/", include("reviews.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(
|
||||
settings.MEDIA_URL,
|
||||
|
|
|
|||
311
frontend/src/components/Footer/Footer.module.css
Normal file
311
frontend/src/components/Footer/Footer.module.css
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
.footer {
|
||||
background-color: #F0F0F0;
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
/* Newsletter секция */
|
||||
.newsletterSection {
|
||||
padding: 0 25px;
|
||||
margin-top: -60px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.newsletterContainer {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background-color: #000;
|
||||
border-radius: 20px;
|
||||
padding: 50px 60px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.newsletterContent {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.newsletterTitle {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.newsletterForm {
|
||||
flex: 0 0 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.emailIcon {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.emailInput {
|
||||
width: 100%;
|
||||
padding: 16px 20px 16px 52px;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.emailInput:focus {
|
||||
box-shadow: 0 0 0 3px rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.emailInput::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.subscribeButton {
|
||||
width: 100%;
|
||||
padding: 16px 32px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.subscribeButton:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.subscribeButton:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #10B981;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #EF4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Основной footer */
|
||||
.mainFooter {
|
||||
padding: 80px 25px 40px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 60px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: 0 0 280px;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #000;
|
||||
margin: 0 0 16px 0;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.brandDescription {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.socialLinks {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.socialLink {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.socialLink:hover {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.linksGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 40px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.linksColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.columnTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.linksList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid #D0D0D0;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.paymentMethods {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.paymentIcon {
|
||||
padding: 6px 12px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
border: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 1024px) {
|
||||
.newsletterContainer {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.newsletterForm {
|
||||
width: 100%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.linksGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.newsletterSection {
|
||||
margin-top: -40px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.newsletterTitle {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.mainFooter {
|
||||
padding: 60px 15px 30px;
|
||||
}
|
||||
|
||||
.linksGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.paymentMethods {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
221
frontend/src/components/Footer/Footer.tsx
Normal file
221
frontend/src/components/Footer/Footer.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { useState } from 'react'
|
||||
import styles from './Footer.module.css'
|
||||
|
||||
const footerLinks = {
|
||||
company: [
|
||||
{ name: 'About', href: '#' },
|
||||
{ name: 'Features', href: '#' },
|
||||
{ name: 'Works', href: '#' },
|
||||
{ name: 'Career', href: '#' },
|
||||
],
|
||||
help: [
|
||||
{ name: 'Customer Support', href: '#' },
|
||||
{ name: 'Delivery Details', href: '#' },
|
||||
{ name: 'Terms & Conditions', href: '#' },
|
||||
{ name: 'Privacy Policy', href: '#' },
|
||||
],
|
||||
faq: [
|
||||
{ name: 'Account', href: '#' },
|
||||
{ name: 'Manage Deliveries', href: '#' },
|
||||
{ name: 'Orders', href: '#' },
|
||||
{ name: 'Payments', href: '#' },
|
||||
],
|
||||
resources: [
|
||||
{ name: 'Free eBooks', href: '#' },
|
||||
{ name: 'Development Tutorial', href: '#' },
|
||||
{ name: 'How to - Blog', href: '#' },
|
||||
{ name: 'Youtube Playlist', href: '#' },
|
||||
],
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!email) {
|
||||
setStatus('error')
|
||||
setMessage('Please enter your email')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('loading')
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/reviews/newsletter/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
setStatus('success')
|
||||
setMessage(data.message)
|
||||
setEmail('')
|
||||
setTimeout(() => setStatus('idle'), 3000)
|
||||
} else {
|
||||
setStatus('error')
|
||||
setMessage(data.error || data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus('error')
|
||||
setMessage('Something went wrong. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
{/* Newsletter секция */}
|
||||
<div className={styles.newsletterSection}>
|
||||
<div className={styles.newsletterContainer}>
|
||||
<div className={styles.newsletterContent}>
|
||||
<h2 className={styles.newsletterTitle}>
|
||||
STAY UPTO DATE ABOUT<br />OUR LATEST OFFERS
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.newsletterForm}>
|
||||
<div className={styles.inputWrapper}>
|
||||
<svg className={styles.emailIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||
<polyline points="22,6 12,13 2,6" />
|
||||
</svg>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter your email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={styles.emailInput}
|
||||
disabled={status === 'loading' || status === 'success'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.subscribeButton}
|
||||
disabled={status === 'loading' || status === 'success'}
|
||||
>
|
||||
{status === 'loading' ? 'Subscribing...' : 'Subscribe to Newsletter'}
|
||||
</button>
|
||||
|
||||
{message && (
|
||||
<div className={`${styles.message} ${status === 'success' ? styles.success : styles.error}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной footer */}
|
||||
<div className={styles.mainFooter}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.mainContent}>
|
||||
{/* Бренд */}
|
||||
<div className={styles.brand}>
|
||||
<h3 className={styles.brandName}>SHOP.CO</h3>
|
||||
<p className={styles.brandDescription}>
|
||||
We have clothes that suits your style and which you're proud to wear. From women to men.
|
||||
</p>
|
||||
|
||||
{/* Социальные иконки */}
|
||||
<div className={styles.socialLinks}>
|
||||
<a href="#" className={styles.socialLink} aria-label="Twitter">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" className={styles.socialLink} aria-label="Facebook">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" className={styles.socialLink} aria-label="Instagram">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" className={styles.socialLink} aria-label="GitHub">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Колонки ссылок */}
|
||||
<div className={styles.linksGrid}>
|
||||
<div className={styles.linksColumn}>
|
||||
<h4 className={styles.columnTitle}>COMPANY</h4>
|
||||
<ul className={styles.linksList}>
|
||||
{footerLinks.company.map((link) => (
|
||||
<li key={link.name}>
|
||||
<a href={link.href} className={styles.link}>{link.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.linksColumn}>
|
||||
<h4 className={styles.columnTitle}>HELP</h4>
|
||||
<ul className={styles.linksList}>
|
||||
{footerLinks.help.map((link) => (
|
||||
<li key={link.name}>
|
||||
<a href={link.href} className={styles.link}>{link.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.linksColumn}>
|
||||
<h4 className={styles.columnTitle}>FAQ</h4>
|
||||
<ul className={styles.linksList}>
|
||||
{footerLinks.faq.map((link) => (
|
||||
<li key={link.name}>
|
||||
<a href={link.href} className={styles.link}>{link.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.linksColumn}>
|
||||
<h4 className={styles.columnTitle}>RESOURCES</h4>
|
||||
<ul className={styles.linksList}>
|
||||
{footerLinks.resources.map((link) => (
|
||||
<li key={link.name}>
|
||||
<a href={link.href} className={styles.link}>{link.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Нижняя часть */}
|
||||
<div className={styles.bottomBar}>
|
||||
<p className={styles.copyright}>
|
||||
Shop.co © 2000-{currentYear}, All Rights Reserved
|
||||
</p>
|
||||
|
||||
<div className={styles.paymentMethods}>
|
||||
<div className={styles.paymentIcon}>VISA</div>
|
||||
<div className={styles.paymentIcon}>Mastercard</div>
|
||||
<div className={styles.paymentIcon}>PayPal</div>
|
||||
<div className={styles.paymentIcon}>Apple Pay</div>
|
||||
<div className={styles.paymentIcon}>Google Pay</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
133
frontend/src/components/Reviews/Reviews.module.css
Normal file
133
frontend/src/components/Reviews/Reviews.module.css
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
.section {
|
||||
padding: 80px 25px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
color: #000;
|
||||
letter-spacing: -1px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.navButton {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 2px solid #000;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.navButton:hover {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.reviewsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.reviewCard {
|
||||
background: #fff;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.reviewCard:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stars {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.starFilled {
|
||||
color: #FFC107;
|
||||
}
|
||||
|
||||
.starEmpty {
|
||||
color: #E0E0E0;
|
||||
}
|
||||
|
||||
.customer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.customerName {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.verifiedBadge {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #10B981;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.comment {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 80px;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 968px) {
|
||||
.reviewsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.title {
|
||||
font-size: 32px;
|
||||
}
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
81
frontend/src/components/Reviews/Reviews.tsx
Normal file
81
frontend/src/components/Reviews/Reviews.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import styles from './Reviews.module.css'
|
||||
|
||||
interface Review {
|
||||
id: number
|
||||
customer_name: string
|
||||
rating: number
|
||||
comment: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
function Reviews() {
|
||||
const [reviews, setReviews] = useState<Review[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:8000/api/reviews/latest/')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setReviews(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error fetching reviews:', err)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const nextReview = () => {
|
||||
setCurrentIndex((prev) =>
|
||||
prev + 3 >= reviews.length ? 0 : prev + 1
|
||||
)
|
||||
}
|
||||
|
||||
const prevReview = () => {
|
||||
setCurrentIndex((prev) =>
|
||||
prev - 1 < 0 ? Math.max(0, reviews.length - 3) : prev - 1
|
||||
)
|
||||
}
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
return Array.from({ length: 5 }, (_, i) => (
|
||||
<span key={i} className={i < rating ? styles.starFilled : styles.starEmpty}>
|
||||
★
|
||||
</span>
|
||||
))
|
||||
}
|
||||
|
||||
if (loading) return <div className={styles.loading}>Loading reviews...</div>
|
||||
if (reviews.length === 0) return <div className={styles.empty}>No reviews yet</div>
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>OUR HAPPY CUSTOMERS</h2>
|
||||
<div className={styles.navigation}>
|
||||
<button onClick={prevReview} className={styles.navButton}>←</button>
|
||||
<button onClick={nextReview} className={styles.navButton}>→</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.reviewsGrid}>
|
||||
{reviews.slice(currentIndex, currentIndex + 3).map((review) => (
|
||||
<div key={review.id} className={styles.reviewCard}>
|
||||
<div className={styles.stars}>
|
||||
{renderStars(review.rating)}
|
||||
</div>
|
||||
<div className={styles.customer}>
|
||||
<h3 className={styles.customerName}>{review.customer_name}</h3>
|
||||
{review.verified && <span className={styles.verifiedBadge}>✓</span>}
|
||||
</div>
|
||||
<p className={styles.comment}>"{review.comment}"</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Reviews
|
||||
|
|
@ -5,6 +5,8 @@ import Brends from '../../components/Brends/Brends'
|
|||
import Arrivals from '../../components/NewArrivals/NewArrivals'
|
||||
import TopSelling from '../../components/TopSelling/TopSelling'
|
||||
import DressStyle from '../../components/DressStyle/DressStyle'
|
||||
import Reviews from '../../components/Reviews/Reviews'
|
||||
import Footer from '../../components/Footer/Footer'
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
|
|
@ -22,6 +24,10 @@ function Home() {
|
|||
<TopSelling />
|
||||
|
||||
<DressStyle />
|
||||
|
||||
<Reviews />
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
0
reviews/__init__.py
Normal file
0
reviews/__init__.py
Normal file
18
reviews/admin.py
Normal file
18
reviews/admin.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from django.contrib import admin
|
||||
from .models import Review, NewsletterSubscription
|
||||
|
||||
|
||||
@admin.register(Review)
|
||||
class ReviewAdmin(admin.ModelAdmin):
|
||||
list_display = ("customer_name", "rating", "verified", "created_at")
|
||||
list_filter = ("verified", "rating", "created_at")
|
||||
search_fields = ("customer_name", "comment")
|
||||
list_editable = ("verified", "rating")
|
||||
|
||||
|
||||
@admin.register(NewsletterSubscription)
|
||||
class NewsletterSubscriptionAdmin(admin.ModelAdmin):
|
||||
list_display = ("email", "subscribed_at", "is_active")
|
||||
list_filter = ("is_active", "subscribed_at")
|
||||
search_fields = ("email",)
|
||||
readonly_fields = ("subscribed_at",)
|
||||
5
reviews/apps.py
Normal file
5
reviews/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReviewsConfig(AppConfig):
|
||||
name = 'reviews'
|
||||
37
reviews/migrations/0001_initial.py
Normal file
37
reviews/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 6.1.1 on 2026-09-19 04:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NewsletterSubscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email', models.EmailField(max_length=254, unique=True)),
|
||||
('subscribed_at', models.DateTimeField(auto_now_add=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Review',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('customer_name', models.CharField(max_length=100)),
|
||||
('rating', models.IntegerField(choices=[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5)])),
|
||||
('comment', models.TextField()),
|
||||
('verified', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
reviews/migrations/__init__.py
Normal file
0
reviews/migrations/__init__.py
Normal file
23
reviews/models.py
Normal file
23
reviews/models.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from django.db import models
|
||||
|
||||
class Review(models.Model):
|
||||
customer_name = models.CharField(max_length=100)
|
||||
rating = models.IntegerField(choices=[(i, i) for i in range(1, 6)])
|
||||
comment = models.TextField()
|
||||
verified = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Review by {self.customer_name}"
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
class NewsletterSubscription(models.Model):
|
||||
email = models.EmailField(unique=True)
|
||||
subscribed_at = models.DateTimeField(auto_now_add=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
14
reviews/serializers.py
Normal file
14
reviews/serializers.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Review, NewsletterSubscription
|
||||
|
||||
|
||||
class ReviewSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Review
|
||||
fields = ["id", "customer_name", "rating", "comment", "verified", "created_at"]
|
||||
|
||||
|
||||
class NewsletterSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = NewsletterSubscription
|
||||
fields = ["email"]
|
||||
3
reviews/tests.py
Normal file
3
reviews/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
8
reviews/urls.py
Normal file
8
reviews/urls.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from django.urls import path
|
||||
from .views import ReviewListView, LatestReviewsView, NewsletterCreateView
|
||||
|
||||
urlpatterns = [
|
||||
path("", ReviewListView.as_view()),
|
||||
path("latest/", LatestReviewsView.as_view()),
|
||||
path("newsletter/", NewsletterCreateView.as_view()),
|
||||
]
|
||||
47
reviews/views.py
Normal file
47
reviews/views.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from rest_framework import generics, status
|
||||
from rest_framework.response import Response
|
||||
from .models import Review, NewsletterSubscription
|
||||
from .serializers import ReviewSerializer, NewsletterSerializer
|
||||
|
||||
|
||||
class ReviewListView(generics.ListAPIView):
|
||||
"""Все верифицированные отзывы"""
|
||||
queryset = Review.objects.filter(verified=True)
|
||||
serializer_class = ReviewSerializer
|
||||
|
||||
|
||||
class LatestReviewsView(generics.ListAPIView):
|
||||
"""Последние 6 отзывов для слайдера"""
|
||||
serializer_class = ReviewSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Review.objects.filter(verified=True).order_by("-created_at")[:6]
|
||||
|
||||
|
||||
class NewsletterCreateView(generics.CreateAPIView):
|
||||
"""Подписка на newsletter"""
|
||||
serializer_class = NewsletterSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
email = request.data.get("email")
|
||||
|
||||
if not email:
|
||||
return Response(
|
||||
{"error": "Email is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if NewsletterSubscription.objects.filter(email=email).exists():
|
||||
return Response(
|
||||
{"message": "You are already subscribed!"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(data={"email": email})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
|
||||
return Response(
|
||||
{"message": "Successfully subscribed to newsletter!"},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue