90
apps/web/src/components/BrandLogos/BrandLogos.module.scss
Normal file
90
apps/web/src/components/BrandLogos/BrandLogos.module.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
.section {
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-navy-500);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.allLink {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-orange-500);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.logoBox {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--border-radius-md);
|
||||
background: var(--color-slate-100);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 8px;
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
color: var(--color-slate-600);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-slate-700);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 10px;
|
||||
color: var(--color-slate-400);
|
||||
margin-top: 2px;
|
||||
}
|
||||
33
apps/web/src/components/BrandLogos/BrandLogos.tsx
Normal file
33
apps/web/src/components/BrandLogos/BrandLogos.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Link from 'next/link';
|
||||
import { Icon } from '@/components/Icon/Icon';
|
||||
import { brands } from '@/data/brands';
|
||||
import styles from './BrandLogos.module.scss';
|
||||
|
||||
export function BrandLogos() {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>Производители</h2>
|
||||
<Link href="/catalog" className={styles.allLink}>
|
||||
Все бренды
|
||||
<Icon name="chevronRight" size={14} color="var(--color-orange-500)" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{brands.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={`/brand/${b.id}`}
|
||||
className={styles.card}
|
||||
>
|
||||
<div className={styles.logoBox}>{b.logo}</div>
|
||||
<div className={styles.name}>{b.name}</div>
|
||||
<div className={styles.meta}>
|
||||
{b.country} · {b.category}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
21
apps/web/src/components/Breadcrumb/Breadcrumb.module.scss
Normal file
21
apps/web/src/components/Breadcrumb/Breadcrumb.module.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
.breadcrumb {
|
||||
font-size: 12px;
|
||||
color: var(--color-slate-400);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-slate-400);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--color-slate-600);
|
||||
}
|
||||
26
apps/web/src/components/Breadcrumb/Breadcrumb.tsx
Normal file
26
apps/web/src/components/Breadcrumb/Breadcrumb.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import Link from 'next/link';
|
||||
import styles from './Breadcrumb.module.scss';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
|
||||
return (
|
||||
<nav className={styles.breadcrumb}>
|
||||
{items.map((item, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className={styles.separator}>/</span>}
|
||||
{item.href ? (
|
||||
<Link href={item.href} className={styles.link}>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={styles.current}>{item.label}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
60
apps/web/src/components/CartProvider/CartProvider.tsx
Normal file
60
apps/web/src/components/CartProvider/CartProvider.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { Product } from '@/types';
|
||||
|
||||
interface CartItem extends Product {
|
||||
qty: number;
|
||||
}
|
||||
|
||||
interface CartContextValue {
|
||||
cart: CartItem[];
|
||||
addToCart: (product: Product) => void;
|
||||
removeFromCart: (id: number) => void;
|
||||
updateQuantity: (id: number, qty: number) => void;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [cart, setCart] = useState<CartItem[]>([]);
|
||||
|
||||
const addToCart = useCallback((product: Product) => {
|
||||
setCart((prev) => {
|
||||
const exists = prev.find((i) => i.id === product.id);
|
||||
if (exists) return prev;
|
||||
return [...prev, { ...product, qty: 1 }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeFromCart = useCallback((id: number) => {
|
||||
setCart((prev) => prev.filter((i) => i.id !== id));
|
||||
}, []);
|
||||
|
||||
const updateQuantity = useCallback((id: number, qty: number) => {
|
||||
if (qty < 1) return;
|
||||
setCart((prev) =>
|
||||
prev.map((i) => (i.id === id ? { ...i, qty } : i))
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CartContext.Provider
|
||||
value={{ cart, addToCart, removeFromCart, updateQuantity }}
|
||||
>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error('useCart must be used within CartProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inner {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 20px;
|
||||
position: sticky;
|
||||
top: 140px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--color-slate-800);
|
||||
margin-bottom: var(--space-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.filterItem {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-slate-600);
|
||||
margin-bottom: 2px;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-slate-50);
|
||||
}
|
||||
}
|
||||
|
||||
.filterItemActive {
|
||||
font-weight: 600;
|
||||
background: var(--color-orange-50);
|
||||
color: var(--color-orange-600);
|
||||
}
|
||||
|
||||
.brandCountry {
|
||||
font-size: 11px;
|
||||
color: var(--color-slate-400);
|
||||
}
|
||||
80
apps/web/src/components/CatalogSidebar/CatalogSidebar.tsx
Normal file
80
apps/web/src/components/CatalogSidebar/CatalogSidebar.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Icon } from '@/components/Icon/Icon';
|
||||
import { categories } from '@/data/categories';
|
||||
import { brands } from '@/data/brands';
|
||||
import styles from './CatalogSidebar.module.scss';
|
||||
|
||||
interface CatalogSidebarProps {
|
||||
activeCategory?: string;
|
||||
}
|
||||
|
||||
export function CatalogSidebar({ activeCategory }: CatalogSidebarProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const activeBrand = searchParams.get('brand');
|
||||
|
||||
const handleCategoryClick = (slug: string | null) => {
|
||||
if (slug) {
|
||||
router.push(`/catalog/${slug}`);
|
||||
} else {
|
||||
router.push('/catalog');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrandClick = (brandId: number) => {
|
||||
router.push(`/brand/${brandId}`);
|
||||
};
|
||||
|
||||
const filteredBrands = activeCategory
|
||||
? brands.filter((b) => {
|
||||
const cat = categories.find((c) => c.slug === activeCategory);
|
||||
return cat ? b.category === cat.name : true;
|
||||
})
|
||||
: brands;
|
||||
|
||||
return (
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<Icon name="filter" size={16} color="var(--color-orange-500)" />
|
||||
Фильтры
|
||||
</div>
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<div className={styles.filterLabel}>Направление</div>
|
||||
<button
|
||||
className={`${styles.filterItem} ${!activeCategory ? styles.filterItemActive : ''}`}
|
||||
onClick={() => handleCategoryClick(null)}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c.slug}
|
||||
className={`${styles.filterItem} ${activeCategory === c.slug ? styles.filterItemActive : ''}`}
|
||||
onClick={() => handleCategoryClick(c.slug)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<div className={styles.filterLabel}>Бренд</div>
|
||||
{filteredBrands.slice(0, 6).map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
className={`${styles.filterItem} ${activeBrand === String(b.id) ? styles.filterItemActive : ''}`}
|
||||
onClick={() => handleBrandClick(b.id)}
|
||||
>
|
||||
<span>{b.name}</span>
|
||||
<span className={styles.brandCountry}>{b.country}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/components/Directions/Directions.module.scss
Normal file
76
apps/web/src/components/Directions/Directions.module.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
.section {
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-navy-500);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.cardAccent {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--color-slate-800);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.cardBrands {
|
||||
font-size: 11px;
|
||||
color: var(--color-slate-400);
|
||||
}
|
||||
31
apps/web/src/components/Directions/Directions.tsx
Normal file
31
apps/web/src/components/Directions/Directions.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import Link from 'next/link';
|
||||
import { categories } from '@/data/categories';
|
||||
import styles from './Directions.module.scss';
|
||||
|
||||
export function Directions() {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.title}>Направления</h2>
|
||||
<p className={styles.subtitle}>
|
||||
Четыре ключевых направления поставок — выберите ваше
|
||||
</p>
|
||||
<div className={styles.grid}>
|
||||
{categories.map((d) => (
|
||||
<Link
|
||||
key={d.slug}
|
||||
href={`/catalog/${d.slug}`}
|
||||
className={styles.card}
|
||||
>
|
||||
<div
|
||||
className={styles.cardAccent}
|
||||
style={{ background: d.color }}
|
||||
/>
|
||||
<h3 className={styles.cardTitle}>{d.name}</h3>
|
||||
<p className={styles.cardDesc}>{d.description}</p>
|
||||
<div className={styles.cardBrands}>{d.brands}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
92
apps/web/src/components/Footer/Footer.module.scss
Normal file
92
apps/web/src/components/Footer/Footer.module.scss
Normal file
@@ -0,0 +1,92 @@
|
||||
.footer {
|
||||
background: var(--color-slate-900);
|
||||
color: var(--color-slate-300);
|
||||
padding: var(--space-2xl) var(--space-lg) 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.inner {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2xl);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--color-orange-500), var(--color-orange-700));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 900;
|
||||
font-size: 18px;
|
||||
letter-spacing: -1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logoTitle {
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.logoSubtitle {
|
||||
font-size: 11px;
|
||||
color: var(--color-slate-400);
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: flex;
|
||||
gap: var(--space-2xl);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.columnTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-slate-400);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.columnList {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.columnLink {
|
||||
font-size: 13px;
|
||||
color: var(--color-slate-300);
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-orange-400);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
max-width: var(--max-width);
|
||||
margin: var(--space-xl) auto 0;
|
||||
padding: var(--space-lg) 0;
|
||||
border-top: 1px solid var(--color-slate-700);
|
||||
font-size: 12px;
|
||||
color: var(--color-slate-500);
|
||||
}
|
||||
42
apps/web/src/components/Footer/Footer.tsx
Normal file
42
apps/web/src/components/Footer/Footer.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import Link from 'next/link';
|
||||
import { footerColumns } from '@/data/navigation';
|
||||
import styles from './Footer.module.scss';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.brand}>
|
||||
<div className={styles.logoIcon}>PP</div>
|
||||
<div>
|
||||
<div className={styles.logoTitle}>PAN-PROM</div>
|
||||
<div className={styles.logoSubtitle}>
|
||||
Промышленное оборудование из Европы
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.columns}>
|
||||
{footerColumns.map((col) => (
|
||||
<div key={col.title} className={styles.column}>
|
||||
<h4 className={styles.columnTitle}>{col.title}</h4>
|
||||
<ul className={styles.columnList}>
|
||||
{col.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link href={link.href} className={styles.columnLink}>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.bottom}>
|
||||
<span>© {new Date().getFullYear()} PAN-PROM. Все права защищены.</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
292
apps/web/src/components/Header/Header.module.scss
Normal file
292
apps/web/src/components/Header/Header.module.scss
Normal file
@@ -0,0 +1,292 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Top bar */
|
||||
.topBar {
|
||||
background: var(--color-navy-500);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 6px var(--space-lg);
|
||||
}
|
||||
|
||||
.topBarInner {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topBarContacts {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topBarItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.langSwitcher {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.langActive {
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.langInactive {
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.langDivider {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Main nav */
|
||||
.mainNav {
|
||||
padding: 12px var(--space-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--color-orange-500), var(--color-orange-700));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 900;
|
||||
font-size: 18px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.logoTitle {
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
color: var(--color-navy-500);
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.logoSubtitle {
|
||||
font-size: 10px;
|
||||
color: var(--color-slate-400);
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.searchWrapper {
|
||||
flex: 1;
|
||||
max-width: 520px;
|
||||
margin: 0 var(--space-xl);
|
||||
position: relative;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
overflow: hidden;
|
||||
transition: border 0.2s;
|
||||
background: var(--color-slate-50);
|
||||
}
|
||||
|
||||
.searchFocused .searchBar {
|
||||
border-color: var(--color-orange-500);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
color: var(--color-slate-800);
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
background: var(--color-orange-500);
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.suggestionItem {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-slate-100);
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-slate-50);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionSku {
|
||||
color: var(--color-orange-500);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.suggestionName {
|
||||
color: var(--color-text-muted);
|
||||
margin-left: var(--space-sm);
|
||||
}
|
||||
|
||||
.suggestionBrand {
|
||||
font-size: 11px;
|
||||
color: var(--color-slate-400);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.cartLink {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cartBadge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -8px;
|
||||
background: var(--color-orange-500);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rfqButton {
|
||||
background: var(--color-orange-500);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.menuToggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* Nav tabs */
|
||||
.navTabs {
|
||||
border-top: 1px solid var(--color-slate-100);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-lg);
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
&.navTabsOpen {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navTab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-slate-600);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.navTabActive {
|
||||
border-bottom-color: var(--color-orange-500);
|
||||
color: var(--color-orange-500);
|
||||
}
|
||||
147
apps/web/src/components/Header/Header.tsx
Normal file
147
apps/web/src/components/Header/Header.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Icon } from '@/components/Icon/Icon';
|
||||
import { useCart } from '@/components/CartProvider/CartProvider';
|
||||
import { headerNav } from '@/data/navigation';
|
||||
import { products } from '@/data/products';
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
const { cart } = useCart();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const [mobileMenu, setMobileMenu] = useState(false);
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const suggestions =
|
||||
searchQuery.length > 1
|
||||
? products
|
||||
.filter(
|
||||
(p) =>
|
||||
p.sku.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
.slice(0, 5)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
{/* Top bar */}
|
||||
<div className={styles.topBar}>
|
||||
<div className={styles.topBarInner}>
|
||||
<div className={styles.topBarContacts}>
|
||||
<span className={styles.topBarItem}>
|
||||
<Icon name="phone" size={13} color="var(--color-orange-400)" />
|
||||
+49 (0) 40 123-4567
|
||||
</span>
|
||||
<span className={styles.topBarItem}>
|
||||
<Icon name="mail" size={13} color="var(--color-orange-400)" />
|
||||
info@pan-prom.eu
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.langSwitcher}>
|
||||
<span className={styles.langActive}>RU</span>
|
||||
<span className={styles.langDivider}>|</span>
|
||||
<span className={styles.langInactive}>EN</span>
|
||||
<span className={styles.langDivider}>|</span>
|
||||
<span className={styles.langInactive}>DE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main nav */}
|
||||
<div className={styles.mainNav}>
|
||||
{/* Logo */}
|
||||
<Link href="/" className={styles.logo}>
|
||||
<div className={styles.logoIcon}>PP</div>
|
||||
<div>
|
||||
<div className={styles.logoTitle}>PAN-PROM</div>
|
||||
<div className={styles.logoSubtitle}>Выбор инженеров</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Search */}
|
||||
<div
|
||||
className={`${styles.searchWrapper} ${searchFocused ? styles.searchFocused : ''}`}
|
||||
ref={searchRef}
|
||||
>
|
||||
<div className={styles.searchBar}>
|
||||
<input
|
||||
placeholder="Артикул, название или бренд..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setTimeout(() => setSearchFocused(false), 200)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
<Link
|
||||
href={searchQuery ? `/catalog?q=${encodeURIComponent(searchQuery)}` : '/catalog'}
|
||||
className={styles.searchButton}
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<Icon name="search" size={18} color="#fff" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{searchFocused && suggestions.length > 0 && (
|
||||
<div className={styles.suggestions}>
|
||||
{suggestions.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/product/${encodeURIComponent(s.sku)}`}
|
||||
className={styles.suggestionItem}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<div>
|
||||
<span className={styles.suggestionSku}>{s.sku}</span>
|
||||
<span className={styles.suggestionName}>{s.name}</span>
|
||||
</div>
|
||||
<span className={styles.suggestionBrand}>{s.brand}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right actions */}
|
||||
<div className={styles.actions}>
|
||||
<Link href="/cart" className={styles.cartLink}>
|
||||
<Icon name="cart" size={22} color="var(--color-slate-700)" />
|
||||
{cart.length > 0 && (
|
||||
<span className={styles.cartBadge}>{cart.length}</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link href="/rfq" className={styles.rfqButton}>
|
||||
Запрос цены (RFQ)
|
||||
</Link>
|
||||
<button
|
||||
className={styles.menuToggle}
|
||||
onClick={() => setMobileMenu(!mobileMenu)}
|
||||
aria-label={mobileMenu ? 'Закрыть меню' : 'Открыть меню'}
|
||||
>
|
||||
<Icon name={mobileMenu ? 'x' : 'menu'} size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav tabs */}
|
||||
<nav className={`${styles.navTabs} ${mobileMenu ? styles.navTabsOpen : ''}`}>
|
||||
{headerNav.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={`${styles.navTab} ${pathname === item.href ? styles.navTabActive : ''}`}
|
||||
onClick={() => setMobileMenu(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
129
apps/web/src/components/Hero/Hero.module.scss
Normal file
129
apps/web/src/components/Hero/Hero.module.scss
Normal file
@@ -0,0 +1,129 @@
|
||||
.hero {
|
||||
background: linear-gradient(135deg, var(--color-navy-500) 0%, var(--color-navy-700) 50%, var(--color-navy-900) 100%);
|
||||
padding: 64px var(--space-lg);
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.05;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-orange-400);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.inner {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: rgba(232, 96, 10, 0.13);
|
||||
color: var(--color-orange-400);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid rgba(232, 96, 10, 0.2);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.titleAccent {
|
||||
color: var(--color-orange-400);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 17px;
|
||||
color: var(--color-slate-300);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ctaPrimary {
|
||||
background: var(--color-orange-500);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: var(--border-radius-md);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-orange-600);
|
||||
}
|
||||
}
|
||||
|
||||
.ctaSecondary {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
border: 1px solid var(--color-slate-400);
|
||||
padding: 14px 32px;
|
||||
border-radius: var(--border-radius-md);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-top: 48px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.statItem {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statNum {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--color-orange-400);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 12px;
|
||||
color: var(--color-slate-300);
|
||||
margin-top: 4px;
|
||||
}
|
||||
62
apps/web/src/components/Hero/Hero.tsx
Normal file
62
apps/web/src/components/Hero/Hero.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from 'next/link';
|
||||
import { Icon } from '@/components/Icon/Icon';
|
||||
import styles from './Hero.module.scss';
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className={styles.hero}>
|
||||
<div className={styles.pattern} aria-hidden="true">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={styles.circle}
|
||||
style={{
|
||||
width: 120 + i * 60,
|
||||
height: 120 + i * 60,
|
||||
top: `${10 + i * 5}%`,
|
||||
right: `${-5 + i * 3}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.badge}>German Engineering. European Standards.</div>
|
||||
<h1 className={styles.title}>
|
||||
Промышленное оборудование
|
||||
<span className={styles.titleAccent}> из Европы</span>
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
Гидравлика, пневматика, АСУ, запасные части. Прямые поставки
|
||||
оригинальных комплектующих от ведущих европейских производителей.
|
||||
Склады в Берлине и Гуанчжоу.
|
||||
</p>
|
||||
<div className={styles.cta}>
|
||||
<Link href="/catalog" className={styles.ctaPrimary}>
|
||||
Каталог оборудования
|
||||
<Icon name="chevronRight" size={16} color="#fff" />
|
||||
</Link>
|
||||
<Link href="/rfq" className={styles.ctaSecondary}>
|
||||
Отправить запрос (RFQ)
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.stats}>
|
||||
{[
|
||||
{ num: '50+', label: 'Европейских брендов' },
|
||||
{ num: '10 000+', label: 'Позиций в каталоге' },
|
||||
{ num: '14', label: 'Дней средняя поставка' },
|
||||
{ num: '100%', label: 'Оригинальная продукция' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className={styles.statItem}>
|
||||
<div className={styles.statNum}>{s.num}</div>
|
||||
<div className={styles.statLabel}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
31
apps/web/src/components/Icon/Icon.tsx
Normal file
31
apps/web/src/components/Icon/Icon.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { iconPaths, IconName } from './icons';
|
||||
|
||||
interface IconProps {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size = 20,
|
||||
color = 'currentColor',
|
||||
className,
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d={iconPaths[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
24
apps/web/src/components/Icon/icons.ts
Normal file
24
apps/web/src/components/Icon/icons.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const iconPaths = {
|
||||
search: 'M21 21l-4.35-4.35M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
|
||||
cart: 'M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0',
|
||||
menu: 'M3 12h18M3 6h18M3 18h18',
|
||||
chevronRight: 'M9 18l6-6-6-6',
|
||||
phone:
|
||||
'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z',
|
||||
mail: '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-2zM22 6l-10 7L2 6',
|
||||
map: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
|
||||
filter: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3z',
|
||||
star: 'M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z',
|
||||
truck:
|
||||
'M1 3h15v13H1zM16 8h4l3 3v5h-7V8zM5.5 21a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM18.5 21a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
|
||||
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
|
||||
clock:
|
||||
'M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10zM12 6v6l4 2',
|
||||
x: 'M18 6L6 18M6 6l12 12',
|
||||
arrowLeft: 'M19 12H5M12 19l-7-7 7-7',
|
||||
check: 'M20 6L9 17l-5-5',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
minus: 'M5 12h14',
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof iconPaths;
|
||||
73
apps/web/src/components/ProductCard/ProductCard.module.scss
Normal file
73
apps/web/src/components/ProductCard/ProductCard.module.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.imageArea {
|
||||
background: var(--color-slate-50);
|
||||
height: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stockBadge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--color-teal-50);
|
||||
color: var(--color-teal-500);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.sku {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-orange-500);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-slate-800);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-slate-400);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: var(--color-slate-600);
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-navy-500);
|
||||
}
|
||||
25
apps/web/src/components/ProductCard/ProductCard.tsx
Normal file
25
apps/web/src/components/ProductCard/ProductCard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import Link from 'next/link';
|
||||
import { Product } from '@/types';
|
||||
import styles from './ProductCard.module.scss';
|
||||
|
||||
export function ProductCard({ product }: { product: Product }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/product/${encodeURIComponent(product.sku)}`}
|
||||
className={styles.card}
|
||||
>
|
||||
<div className={styles.imageArea}>
|
||||
{product.inStock && <span className={styles.stockBadge}>В наличии</span>}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.sku}>{product.sku}</div>
|
||||
<div className={styles.name}>{product.name}</div>
|
||||
<div className={styles.meta}>
|
||||
<span className={styles.brand}>{product.brand}</span>
|
||||
<span className={styles.sub}>{product.subcategory}</span>
|
||||
</div>
|
||||
<div className={styles.price}>{product.price}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-xl);
|
||||
margin-top: var(--space-lg);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.imageArea {
|
||||
background: var(--color-slate-50);
|
||||
border-radius: var(--border-radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.imagePlaceholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: var(--color-slate-200);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-slate-900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sku {
|
||||
font-size: 14px;
|
||||
color: var(--color-slate-500);
|
||||
|
||||
span {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-orange-500);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: var(--space-md) 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
color: var(--color-slate-400);
|
||||
}
|
||||
|
||||
.inStock {
|
||||
color: var(--color-teal-500);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.outOfStock {
|
||||
color: var(--color-slate-400);
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-navy-500);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
57
apps/web/src/components/ProductDetail/ProductDetail.tsx
Normal file
57
apps/web/src/components/ProductDetail/ProductDetail.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Product } from '@/types';
|
||||
import { useCart } from '@/components/CartProvider/CartProvider';
|
||||
import { Icon } from '@/components/Icon/Icon';
|
||||
import { Button } from '@/components/ui/Button/Button';
|
||||
import styles from './ProductDetail.module.scss';
|
||||
|
||||
export function ProductDetail({ product }: { product: Product }) {
|
||||
const { addToCart } = useCart();
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<div className={styles.imageArea}>
|
||||
<div className={styles.imagePlaceholder} />
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
<div className={styles.brand}>{product.brand}</div>
|
||||
<h1 className={styles.name}>{product.name}</h1>
|
||||
<div className={styles.sku}>
|
||||
Артикул: <span>{product.sku}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.meta}>
|
||||
<div className={styles.metaItem}>
|
||||
<span className={styles.metaLabel}>Категория</span>
|
||||
<Link href="/catalog">{product.category}</Link>
|
||||
</div>
|
||||
<div className={styles.metaItem}>
|
||||
<span className={styles.metaLabel}>Подкатегория</span>
|
||||
<span>{product.subcategory}</span>
|
||||
</div>
|
||||
<div className={styles.metaItem}>
|
||||
<span className={styles.metaLabel}>Наличие</span>
|
||||
<span className={product.inStock ? styles.inStock : styles.outOfStock}>
|
||||
{product.inStock ? 'В наличии' : 'Под заказ'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.price}>{product.price}</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button onClick={() => addToCart(product)}>
|
||||
<Icon name="cart" size={16} color="#fff" />
|
||||
В корзину
|
||||
</Button>
|
||||
<Link href="/rfq">
|
||||
<Button variant="secondary">Запросить цену (RFQ)</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/components/RFQForm/RFQForm.module.scss
Normal file
71
apps/web/src/components/RFQForm/RFQForm.module.scss
Normal file
@@ -0,0 +1,71 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md);
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-slate-700);
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-orange-500);
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-orange-500);
|
||||
}
|
||||
}
|
||||
|
||||
.submit {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: var(--border-radius-md);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
79
apps/web/src/components/RFQForm/RFQForm.tsx
Normal file
79
apps/web/src/components/RFQForm/RFQForm.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent } from 'react';
|
||||
import styles from './RFQForm.module.scss';
|
||||
|
||||
export function RFQForm() {
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
// TODO: integrate with API
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Имя *</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="Ваше имя"
|
||||
required
|
||||
name="name"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Компания</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="Название компании"
|
||||
name="company"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Email *</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="email"
|
||||
placeholder="email@company.com"
|
||||
required
|
||||
name="email"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Телефон</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
name="phone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>
|
||||
Перечень оборудования / артикулы *
|
||||
</label>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
rows={6}
|
||||
placeholder="Укажите артикулы, названия и количество необходимого оборудования"
|
||||
required
|
||||
name="equipment"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Комментарий</label>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
rows={3}
|
||||
placeholder="Дополнительная информация, сроки, условия поставки"
|
||||
name="comment"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className={styles.submit}>
|
||||
Отправить запрос
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
49
apps/web/src/components/TrustStrip/TrustStrip.module.scss
Normal file
49
apps/web/src/components/TrustStrip/TrustStrip.module.scss
Normal file
@@ -0,0 +1,49 @@
|
||||
.section {
|
||||
background: var(--color-slate-50);
|
||||
padding: 40px var(--space-lg);
|
||||
}
|
||||
|
||||
.inner {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.iconBox {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--border-radius-md);
|
||||
background: var(--color-orange-50);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--color-slate-800);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
30
apps/web/src/components/TrustStrip/TrustStrip.tsx
Normal file
30
apps/web/src/components/TrustStrip/TrustStrip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Icon } from '@/components/Icon/Icon';
|
||||
import { IconName } from '@/components/Icon/icons';
|
||||
import styles from './TrustStrip.module.scss';
|
||||
|
||||
const items: { icon: IconName; title: string; desc: string }[] = [
|
||||
{ icon: 'truck', title: 'Доставка по РФ', desc: 'Склады в Европе, логистика до вашего города' },
|
||||
{ icon: 'shield', title: 'Гарантия', desc: 'Оригинальная продукция с сертификатами' },
|
||||
{ icon: 'clock', title: 'Быстрый отклик', desc: 'КП в течение 24 часов после запроса' },
|
||||
{ icon: 'star', title: 'Экспертиза', desc: 'Подбор аналогов и техническая поддержка' },
|
||||
];
|
||||
|
||||
export function TrustStrip() {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div className={styles.inner}>
|
||||
{items.map((t, i) => (
|
||||
<div key={i} className={styles.item}>
|
||||
<div className={styles.iconBox}>
|
||||
<Icon name={t.icon} size={24} color="var(--color-orange-500)" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.title}>{t.title}</div>
|
||||
<div className={styles.desc}>{t.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
42
apps/web/src/components/ui/Button/Button.module.scss
Normal file
42
apps/web/src/components/ui/Button/Button.module.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-md);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-slate-100);
|
||||
}
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
padding: 10px 12px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-orange-50);
|
||||
}
|
||||
}
|
||||
22
apps/web/src/components/ui/Button/Button.tsx
Normal file
22
apps/web/src/components/ui/Button/Button.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ButtonHTMLAttributes } from 'react';
|
||||
import styles from './Button.module.scss';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`${styles.button} ${styles[variant]} ${className ?? ''}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
13
apps/web/src/components/ui/Container/Container.tsx
Normal file
13
apps/web/src/components/ui/Container/Container.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ReactNode } from 'react';
|
||||
import styles from './Container.module.scss';
|
||||
|
||||
interface ContainerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Container({ children, className }: ContainerProps) {
|
||||
return (
|
||||
<div className={`${styles.container} ${className ?? ''}`}>{children}</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user