diff --git a/api/admin.py b/api/admin.py index 8c38f3f..0b7e774 100644 --- a/api/admin.py +++ b/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',) \ No newline at end of file diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py new file mode 100644 index 0000000..923711f --- /dev/null +++ b/api/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/api/models.py b/api/models.py index 71a8362..db12507 100644 --- a/api/models.py +++ b/api/models.py @@ -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 \ No newline at end of file diff --git a/api/serializers.py b/api/serializers.py new file mode 100644 index 0000000..f5096f7 --- /dev/null +++ b/api/serializers.py @@ -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', + ) \ No newline at end of file diff --git a/api/urls.py b/api/urls.py index a6db943..bdef1f9 100644 --- a/api/urls.py +++ b/api/urls.py @@ -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()), ] \ No newline at end of file diff --git a/api/views.py b/api/views.py index 7b3d3cf..6d1cd5d 100644 --- a/api/views.py +++ b/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!" - }) \ No newline at end of file + }) + + +class ProductListView(ListAPIView): + queryset = Product.objects.all() + serializer_class = ProductSerializer \ No newline at end of file diff --git a/backend/settings.py b/backend/settings.py index 035ea79..9083ced 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -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", -] \ No newline at end of file +] + +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" \ No newline at end of file diff --git a/backend/urls.py b/backend/urls.py index 3472a5d..94464d3 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -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")), -] \ No newline at end of file +] + +if settings.DEBUG: + urlpatterns += static( + settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT, + ) \ No newline at end of file diff --git a/frontend/src/components/NewArrivals/NewArrivals.module.css b/frontend/src/components/NewArrivals/NewArrivals.module.css new file mode 100644 index 0000000..2f6a677 --- /dev/null +++ b/frontend/src/components/NewArrivals/NewArrivals.module.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/components/NewArrivals/NewArrivals.tsx b/frontend/src/components/NewArrivals/NewArrivals.tsx new file mode 100644 index 0000000..07500b0 --- /dev/null +++ b/frontend/src/components/NewArrivals/NewArrivals.tsx @@ -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([]) + 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 ( +
+
+

NEW ARRIVALS

+ +
+ {products.slice(0, 4).map((product) => ( + + ))} +
+ + +
+
+ ) +} + +export default NewArrivals \ No newline at end of file diff --git a/frontend/src/components/ProductCard/ProductCard.module.css b/frontend/src/components/ProductCard/ProductCard.module.css new file mode 100644 index 0000000..7a5249c --- /dev/null +++ b/frontend/src/components/ProductCard/ProductCard.module.css @@ -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; +} diff --git a/frontend/src/components/ProductCard/ProductCard.tsx b/frontend/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 0000000..c5a88e0 --- /dev/null +++ b/frontend/src/components/ProductCard/ProductCard.tsx @@ -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 ( +
+
+ {product.name} +
+ +

{product.name}

+ +
+ ★★★★★ + + {product.rating}/5 + +
+ +
+ + ${Number(product.price).toFixed(0)} + + + {product.old_price && ( + + ${Number(product.old_price).toFixed(0)} + + )} + + {product.discount_percent && ( + + -{product.discount_percent}% + + )} +
+
+ ) +} + +export default ProductsCard \ No newline at end of file diff --git a/frontend/src/components/TopSelling/TopSelling.module.css b/frontend/src/components/TopSelling/TopSelling.module.css new file mode 100644 index 0000000..41862d5 --- /dev/null +++ b/frontend/src/components/TopSelling/TopSelling.module.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/components/TopSelling/TopSelling.tsx b/frontend/src/components/TopSelling/TopSelling.tsx new file mode 100644 index 0000000..1a583ab --- /dev/null +++ b/frontend/src/components/TopSelling/TopSelling.tsx @@ -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([]) + 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 ( +
+
+

TOP SELLING

+ +
+ {products.slice(4, 8).map((product) => ( + + ))} +
+ + +
+
+ ) +} + +export default TopSelling \ No newline at end of file diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 32f8b5b..d4e62b7 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -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() { + + + + ) } diff --git a/media/products/Courage.png b/media/products/Courage.png new file mode 100644 index 0000000..3a52e0b Binary files /dev/null and b/media/products/Courage.png differ diff --git a/media/products/Faded.png b/media/products/Faded.png new file mode 100644 index 0000000..5ec4058 Binary files /dev/null and b/media/products/Faded.png differ diff --git a/media/products/Loose.png b/media/products/Loose.png new file mode 100644 index 0000000..a96530b Binary files /dev/null and b/media/products/Loose.png differ diff --git a/media/products/Striped.png b/media/products/Striped.png new file mode 100644 index 0000000..c0b4a8b Binary files /dev/null and b/media/products/Striped.png differ diff --git a/media/products/checkered.png b/media/products/checkered.png new file mode 100644 index 0000000..7d46c03 Binary files /dev/null and b/media/products/checkered.png differ diff --git a/media/products/skinny.png b/media/products/skinny.png new file mode 100644 index 0000000..fe5dc55 Binary files /dev/null and b/media/products/skinny.png differ diff --git a/media/products/sleeve.png b/media/products/sleeve.png new file mode 100644 index 0000000..be31924 Binary files /dev/null and b/media/products/sleeve.png differ diff --git a/media/products/t-shirt.png b/media/products/t-shirt.png new file mode 100644 index 0000000..3915a5d Binary files /dev/null and b/media/products/t-shirt.png differ diff --git a/requirements.txt b/requirements.txt index 830c192..8039e50 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Django==6.1.1 django-cors-headers==4.9.0 -djangorestframework==3.18.1 \ No newline at end of file +djangorestframework==3.18.1 +pillow==12.3.0 \ No newline at end of file