add top selling and card component

This commit is contained in:
Nikita 2026-09-18 17:53:39 +05:00
parent 2a2d7925d9
commit cc8fac8f32
24 changed files with 558 additions and 8 deletions

View file

@ -1,3 +1,22 @@
from django.contrib import admin 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',)

View 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',
},
),
]

View file

@ -1,3 +1,47 @@
from django.db import models 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
View 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',
)

View file

@ -1,7 +1,8 @@
from django.urls import path from django.urls import path
from .views import hello from .views import hello, ProductListView
urlpatterns = [ urlpatterns = [
path("hello/", hello), path("hello/", hello),
path("products/", ProductListView.as_view()),
] ]

View file

@ -1,9 +1,18 @@
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
from rest_framework.generics import ListAPIView
from .models import Product
from .serializers import ProductSerializer
@api_view(["GET"]) @api_view(["GET"])
def hello(request): def hello(request):
return Response({ return Response({
"message": "Hello from Freebie API!" "message": "Hello from Freebie API!"
}) })
class ProductListView(ListAPIView):
queryset = Product.objects.all()
serializer_class = ProductSerializer

View file

@ -40,6 +40,7 @@ INSTALLED_APPS = [
# ============================= # =============================
"corsheaders", "corsheaders",
"rest_framework", "rest_framework",
"api",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -106,7 +107,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/6.1/topics/i18n/ # https://docs.djangoproject.com/en/6.1/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'ru-RU'
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
@ -132,4 +133,7 @@ MAILERS = {
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = [
"http://localhost:5173", "http://localhost:5173",
] ]
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

View file

@ -1,7 +1,15 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/", include("api.urls")), path("api/", include("api.urls")),
] ]
if settings.DEBUG:
urlpatterns += static(
settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT,
)

View 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;
}
}

View 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

View 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;
}

View 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

View 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;
}
}

View 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

View file

@ -2,6 +2,8 @@ import AnnouncementBar from '../../components/AnnouncementBar/AnnouncementBar'
import Header from '../../components/Header/Header' import Header from '../../components/Header/Header'
import Hero from '../../components/Hero/Hero' import Hero from '../../components/Hero/Hero'
import Brends from '../../components/Brends/Brends' import Brends from '../../components/Brends/Brends'
import Arrivals from '../../components/NewArrivals/NewArrivals'
import TopSelling from '../../components/TopSelling/TopSelling'
function Home() { function Home() {
return ( return (
@ -13,6 +15,10 @@ function Home() {
<Hero /> <Hero />
<Brends /> <Brends />
<Arrivals />
<TopSelling />
</> </>
) )
} }

BIN
media/products/Courage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
media/products/Faded.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
media/products/Loose.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
media/products/Striped.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
media/products/skinny.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
media/products/sleeve.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
media/products/t-shirt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -1,3 +1,4 @@
Django==6.1.1 Django==6.1.1
django-cors-headers==4.9.0 django-cors-headers==4.9.0
djangorestframework==3.18.1 djangorestframework==3.18.1
pillow==12.3.0