add top selling and card component
21
api/admin.py
|
|
@ -1,3 +1,22 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
from .models import Category, Product
|
||||
|
||||
|
||||
@admin.register(Category)
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'slug')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
|
||||
|
||||
@admin.register(Product)
|
||||
class ProductAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'name',
|
||||
'category',
|
||||
'price',
|
||||
'old_price',
|
||||
'rating',
|
||||
)
|
||||
list_filter = ('category',)
|
||||
search_fields = ('name',)
|
||||
43
api/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Generated by Django 6.1.1 on 2026-09-18 11:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Category',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Category',
|
||||
'verbose_name_plural': 'Categories',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Product',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('image', models.ImageField(upload_to='products/')),
|
||||
('rating', models.DecimalField(decimal_places=1, max_digits=2)),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('old_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='api.category')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Product',
|
||||
'verbose_name_plural': 'Products',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,3 +1,47 @@
|
|||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Category'
|
||||
verbose_name_plural = 'Categories'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Product(models.Model):
|
||||
category = models.ForeignKey(
|
||||
Category,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='products',
|
||||
)
|
||||
name = models.CharField(max_length=200)
|
||||
image = models.ImageField(upload_to='products/')
|
||||
rating = models.DecimalField(max_digits=2, decimal_places=1)
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
old_price = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def discount_percent(self):
|
||||
if self.old_price and self.old_price > self.price:
|
||||
return round(
|
||||
(self.old_price - self.price) / self.old_price * 100
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Product'
|
||||
verbose_name_plural = 'Products'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
20
api/serializers.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from .models import Product
|
||||
|
||||
|
||||
class ProductSerializer(serializers.ModelSerializer):
|
||||
discount_percent = serializers.ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = (
|
||||
'id',
|
||||
'name',
|
||||
'image',
|
||||
'rating',
|
||||
'price',
|
||||
'old_price',
|
||||
'discount_percent',
|
||||
'category',
|
||||
)
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
from django.urls import path
|
||||
|
||||
from .views import hello
|
||||
from .views import hello, ProductListView
|
||||
|
||||
urlpatterns = [
|
||||
path("hello/", hello),
|
||||
path("products/", ProductListView.as_view()),
|
||||
]
|
||||
11
api/views.py
|
|
@ -1,9 +1,18 @@
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.generics import ListAPIView
|
||||
|
||||
from .models import Product
|
||||
from .serializers import ProductSerializer
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
def hello(request):
|
||||
return Response({
|
||||
"message": "Hello from Freebie API!"
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
class ProductListView(ListAPIView):
|
||||
queryset = Product.objects.all()
|
||||
serializer_class = ProductSerializer
|
||||
|
|
@ -40,6 +40,7 @@ INSTALLED_APPS = [
|
|||
# =============================
|
||||
"corsheaders",
|
||||
"rest_framework",
|
||||
"api",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
|
@ -106,7 +107,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
LANGUAGE_CODE = 'ru-RU'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
|
|
@ -132,4 +133,7 @@ MAILERS = {
|
|||
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:5173",
|
||||
]
|
||||
]
|
||||
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/", include("api.urls")),
|
||||
]
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(
|
||||
settings.MEDIA_URL,
|
||||
document_root=settings.MEDIA_ROOT,
|
||||
)
|
||||
67
frontend/src/components/NewArrivals/NewArrivals.module.css
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
.section {
|
||||
border-top: 3px solid #000;
|
||||
padding: 48px 0 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.section :global(.container) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 30px;
|
||||
color: #000;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.products {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.viewAll {
|
||||
display: block;
|
||||
width: 138px;
|
||||
height: 36px;
|
||||
margin: 27px auto 0;
|
||||
padding: 0;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewAll:hover {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.products {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
row-gap: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.section {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.products {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
58
frontend/src/components/NewArrivals/NewArrivals.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import styles from './NewArrivals.module.css'
|
||||
import ProductsCard from '../ProductCard/ProductCard'
|
||||
|
||||
type Product = {
|
||||
id: number
|
||||
name: string
|
||||
image: string
|
||||
rating: number
|
||||
price: string
|
||||
old_price: string | null
|
||||
discount_percent: number | null
|
||||
category: number
|
||||
}
|
||||
|
||||
function NewArrivals() {
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://127.0.0.1:8000/api/products/')
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setProducts(data)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div className="container">
|
||||
<h2 className={styles.title}>NEW ARRIVALS</h2>
|
||||
|
||||
<div className={styles.products}>
|
||||
{products.slice(0, 4).map((product) => (
|
||||
<ProductsCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className={styles.viewAll}>
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewArrivals
|
||||
86
frontend/src/components/ProductCard/ProductCard.module.css
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
.card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.imageWrapper {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: #f1efef;
|
||||
}
|
||||
|
||||
.image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 11px 0 6px;
|
||||
overflow: hidden;
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.stars {
|
||||
color: #ffc633;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.ratingValue {
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: #000;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.oldPrice {
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.discount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 39px;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
border-radius: 20px;
|
||||
background: #ffebeb;
|
||||
color: #ff3333;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
59
frontend/src/components/ProductCard/ProductCard.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import styles from './ProductCard.module.css'
|
||||
|
||||
type Product = {
|
||||
id: number
|
||||
name: string
|
||||
image: string
|
||||
rating: number
|
||||
price: string
|
||||
old_price: string | null
|
||||
discount_percent: number | null
|
||||
category: number
|
||||
}
|
||||
|
||||
type ProductsCardProps = {
|
||||
product: Product
|
||||
}
|
||||
|
||||
function ProductsCard({ product }: ProductsCardProps) {
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
<div className={styles.imageWrapper}>
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className={styles.image}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className={styles.name}>{product.name}</h3>
|
||||
|
||||
<div className={styles.rating}>
|
||||
<span className={styles.stars}>★★★★★</span>
|
||||
<span className={styles.ratingValue}>
|
||||
{product.rating}/5
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.priceRow}>
|
||||
<span className={styles.price}>
|
||||
${Number(product.price).toFixed(0)}
|
||||
</span>
|
||||
|
||||
{product.old_price && (
|
||||
<span className={styles.oldPrice}>
|
||||
${Number(product.old_price).toFixed(0)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{product.discount_percent && (
|
||||
<span className={styles.discount}>
|
||||
-{product.discount_percent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductsCard
|
||||
67
frontend/src/components/TopSelling/TopSelling.module.css
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
.section {
|
||||
border-top: 3px solid #00000010;
|
||||
padding: 48px 0 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.section :global(.container) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 30px 0 30px;
|
||||
color: #000;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.products {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.viewAll {
|
||||
display: block;
|
||||
width: 138px;
|
||||
height: 36px;
|
||||
margin: 27px auto 0;
|
||||
padding: 0;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewAll:hover {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.products {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
row-gap: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.section {
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.products {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
58
frontend/src/components/TopSelling/TopSelling.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import styles from './TopSelling.module.css'
|
||||
import ProductsCard from '../ProductCard/ProductCard'
|
||||
|
||||
type Product = {
|
||||
id: number
|
||||
name: string
|
||||
image: string
|
||||
rating: number
|
||||
price: string
|
||||
old_price: string | null
|
||||
discount_percent: number | null
|
||||
category: number
|
||||
}
|
||||
|
||||
function TopSelling() {
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://127.0.0.1:8000/api/products/')
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
setProducts(data)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`${styles.section} container`}>
|
||||
<div className="container">
|
||||
<h2 className={styles.title}>TOP SELLING</h2>
|
||||
|
||||
<div className={styles.products}>
|
||||
{products.slice(4, 8).map((product) => (
|
||||
<ProductsCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className={styles.viewAll}>
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopSelling
|
||||
|
|
@ -2,6 +2,8 @@ import AnnouncementBar from '../../components/AnnouncementBar/AnnouncementBar'
|
|||
import Header from '../../components/Header/Header'
|
||||
import Hero from '../../components/Hero/Hero'
|
||||
import Brends from '../../components/Brends/Brends'
|
||||
import Arrivals from '../../components/NewArrivals/NewArrivals'
|
||||
import TopSelling from '../../components/TopSelling/TopSelling'
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
|
|
@ -13,6 +15,10 @@ function Home() {
|
|||
<Hero />
|
||||
|
||||
<Brends />
|
||||
|
||||
<Arrivals />
|
||||
|
||||
<TopSelling />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
BIN
media/products/Courage.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
media/products/Faded.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
media/products/Loose.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
media/products/Striped.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
media/products/checkered.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
media/products/skinny.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
media/products/sleeve.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
media/products/t-shirt.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
|
|
@ -1,3 +1,4 @@
|
|||
Django==6.1.1
|
||||
django-cors-headers==4.9.0
|
||||
djangorestframework==3.18.1
|
||||
djangorestframework==3.18.1
|
||||
pillow==12.3.0
|
||||