very first commit

This commit is contained in:
sdarbinyan
2026-01-18 18:57:06 +04:00
commit bd80896886
152 changed files with 28211 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
<div class="category-container">
@if (error()) {
<div class="error">
<p>{{ error() }}</p>
<button (click)="resetAndLoad()">Попробовать снова</button>
</div>
}
@if (!error()) {
<div class="items-grid">
@for (item of items(); track trackByItemId($index, item)) {
<div class="item-card">
<a [routerLink]="['/item', item.itemID]" class="item-link">
<div class="item-image">
<img [src]="getMainImage(item)" [alt]="item.name" loading="lazy" decoding="async" width="300" height="300" />
@if (item.discount > 0) {
<div class="discount-badge">-{{ item.discount }}%</div>
}
</div>
<div class="item-details">
<h3 class="item-name">{{ item.name }}</h3>
<div class="item-rating">
<span class="rating-stars">⭐ {{ item.rating }}</span>
<span class="rating-count">({{ item.callbacks?.length || 0 }})</span>
</div>
<div class="item-price">
@if (item.discount > 0) {
<span class="original-price">{{ item.price }} {{ item.currency }}</span>
<span class="discounted-price">{{ getDiscountedPrice(item) | number:'1.2-2' }} {{ item.currency }}</span>
} @else {
<span class="current-price">{{ item.price }} {{ item.currency }}</span>
}
</div>
<div class="item-stock">
<div class="stock-bar">
<span class="bar-segment" [class.filled]="item.remainings === 'high' || item.remainings === 'medium' || item.remainings === 'low'" [class.high]="item.remainings === 'high'" [class.medium]="item.remainings === 'medium'" [class.low]="item.remainings === 'low'"></span>
<span class="bar-segment" [class.filled]="item.remainings === 'high' || item.remainings === 'medium'" [class.high]="item.remainings === 'high'" [class.medium]="item.remainings === 'medium'"></span>
<span class="bar-segment" [class.filled]="item.remainings === 'high'" [class.high]="item.remainings === 'high'"></span>
</div>
</div>
</div>
</a>
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)">
В корзину
</button>
</div>
}
</div>
@if (loading() && items().length > 0) {
<div class="loading-more">
<div class="spinner"></div>
<p>Загрузка...</p>
</div>
}
@if (!hasMore() && items().length > 0) {
<div class="no-more">
<p>Все товары загружены</p>
</div>
}
@if (items().length === 0 && !loading()) {
<div class="no-items">
<p>В этой категории пока нет товаров</p>
</div>
}
@if (loading() && items().length === 0) {
<div class="loading-initial">
<div class="spinner"></div>
<p>Загрузка товаров...</p>
</div>
}
}
</div>

View File

@@ -0,0 +1,233 @@
.category-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.error,
.loading-initial,
.no-items,
.no-more {
text-align: center;
padding: 60px 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.error button {
margin-top: 20px;
padding: 10px 24px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
&:hover {
background: var(--primary-hover);
}
}
.items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.item-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
}
.item-link {
text-decoration: none;
color: inherit;
flex: 1;
display: flex;
flex-direction: column;
}
.item-image {
position: relative;
width: 100%;
padding-top: 75%; // 4:3 aspect ratio
background: #f5f5f5;
overflow: hidden;
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.discount-badge {
position: absolute;
top: 12px;
right: 12px;
background: #ff4757;
color: white;
padding: 6px 12px;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
}
.item-details {
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.item-name {
font-size: 1.1rem;
margin: 0;
color: #333;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.item-rating {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: #333;
.rating-stars {
color: #ffa502;
font-weight: 600;
}
}
.item-price {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: auto;
}
.original-price {
text-decoration: line-through;
color: #555;
font-size: 0.9rem;
}
.discounted-price,
.current-price {
font-size: 1.3rem;
font-weight: 700;
color: var(--primary-color);
}
.item-stock {
.stock-bar {
display: flex;
gap: 4px;
align-items: center;
.bar-segment {
width: 20px;
height: 6px;
background: #e0e0e0;
border-radius: 3px;
transition: background 0.2s;
&.filled.high {
background: #2ed573;
}
&.filled.medium {
background: #ffa502;
}
&.filled.low {
background: #ff4757;
}
}
}
}
.add-to-cart-btn {
width: 100%;
padding: 12px;
background: var(--primary-color);
color: white;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: var(--primary-hover);
}
&:active {
transform: scale(0.98);
}
}
.loading-more {
text-align: center;
padding: 40px 20px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.no-more {
color: #555;
padding: 40px 20px;
}
@media (max-width: 768px) {
.items-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
}
.item-name {
font-size: 0.95rem;
}
.discounted-price,
.current-price {
font-size: 1.1rem;
}
}

View File

@@ -0,0 +1,117 @@
import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, CartService } from '../../services';
import { Item } from '../../models';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-category',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './category.component.html',
styleUrls: ['./category.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CategoryComponent implements OnInit, OnDestroy {
categoryID = signal<number>(0);
items = signal<Item[]>([]);
loading = signal(false);
error = signal<string | null>(null);
hasMore = signal(true);
private skip = 0;
private readonly count = 20;
private isLoadingMore = false;
private routeSubscription?: Subscription;
constructor(
private route: ActivatedRoute,
private apiService: ApiService,
private cartService: CartService
) {}
ngOnInit(): void {
this.routeSubscription = this.route.params.subscribe(params => {
const id = parseInt(params['id'], 10);
this.categoryID.set(id);
this.resetAndLoad();
});
}
ngOnDestroy(): void {
this.routeSubscription?.unsubscribe();
}
resetAndLoad(): void {
this.items.set([]);
this.skip = 0;
this.hasMore.set(true);
this.loadItems();
}
loadItems(): void {
if (this.isLoadingMore || !this.hasMore()) return;
this.loading.set(true);
this.isLoadingMore = true;
this.apiService.getCategoryItems(this.categoryID(), this.count, this.skip).subscribe({
next: (newItems) => {
// Handle null or empty response
if (!newItems || newItems.length === 0) {
this.hasMore.set(false);
} else {
if (newItems.length < this.count) {
this.hasMore.set(false);
}
this.items.update(current => [...current, ...newItems]);
this.skip += this.count;
}
this.loading.set(false);
this.isLoadingMore = false;
},
error: (err) => {
this.error.set('Failed to load items');
this.loading.set(false);
this.isLoadingMore = false;
console.error('Error loading items:', err);
}
});
}
private scrollTimeout: any;
@HostListener('window:scroll')
onScroll(): void {
if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
this.scrollTimeout = setTimeout(() => {
const scrollPosition = window.innerHeight + window.scrollY;
const bottomPosition = document.documentElement.scrollHeight - 500;
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore() && !this.isLoadingMore) {
this.loadItems();
}
}, 100);
}
addToCart(itemID: number, event: Event): void {
event.preventDefault();
event.stopPropagation();
this.cartService.addItem(itemID);
}
getDiscountedPrice(item: Item): number {
return item.price * (1 - item.discount / 100);
}
getMainImage(item: Item): string {
return item.photos?.[0]?.url || '';
}
// TrackBy function for performance optimization
trackByItemId(index: number, item: Item): number {
return item.itemID;
}
}

View File

@@ -0,0 +1,38 @@
<div class="subcategories-container">
@if (loading()) {
<div class="loading">
<div class="spinner"></div>
<p>Загрузка подкатегорий...</p>
</div>
}
@if (error()) {
<div class="error">
<p>{{ error() }}</p>
<button (click)="ngOnInit()">Попробовать снова</button>
</div>
}
@if (!loading() && !error()) {
<header class="sub-header">
<h2>{{ parentName() }}</h2>
</header>
<div class="categories-grid">
@for (cat of subcategories(); track trackByCategoryId($index, cat)) {
<div class="category-card">
<a [routerLink]="['/category', cat.categoryID]" class="category-link">
<div class="category-media">
@if (cat.icon) {
<img [src]="cat.icon" [alt]="cat.name" loading="lazy" decoding="async" />
} @else {
<div class="category-fallback">{{ cat.name }}</div>
}
</div>
<h3>{{ cat.name }}</h3>
</a>
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,300 @@
.subcategories-container {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
// Loading состояние
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 16px;
.spinner {
width: 48px;
height: 48px;
border: 4px solid #f3f4f6;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
p {
color: #6b7280;
font-size: 1rem;
margin: 0;
}
}
// Error состояние
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 16px;
padding: 24px;
p {
color: #dc2626;
font-size: 1rem;
text-align: center;
margin: 0;
}
button {
padding: 10px 24px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
&:active {
transform: translateY(0);
}
}
}
.sub-header {
margin-bottom: 24px;
position: relative;
padding-bottom: 12px;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 60px;
height: 3px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
border-radius: 2px;
}
h2 {
font-size: 1.75rem;
color: #1f2937;
margin: 0;
font-weight: 600;
letter-spacing: -0.02em;
}
}
.categories-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
}
.category-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
transition: all 0.3s ease;
animation: fadeInUp 0.5s ease backwards;
cursor: pointer;
// Анимация появления с задержкой для каждой карточки
@for $i from 1 through 20 {
&:nth-child(#{$i}) {
animation-delay: #{$i * 0.05}s;
}
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0,0,0,0.12);
}
.category-link {
display: flex;
flex-direction: column;
flex: 1;
text-decoration: none;
color: inherit;
position: relative;
min-height: 200px;
.category-media {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: linear-gradient(135deg, #f6f7fb 0%, #e9ecf5 100%);
transition: transform 0.3s ease;
}
&:hover .category-media {
transform: scale(1.05);
}
.category-media img {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.3s ease;
}
.category-fallback {
text-align: center;
font-weight: 600;
font-size: 1.1rem;
padding: 20px;
color: #6b7280;
}
h3 {
position: absolute;
bottom: 0;
left: 0;
right: 0;
text-align: center;
margin: 0;
font-size: 0.95rem;
color: white;
padding: 12px 14px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.4) 70%, transparent);
z-index: 1;
font-weight: 500;
transition: all 0.3s ease;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
&:hover h3 {
padding: 16px 14px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0.5) 70%, transparent);
}
}
}
// Keyframes для анимаций
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Мобильная версия
@media (max-width: 768px) {
padding: 16px;
.sub-header {
margin-bottom: 20px;
h2 {
font-size: 1.5rem;
}
}
.categories-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.category-card {
border-radius: 10px;
.category-link {
min-height: 140px;
h3 {
font-size: 0.85rem;
padding: 10px 8px;
}
}
}
}
// Очень маленькие экраны
@media (max-width: 480px) {
padding: 12px;
.sub-header {
margin-bottom: 16px;
h2 {
font-size: 1.35rem;
}
}
.categories-grid {
gap: 10px;
}
.category-card {
.category-link {
min-height: 120px;
h3 {
font-size: 0.8rem;
padding: 8px 6px;
}
.category-fallback {
font-size: 0.95rem;
padding: 12px;
}
}
}
}
// Большие экраны
@media (min-width: 1200px) {
padding: 32px;
.sub-header {
margin-bottom: 28px;
h2 {
font-size: 2rem;
}
}
.categories-grid {
gap: 24px;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.category-card {
.category-link {
min-height: 220px;
h3 {
font-size: 1rem;
padding: 14px 16px;
}
}
}
}
}

View File

@@ -0,0 +1,65 @@
import { Component, OnInit, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ApiService } from '../../services';
import { Category } from '../../models';
@Component({
selector: 'app-subcategories',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './subcategories.component.html',
styleUrls: ['./subcategories.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SubcategoriesComponent implements OnInit {
categories = signal<Category[]>([]);
subcategories = signal<Category[]>([]);
loading = signal(true);
error = signal<string | null>(null);
parentName = signal<string>('');
constructor(
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService
) {}
ngOnInit(): void {
this.route.params.subscribe(params => {
const id = parseInt(params['id'], 10);
this.loadForParent(id);
});
}
private loadForParent(parentID: number): void {
this.loading.set(true);
this.apiService.getCategories().subscribe({
next: (cats) => {
this.categories.set(cats);
const subs = cats.filter(c => c.parentID === parentID);
const parent = cats.find(c => c.categoryID === parentID);
this.parentName.set(parent ? parent.name : 'Категория');
if (!subs || subs.length === 0) {
// No subcategories: redirect to items list for this category
this.router.navigate(['/category', parentID, 'items'], { replaceUrl: true });
} else {
this.subcategories.set(subs);
}
this.loading.set(false);
},
error: (err) => {
this.error.set('Failed to load subcategories');
this.loading.set(false);
console.error('Error loading categories:', err);
}
});
}
// TrackBy function for performance optimization
trackByCategoryId(index: number, category: Category): number {
return category.categoryID;
}
}