mirror of
https://git.meshkee.com/novintrades/website.git
synced 2026-08-11 20:50:58 +04:30
Initial commit: NovinTrades website monorepo.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Public website
|
||||
|
||||
English marketing site for NovinTrades.
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
- http://novintrades.local:5174 (requires `/etc/hosts` → `127.0.0.1 novintrades.local`)
|
||||
- http://127.0.0.1:5174
|
||||
|
||||
## Stack
|
||||
|
||||
- Vite + React + TypeScript
|
||||
- Montserrat
|
||||
- Light gray → blue → dark blue gradient theme
|
||||
|
||||
## Sections / pages
|
||||
|
||||
Home · About · Contact · Blog list/detail · Reportage list/detail · Brand list/detail
|
||||
|
||||
## Routes
|
||||
|
||||
- `/` home
|
||||
- `/about` `/contact`
|
||||
- `/blog` `/blog/:slug`
|
||||
- `/reportages` `/reportages/:slug`
|
||||
- `/brands` `/brands/:slug`
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="NovinTrades — discover brands, insights, and trade opportunities across industries."
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>NovinTrades</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@novintrades/web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.26.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#0B2A5B"/>
|
||||
<path d="M8 22V10h3.2l4.4 7.2V10H19v12h-3.2L11.4 14.8V22H8z" fill="#E8EEF6"/>
|
||||
<path d="M21 22V10h3v12h-3z" fill="#3B82C4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 263 B |
@@ -0,0 +1,9 @@
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app main {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import Layout from "./components/Layout";
|
||||
import AboutPage from "./pages/AboutPage";
|
||||
import BlogDetailPage from "./pages/BlogDetailPage";
|
||||
import BlogListPage from "./pages/BlogListPage";
|
||||
import BrandDetailPage from "./pages/BrandDetailPage";
|
||||
import BrandListPage from "./pages/BrandListPage";
|
||||
import ContactPage from "./pages/ContactPage";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import ReportageDetailPage from "./pages/ReportageDetailPage";
|
||||
import ReportageListPage from "./pages/ReportageListPage";
|
||||
import "./App.css";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="about" element={<AboutPage />} />
|
||||
<Route path="contact" element={<ContactPage />} />
|
||||
<Route path="blog" element={<BlogListPage />} />
|
||||
<Route path="blog/:slug" element={<BlogDetailPage />} />
|
||||
<Route path="reportages" element={<ReportageListPage />} />
|
||||
<Route path="reportages/:slug" element={<ReportageDetailPage />} />
|
||||
<Route path="brands" element={<BrandListPage />} />
|
||||
<Route path="brands/:slug" element={<BrandDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.about {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.about__layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 0.95fr;
|
||||
gap: 3.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.about__visual {
|
||||
position: relative;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.about__panel {
|
||||
position: absolute;
|
||||
border-radius: 0.65rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.about__panel--back {
|
||||
inset: 12% 18% 0 0;
|
||||
background: linear-gradient(145deg, var(--blue-soft), var(--blue-deep), var(--dark-blue));
|
||||
transform: rotate(-2.5deg);
|
||||
}
|
||||
|
||||
.about__panel--front {
|
||||
inset: 0 0 12% 8%;
|
||||
box-shadow: 0 18px 40px rgba(11, 42, 91, 0.18);
|
||||
}
|
||||
|
||||
.about__panel--front img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.about__lead {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.about__text {
|
||||
color: var(--gray-600);
|
||||
margin-bottom: 1.75rem;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
.about__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.85rem 1.3rem;
|
||||
border-radius: 0.4rem;
|
||||
background: linear-gradient(135deg, var(--blue), var(--dark-blue));
|
||||
color: var(--white);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: transform 0.25s var(--ease);
|
||||
}
|
||||
|
||||
.about__cta:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.about__layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.about__visual {
|
||||
min-height: 300px;
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import "./AboutUs.css";
|
||||
|
||||
export default function AboutUs() {
|
||||
return (
|
||||
<section className="section about" id="about">
|
||||
<div className="container about__layout">
|
||||
<div className="about__visual" aria-hidden="true">
|
||||
<div className="about__panel about__panel--back" />
|
||||
<div className="about__panel about__panel--front">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=1200&q=80"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="about__copy">
|
||||
<span className="section__eyebrow">Who we are</span>
|
||||
<h2 className="section__title">About us</h2>
|
||||
<p className="section__lead about__lead">
|
||||
NovinTrades is a marketplace of trusted brands, industry categories,
|
||||
and practical content — built to help businesses find the right
|
||||
partners faster.
|
||||
</p>
|
||||
<p className="about__text">
|
||||
We bring together verified company profiles, trade insights, and a
|
||||
clear category map so buyers and suppliers can navigate opportunities
|
||||
with confidence. From manufacturing floors to logistics corridors,
|
||||
our platform keeps discovery simple and professional.
|
||||
</p>
|
||||
<Link className="about__cta" to="/about">
|
||||
Learn more
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
.categories {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.categories__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.categories__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
padding: 1.25rem 1.15rem 1.35rem;
|
||||
border-radius: 0.55rem;
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
rgba(255, 255, 255, 0.78) 0%,
|
||||
rgba(232, 238, 246, 0.92) 100%
|
||||
);
|
||||
border: 1px solid rgba(11, 42, 91, 0.08);
|
||||
transition:
|
||||
transform 0.3s var(--ease),
|
||||
border-color 0.25s ease,
|
||||
background 0.25s ease;
|
||||
animation: rise-in 0.55s var(--ease) both;
|
||||
scroll-margin-top: calc(var(--header-h) + 1rem);
|
||||
}
|
||||
|
||||
.categories__item:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(31, 95, 168, 0.28);
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
rgba(255, 255, 255, 0.95) 0%,
|
||||
rgba(197, 216, 239, 0.55) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.categories__icon {
|
||||
width: 2.55rem;
|
||||
height: 2.55rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.45rem;
|
||||
color: var(--dark-blue);
|
||||
background: linear-gradient(145deg, var(--gray-100), var(--blue-soft));
|
||||
margin-bottom: 0.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.categories__name {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--dark-blue);
|
||||
}
|
||||
|
||||
.categories__desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--gray-600);
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.categories__status {
|
||||
margin-bottom: 1.25rem;
|
||||
color: var(--gray-600);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.categories__status--error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.categories__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.categories__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
BrickWall,
|
||||
Factory,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Gem,
|
||||
Layers,
|
||||
Package,
|
||||
UtensilsCrossed,
|
||||
} from "lucide-react";
|
||||
import { fetchTopCategories } from "../lib/services";
|
||||
import type { Category } from "../lib/types";
|
||||
import "./Categories.css";
|
||||
|
||||
const iconRules: Array<{ match: RegExp; icon: LucideIcon }> = [
|
||||
{ match: /building|construction|stone/, icon: BrickWall },
|
||||
{ match: /chemical|petro/, icon: FlaskConical },
|
||||
{ match: /food|beverage|fruit/, icon: UtensilsCrossed },
|
||||
{ match: /metal|mining|mineral|jewel/, icon: Gem },
|
||||
{ match: /oil|energy|fuel|feedstock/, icon: Flame },
|
||||
{ match: /industrial|consumer|material|textile|tech/, icon: Layers },
|
||||
{ match: /manufactur|factory/, icon: Factory },
|
||||
];
|
||||
|
||||
function categoryIcon(category: Category): LucideIcon {
|
||||
const haystack = `${category.slug} ${category.name}`.toLowerCase();
|
||||
return iconRules.find((rule) => rule.match.test(haystack))?.icon ?? Package;
|
||||
}
|
||||
|
||||
function subcategoryPreview(children: Category[] | undefined): string {
|
||||
if (!children?.length) return "Industry category";
|
||||
return children.map((child) => child.name).join(" · ");
|
||||
}
|
||||
|
||||
export default function Categories() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetchTopCategories()
|
||||
.then((items) => {
|
||||
if (active) setCategories(items);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load categories",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="section categories" id="categories">
|
||||
<div className="container">
|
||||
<div className="section__head">
|
||||
<span className="section__eyebrow">Browse by industry</span>
|
||||
<h2 className="section__title">Categories</h2>
|
||||
<p className="section__lead">
|
||||
Explore first-level industries to discover brands, partners, and opportunities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="categories__status">Loading categories…</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="categories__status categories__status--error">{error}</p>
|
||||
) : null}
|
||||
{!loading && !error && categories.length === 0 ? (
|
||||
<p className="categories__status">No categories yet.</p>
|
||||
) : null}
|
||||
|
||||
<div className="categories__grid">
|
||||
{categories.map((category, index) => {
|
||||
const Icon = categoryIcon(category);
|
||||
return (
|
||||
<Link
|
||||
key={category.id}
|
||||
id={`category-${category.slug}`}
|
||||
className="categories__item"
|
||||
to={`/brands?category=${category.slug}`}
|
||||
style={{ animationDelay: `${index * 45}ms` }}
|
||||
>
|
||||
<span className="categories__icon" aria-hidden="true">
|
||||
<Icon size={22} strokeWidth={1.75} />
|
||||
</span>
|
||||
<span className="categories__name">{category.name}</span>
|
||||
<span className="categories__desc" title={subcategoryPreview(category.children)}>
|
||||
{subcategoryPreview(category.children)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.cat-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.cat-filter__chip {
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid rgba(11, 42, 91, 0.12);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: var(--gray-600);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.cat-filter__chip:hover {
|
||||
border-color: rgba(31, 95, 168, 0.3);
|
||||
color: var(--dark-blue);
|
||||
}
|
||||
|
||||
.cat-filter__chip.is-active {
|
||||
background: linear-gradient(135deg, var(--blue-deep), var(--dark-blue));
|
||||
border-color: transparent;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.cat-filter__clear {
|
||||
align-self: center;
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--blue-deep);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { Category } from "../lib/types";
|
||||
import "./CategoryFilter.css";
|
||||
|
||||
type CategoryFilterProps = {
|
||||
categories: Category[];
|
||||
};
|
||||
|
||||
export default function CategoryFilter({ categories }: CategoryFilterProps) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const active = params.get("category");
|
||||
|
||||
function select(slug: string | null) {
|
||||
const next = new URLSearchParams(params);
|
||||
if (slug) next.set("category", slug);
|
||||
else next.delete("category");
|
||||
next.delete("page");
|
||||
setParams(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cat-filter" role="list">
|
||||
<button
|
||||
type="button"
|
||||
className={`cat-filter__chip ${!active ? "is-active" : ""}`}
|
||||
onClick={() => select(null)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
className={`cat-filter__chip ${active === category.slug ? "is-active" : ""}`}
|
||||
onClick={() => select(category.slug)}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
.content-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-card__media {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: 0.55rem;
|
||||
aspect-ratio: 16 / 11;
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.content-card__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.7s var(--ease);
|
||||
}
|
||||
|
||||
.content-card:hover .content-card__media img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.content-card__placeholder {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(145deg, var(--gray-100), var(--blue-soft), var(--blue));
|
||||
}
|
||||
|
||||
.content-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--blue-deep);
|
||||
}
|
||||
|
||||
.content-card__meta time {
|
||||
color: var(--gray-400);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.content-card__body h3 {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.3;
|
||||
color: var(--dark-blue);
|
||||
}
|
||||
|
||||
.content-card__body h3 a:hover {
|
||||
color: var(--blue-deep);
|
||||
}
|
||||
|
||||
.content-card__body p {
|
||||
font-size: 0.92rem;
|
||||
color: var(--gray-600);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-card__loc {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--blue-deep);
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.content-status {
|
||||
margin-bottom: 1.25rem;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.content-status--error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.content-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { formatDate, itemDate } from "../lib/format";
|
||||
import "./ContentCard.css";
|
||||
|
||||
type ContentCardProps = {
|
||||
to: string;
|
||||
title: string;
|
||||
abstract?: string | null;
|
||||
imageUrl?: string | null;
|
||||
publishedAt?: string | null;
|
||||
createdAt: string;
|
||||
meta?: string;
|
||||
location?: string;
|
||||
};
|
||||
|
||||
export default function ContentCard({
|
||||
to,
|
||||
title,
|
||||
abstract,
|
||||
imageUrl,
|
||||
publishedAt,
|
||||
createdAt,
|
||||
meta,
|
||||
location,
|
||||
}: ContentCardProps) {
|
||||
const dateValue = publishedAt ?? createdAt;
|
||||
|
||||
return (
|
||||
<article className="content-card">
|
||||
<Link className="content-card__media" to={to}>
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="content-card__placeholder" aria-hidden="true" />
|
||||
)}
|
||||
</Link>
|
||||
<div className="content-card__body">
|
||||
<div className="content-card__meta">
|
||||
{meta ? <span>{meta}</span> : null}
|
||||
<time dateTime={dateValue}>{formatDate(itemDate({ publishedAt: publishedAt ?? null, createdAt }))}</time>
|
||||
</div>
|
||||
<h3>
|
||||
<Link to={to}>{title}</Link>
|
||||
</h3>
|
||||
{abstract ? <p>{abstract}</p> : null}
|
||||
{location ? <span className="content-card__loc">{location}</span> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
.footer {
|
||||
background: linear-gradient(165deg, var(--dark-blue-mid) 0%, var(--dark-blue) 55%, #071d40 100%);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
padding: 4rem 0 1.75rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.footer__top {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1.8fr;
|
||||
gap: 3rem;
|
||||
padding-bottom: 2.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.footer__logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--white);
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.footer__mark {
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
border-radius: 0.3rem;
|
||||
background: linear-gradient(145deg, var(--blue-soft), var(--blue));
|
||||
}
|
||||
|
||||
.footer__brand p {
|
||||
max-width: 22rem;
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.55;
|
||||
color: rgba(232, 238, 246, 0.78);
|
||||
}
|
||||
|
||||
.footer__cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.footer__col h3 {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.footer__col ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.footer__col a {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.footer__col a:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.footer__bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-top: 1.35rem;
|
||||
font-size: 0.84rem;
|
||||
color: rgba(232, 238, 246, 0.62);
|
||||
}
|
||||
|
||||
.footer__legal {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.footer__legal a:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.footer__top {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.25rem;
|
||||
}
|
||||
|
||||
.footer__cols {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.footer__cols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.footer__bottom {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import "./Footer.css";
|
||||
|
||||
const footerLinks = [
|
||||
{
|
||||
title: "Explore",
|
||||
links: [
|
||||
{ label: "Blog", to: "/blog" },
|
||||
{ label: "Reportages", to: "/reportages" },
|
||||
{ label: "Brands", to: "/brands" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Company",
|
||||
links: [
|
||||
{ label: "About us", to: "/about" },
|
||||
{ label: "Contact", to: "/contact" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
links: [
|
||||
{ label: "Latest blog", to: "/blog" },
|
||||
{ label: "Partner with us", to: "/brands" },
|
||||
{ label: "Contact us", to: "/contact" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="container footer__top">
|
||||
<div className="footer__brand">
|
||||
<Link className="footer__logo" to="/">
|
||||
<span className="footer__mark" aria-hidden="true" />
|
||||
NovinTrades
|
||||
</Link>
|
||||
<p>
|
||||
Discover brands, categories, and trade insights in one clear place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="footer__cols">
|
||||
{footerLinks.map((group) => (
|
||||
<div key={group.title} className="footer__col">
|
||||
<h3>{group.title}</h3>
|
||||
<ul>
|
||||
{group.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link to={link.to}>{link.label}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container footer__bottom">
|
||||
<p>© {new Date().getFullYear()} NovinTrades. All rights reserved.</p>
|
||||
<div className="footer__legal">
|
||||
<Link to="/contact">Contact</Link>
|
||||
<Link to="/about">About</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
.header {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
z-index: 50;
|
||||
height: var(--header-h);
|
||||
transition:
|
||||
background 0.35s var(--ease),
|
||||
box-shadow 0.35s var(--ease),
|
||||
backdrop-filter 0.35s var(--ease);
|
||||
}
|
||||
|
||||
.header--scrolled {
|
||||
background: rgba(244, 246, 249, 0.88);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 1px 0 rgba(11, 42, 91, 0.08);
|
||||
}
|
||||
|
||||
.header__inner {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.header__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header__mark {
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
border-radius: 0.35rem;
|
||||
background: linear-gradient(145deg, #9ec0e8 0%, var(--blue) 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
||||
transition: background 0.35s var(--ease);
|
||||
}
|
||||
|
||||
.header--scrolled .header__mark {
|
||||
background: linear-gradient(145deg, var(--blue) 0%, var(--dark-blue) 100%);
|
||||
}
|
||||
|
||||
.header__name {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--white);
|
||||
transition: color 0.35s var(--ease);
|
||||
}
|
||||
|
||||
.header--scrolled .header__name {
|
||||
color: var(--dark-blue);
|
||||
}
|
||||
|
||||
.header__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.header__nav > a,
|
||||
.header__drop-btn {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.header__nav > a:hover,
|
||||
.header__nav > a.is-active,
|
||||
.header__drop-btn:hover,
|
||||
.header__drop-btn.is-open {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.header--scrolled .header__nav > a,
|
||||
.header--scrolled .header__drop-btn {
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.header--scrolled .header__nav > a:hover,
|
||||
.header--scrolled .header__nav > a.is-active,
|
||||
.header--scrolled .header__drop-btn:hover,
|
||||
.header--scrolled .header__drop-btn.is-open {
|
||||
color: var(--dark-blue);
|
||||
}
|
||||
|
||||
.header__dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header__drop-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.header__drop-btn svg {
|
||||
transition: transform 0.25s var(--ease);
|
||||
}
|
||||
|
||||
.header__drop-btn.is-open svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.header__menu-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.75rem);
|
||||
left: 0;
|
||||
min-width: 15rem;
|
||||
max-height: min(70vh, 22rem);
|
||||
overflow: auto;
|
||||
padding: 0.45rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border: 1px solid rgba(11, 42, 91, 0.1);
|
||||
box-shadow: 0 12px 28px rgba(11, 42, 91, 0.12);
|
||||
}
|
||||
|
||||
.header__menu-panel a {
|
||||
display: block;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 0.35rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--dark-blue);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.header__menu-panel a:hover {
|
||||
background: rgba(197, 216, 239, 0.55);
|
||||
}
|
||||
|
||||
.header__menu-empty {
|
||||
padding: 0.75rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.header__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.header__login {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
border-radius: 0.4rem;
|
||||
color: var(--white);
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.35s var(--ease),
|
||||
border-color 0.35s var(--ease);
|
||||
}
|
||||
|
||||
.header__login:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.header--scrolled .header__login {
|
||||
color: var(--dark-blue);
|
||||
border-color: rgba(11, 42, 91, 0.14);
|
||||
}
|
||||
|
||||
.header--scrolled .header__login:hover {
|
||||
background: rgba(197, 216, 239, 0.45);
|
||||
}
|
||||
|
||||
.header__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.65rem 1.15rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--white);
|
||||
color: var(--dark-blue);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition:
|
||||
transform 0.25s var(--ease),
|
||||
opacity 0.2s ease,
|
||||
background 0.35s var(--ease),
|
||||
color 0.35s var(--ease);
|
||||
}
|
||||
|
||||
.header--scrolled .header__cta {
|
||||
background: linear-gradient(135deg, var(--blue-deep), var(--dark-blue));
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.header__cta:hover {
|
||||
transform: translateY(-1px);
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.header__burger {
|
||||
display: none;
|
||||
margin-left: auto;
|
||||
color: var(--white);
|
||||
padding: 0.35rem;
|
||||
transition: color 0.35s var(--ease);
|
||||
}
|
||||
|
||||
.header--scrolled .header__burger {
|
||||
color: var(--dark-blue);
|
||||
}
|
||||
|
||||
.header__drawer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.header__nav,
|
||||
.header__actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header__burger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.header__drawer {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: var(--header-h) 0 auto;
|
||||
padding: 1.25rem 1.5rem 1.75rem;
|
||||
background: rgba(244, 246, 249, 0.97);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid rgba(11, 42, 91, 0.08);
|
||||
transform: translateY(-120%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform 0.35s var(--ease),
|
||||
opacity 0.35s var(--ease);
|
||||
max-height: calc(100dvh - var(--header-h));
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.header__drawer--open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.header__drawer nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.header__drawer-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-400);
|
||||
margin: 0.25rem 0 0.35rem;
|
||||
}
|
||||
|
||||
.header__drawer a {
|
||||
padding: 0.75rem 0.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--dark-blue);
|
||||
border-bottom: 1px solid rgba(11, 42, 91, 0.06);
|
||||
}
|
||||
|
||||
.header__drawer-login {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.header__drawer-cta {
|
||||
margin-top: 0.75rem;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
padding: 0.85rem 1rem !important;
|
||||
border-radius: 0.4rem;
|
||||
background: linear-gradient(135deg, var(--blue-deep), var(--dark-blue));
|
||||
color: var(--white) !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { ChevronDown, LogIn, Menu, X } from "lucide-react";
|
||||
import { ADMIN_URL, fetchTopCategories } from "../lib/services";
|
||||
import type { Category } from "../lib/types";
|
||||
import "./Header.css";
|
||||
|
||||
const links = [
|
||||
{ label: "Blog", to: "/blog" },
|
||||
{ label: "Reportages", to: "/reportages" },
|
||||
{ label: "Brands", to: "/brands" },
|
||||
{ label: "About", to: "/about" },
|
||||
];
|
||||
|
||||
type HeaderProps = {
|
||||
solid?: boolean;
|
||||
};
|
||||
|
||||
export default function Header({ solid = false }: HeaderProps) {
|
||||
const [scrolled, setScrolled] = useState(solid);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [catsOpen, setCatsOpen] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (solid) {
|
||||
setScrolled(true);
|
||||
return;
|
||||
}
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, [solid]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetchTopCategories()
|
||||
.then((items) => {
|
||||
if (active) setCategories(items);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setCategories([]);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!catsOpen) return;
|
||||
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!dropdownRef.current?.contains(event.target as Node)) {
|
||||
setCatsOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setCatsOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [catsOpen]);
|
||||
|
||||
return (
|
||||
<header className={`header ${scrolled || solid ? "header--scrolled" : ""}`}>
|
||||
<div className="container header__inner">
|
||||
<Link className="header__brand" to="/" aria-label="NovinTrades home">
|
||||
<span className="header__mark" aria-hidden="true" />
|
||||
<span className="header__name">NovinTrades</span>
|
||||
</Link>
|
||||
|
||||
<nav className="header__nav" aria-label="Primary">
|
||||
<div className="header__dropdown" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`header__drop-btn ${catsOpen ? "is-open" : ""}`}
|
||||
aria-expanded={catsOpen}
|
||||
aria-haspopup="true"
|
||||
onClick={() => setCatsOpen((value) => !value)}
|
||||
>
|
||||
Categories
|
||||
<ChevronDown size={15} strokeWidth={2.25} />
|
||||
</button>
|
||||
{catsOpen ? (
|
||||
<div className="header__menu-panel" role="menu">
|
||||
{categories.length === 0 ? (
|
||||
<p className="header__menu-empty">No categories yet</p>
|
||||
) : (
|
||||
categories.map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
role="menuitem"
|
||||
to={`/brands?category=${category.slug}`}
|
||||
onClick={() => setCatsOpen(false)}
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{links.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={({ isActive }) => (isActive ? "is-active" : undefined)}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="header__actions">
|
||||
<a
|
||||
className="header__login"
|
||||
href={`${ADMIN_URL}/login`}
|
||||
aria-label="Log in"
|
||||
title="Log in"
|
||||
>
|
||||
<LogIn size={18} strokeWidth={2} />
|
||||
</a>
|
||||
<Link className="header__cta" to="/brands">
|
||||
Explore brands
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="header__burger"
|
||||
type="button"
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{open ? <X size={22} /> : <Menu size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`header__drawer ${open ? "header__drawer--open" : ""}`}>
|
||||
<nav aria-label="Mobile">
|
||||
<p className="header__drawer-label">Categories</p>
|
||||
{categories.map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
to={`/brands?category=${category.slug}`}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
))}
|
||||
{links.map((link) => (
|
||||
<Link key={link.to} to={link.to} onClick={() => setOpen(false)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<a
|
||||
className="header__drawer-login"
|
||||
href={`${ADMIN_URL}/login`}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<LogIn size={16} />
|
||||
Log in
|
||||
</a>
|
||||
<Link
|
||||
className="header__drawer-cta"
|
||||
to="/brands"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Explore brands
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
.blog {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.35) 20%,
|
||||
rgba(232, 238, 246, 0.55) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.blog__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.blog__all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--blue-deep);
|
||||
padding-bottom: 0.15rem;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.blog__all:hover {
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.blog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.blog__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blog__media {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: 0.55rem;
|
||||
aspect-ratio: 16 / 11;
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.blog__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.7s var(--ease);
|
||||
}
|
||||
|
||||
.blog__item:hover .blog__media img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.blog__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.blog__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--blue-deep);
|
||||
}
|
||||
|
||||
.blog__meta time {
|
||||
color: var(--gray-400);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.blog__body h3 {
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.3;
|
||||
color: var(--dark-blue);
|
||||
}
|
||||
|
||||
.blog__body h3 a:hover {
|
||||
color: var(--blue-deep);
|
||||
}
|
||||
|
||||
.blog__body p {
|
||||
font-size: 0.94rem;
|
||||
color: var(--gray-600);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.blog__placeholder {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(145deg, var(--gray-100), var(--blue-soft), var(--blue));
|
||||
}
|
||||
|
||||
.blog__status {
|
||||
margin-bottom: 1.25rem;
|
||||
color: var(--gray-600);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.blog__status--error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.blog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.blog__head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { formatDate, itemDate } from "../lib/format";
|
||||
import { fetchLatestBlogs } from "../lib/services";
|
||||
import type { Blog } from "../lib/types";
|
||||
import "./LatestBlog.css";
|
||||
|
||||
export default function LatestBlog() {
|
||||
const [posts, setPosts] = useState<Blog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetchLatestBlogs(3)
|
||||
.then((items) => {
|
||||
if (active) setPosts(items);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : "Could not load blogs");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="section blog" id="blog">
|
||||
<div className="container">
|
||||
<div className="section__head blog__head">
|
||||
<div>
|
||||
<span className="section__eyebrow">Fresh reading</span>
|
||||
<h2 className="section__title">Latest blog</h2>
|
||||
<p className="section__lead">
|
||||
Practical insights and market notes for buyers, sellers, and operators.
|
||||
</p>
|
||||
</div>
|
||||
<Link className="blog__all" to="/blog">
|
||||
View all articles
|
||||
<ArrowUpRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="blog__status">Loading articles…</p> : null}
|
||||
{error ? <p className="blog__status blog__status--error">{error}</p> : null}
|
||||
{!loading && !error && posts.length === 0 ? (
|
||||
<p className="blog__status">No published articles yet.</p>
|
||||
) : null}
|
||||
|
||||
<div className="blog__grid">
|
||||
{posts.map((post) => {
|
||||
const category = post.categories[0]?.category.name ?? "Blog";
|
||||
const dateValue = itemDate(post);
|
||||
return (
|
||||
<article key={post.id} className="blog__item">
|
||||
<Link className="blog__media" to={`/blog/${post.slug}`}>
|
||||
{post.imageUrl ? (
|
||||
<img src={post.imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="blog__placeholder" aria-hidden="true" />
|
||||
)}
|
||||
</Link>
|
||||
<div className="blog__body">
|
||||
<div className="blog__meta">
|
||||
<span>{category}</span>
|
||||
<time dateTime={dateValue}>{formatDate(dateValue)}</time>
|
||||
</div>
|
||||
<h3>
|
||||
<Link to={`/blog/${post.slug}`}>{post.title}</Link>
|
||||
</h3>
|
||||
{post.abstract ? <p>{post.abstract}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
.brands {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(197, 216, 239, 0.25) 0%,
|
||||
rgba(31, 95, 168, 0.08) 55%,
|
||||
rgba(11, 42, 91, 0.06) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.brands__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.brands__item {
|
||||
display: grid;
|
||||
grid-template-columns: 7.5rem minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 0.85rem;
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
border: 1px solid rgba(11, 42, 91, 0.08);
|
||||
transition:
|
||||
transform 0.3s var(--ease),
|
||||
border-color 0.25s ease,
|
||||
background 0.25s ease;
|
||||
}
|
||||
|
||||
.brands__item:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(31, 95, 168, 0.25);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.brands__media {
|
||||
width: 7.5rem;
|
||||
height: 7.5rem;
|
||||
overflow: hidden;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--blue-soft);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brands__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.6s var(--ease);
|
||||
}
|
||||
|
||||
.brands__item:hover .brands__media img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.brands__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brands__body h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--dark-blue);
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brands__body p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--gray-600);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brands__loc {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--blue-deep);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brands__placeholder {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(145deg, var(--gray-100), var(--blue-soft), var(--blue-deep));
|
||||
}
|
||||
|
||||
.brands__status {
|
||||
margin-bottom: 1.25rem;
|
||||
color: var(--gray-600);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.brands__status--error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.brands__grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.brands__item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.brands__media {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
.brands__body h3 {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { fetchLatestBrands } from "../lib/services";
|
||||
import type { Brand } from "../lib/types";
|
||||
import "./LatestBrands.css";
|
||||
|
||||
export default function LatestBrands() {
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetchLatestBrands(4)
|
||||
.then((items) => {
|
||||
if (active) setBrands(items);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : "Could not load brands");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="section brands" id="brands">
|
||||
<div className="container">
|
||||
<div className="section__head">
|
||||
<span className="section__eyebrow">Partner directory</span>
|
||||
<h2 className="section__title">Latest brands</h2>
|
||||
<p className="section__lead">
|
||||
Newly featured companies ready to connect across markets and industries.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="brands__status">Loading brands…</p> : null}
|
||||
{error ? (
|
||||
<p className="brands__status brands__status--error">{error}</p>
|
||||
) : null}
|
||||
{!loading && !error && brands.length === 0 ? (
|
||||
<p className="brands__status">No published brands yet.</p>
|
||||
) : null}
|
||||
|
||||
<div className="brands__grid">
|
||||
{brands.map((brand) => (
|
||||
<Link
|
||||
key={brand.id}
|
||||
className="brands__item"
|
||||
to={`/brands/${brand.slug}`}
|
||||
>
|
||||
<div className="brands__media">
|
||||
{brand.imageUrl ? (
|
||||
<img src={brand.imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="brands__placeholder" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="brands__body">
|
||||
<h3>{brand.title}</h3>
|
||||
{brand.abstract ? <p>{brand.abstract}</p> : null}
|
||||
<span className="brands__loc">
|
||||
<MapPin size={14} strokeWidth={2} />
|
||||
{[brand.city, brand.country].filter(Boolean).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import Header from "./Header";
|
||||
import Footer from "./Footer";
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname, search } = useLocation();
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
}, [pathname, search]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { pathname } = useLocation();
|
||||
const isHome = pathname === "/";
|
||||
|
||||
return (
|
||||
<div className={`app ${isHome ? "app--home" : "app--page"}`}>
|
||||
<ScrollToTop />
|
||||
<Header solid={!isHome} />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
.page-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 22rem;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: calc(var(--header-h) + 2.5rem) 0 3rem;
|
||||
background: linear-gradient(135deg, #9eb6d4, var(--blue-deep), var(--dark-blue));
|
||||
}
|
||||
|
||||
.page-hero__media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.page-hero__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.page-hero__wash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(
|
||||
105deg,
|
||||
rgba(11, 42, 91, 0.86) 0%,
|
||||
rgba(31, 95, 168, 0.62) 48%,
|
||||
rgba(11, 42, 91, 0.45) 100%
|
||||
),
|
||||
linear-gradient(180deg, rgba(11, 42, 91, 0.35) 0%, transparent 40%, rgba(11, 42, 91, 0.5) 100%);
|
||||
}
|
||||
|
||||
.page-hero__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 44rem;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.page-hero__home {
|
||||
display: inline-block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.page-hero__home:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.page-hero__eyebrow {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(197, 216, 239, 0.95);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.page-hero__title {
|
||||
font-size: clamp(1.9rem, 3.5vw, 2.75rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--white);
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.page-hero__lead {
|
||||
color: rgba(232, 238, 246, 0.9);
|
||||
font-size: 1.05rem;
|
||||
max-width: 38rem;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import "./PageHero.css";
|
||||
|
||||
const DEFAULT_IMAGE =
|
||||
"https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=2000&q=80";
|
||||
|
||||
type PageHeroProps = {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead?: string;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
export default function PageHero({
|
||||
eyebrow,
|
||||
title,
|
||||
lead,
|
||||
image = DEFAULT_IMAGE,
|
||||
}: PageHeroProps) {
|
||||
return (
|
||||
<section className="page-hero">
|
||||
<div className="page-hero__media" aria-hidden="true">
|
||||
<img className="page-hero__image" src={image} alt="" />
|
||||
<div className="page-hero__wash" />
|
||||
</div>
|
||||
<div className="container page-hero__inner">
|
||||
<Link className="page-hero__home" to="/">
|
||||
Home
|
||||
</Link>
|
||||
<span className="page-hero__eyebrow">{eyebrow}</span>
|
||||
<h1 className="page-hero__title">{title}</h1>
|
||||
{lead ? <p className="page-hero__lead">{lead}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.pagination__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.6rem 0.95rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid rgba(11, 42, 91, 0.12);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: var(--dark-blue);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pagination__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination__btn:not(:disabled):hover {
|
||||
border-color: rgba(31, 95, 168, 0.3);
|
||||
}
|
||||
|
||||
.pagination__status {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import "./Pagination.css";
|
||||
|
||||
type PaginationProps = {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
export default function Pagination({ page, totalPages }: PaginationProps) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
function go(nextPage: number) {
|
||||
const next = new URLSearchParams(params);
|
||||
if (nextPage <= 1) next.delete("page");
|
||||
else next.set("page", String(nextPage));
|
||||
setParams(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="pagination" aria-label="Pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="pagination__btn"
|
||||
disabled={page <= 1}
|
||||
onClick={() => go(page - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Prev
|
||||
</button>
|
||||
<span className="pagination__status">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="pagination__btn"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => go(page + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
type RichHtmlProps = {
|
||||
html: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function RichHtml({ html, className }: RichHtmlProps) {
|
||||
if (!html.trim()) return null;
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
.slider {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.slider__stage {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #9eb6d4, var(--blue-deep), var(--dark-blue));
|
||||
}
|
||||
|
||||
.slider__slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 1s var(--ease);
|
||||
}
|
||||
|
||||
.slider__slide.is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slider__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scale(1.04);
|
||||
animation: slider-pan 12s var(--ease) infinite alternate;
|
||||
}
|
||||
|
||||
.slider__slide.is-active .slider__image {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
.slider__wash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(
|
||||
105deg,
|
||||
rgba(11, 42, 91, 0.82) 0%,
|
||||
rgba(31, 95, 168, 0.55) 48%,
|
||||
rgba(244, 246, 249, 0.18) 100%
|
||||
),
|
||||
linear-gradient(180deg, rgba(11, 42, 91, 0.35) 0%, transparent 35%, rgba(11, 42, 91, 0.45) 100%);
|
||||
}
|
||||
|
||||
.slider__content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: calc(var(--header-h) + 2rem) 0 6.5rem;
|
||||
}
|
||||
|
||||
.slider__copy {
|
||||
max-width: 40rem;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.slider__brand {
|
||||
font-size: clamp(2rem, 6vw, 3.75rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1;
|
||||
margin-bottom: 1.1rem;
|
||||
animation: rise-in 0.8s var(--ease) both;
|
||||
}
|
||||
|
||||
.slider__title {
|
||||
font-size: clamp(1.35rem, 2.8vw, 1.85rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.25;
|
||||
max-width: 18ch;
|
||||
margin-bottom: 0.85rem;
|
||||
animation: rise-in 0.75s var(--ease) 0.08s both;
|
||||
}
|
||||
|
||||
.slider__subtitle {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
max-width: 34rem;
|
||||
margin-bottom: 1.75rem;
|
||||
animation: rise-in 0.75s var(--ease) 0.14s both;
|
||||
}
|
||||
|
||||
.slider__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem 1.35rem;
|
||||
animation: rise-in 0.75s var(--ease) 0.2s both;
|
||||
}
|
||||
|
||||
.slider__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.9rem 1.35rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--white);
|
||||
color: var(--dark-blue);
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
transition: transform 0.25s var(--ease);
|
||||
}
|
||||
|
||||
.slider__cta:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.slider__link {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.45);
|
||||
padding-bottom: 0.1rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.slider__link:hover {
|
||||
border-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.slider__controls {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.slider__arrow {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 0.4rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--white);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.slider__arrow:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.slider__dots {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.slider__dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transition:
|
||||
background 0.25s ease,
|
||||
transform 0.25s var(--ease);
|
||||
}
|
||||
|
||||
.slider__dot.is-active {
|
||||
background: var(--white);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slider-pan {
|
||||
from {
|
||||
transform: scale(1.04) translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: scale(1.1) translate3d(-1.5%, -1%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.slider__content {
|
||||
padding-bottom: 5.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.slider__brand {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.slider__subtitle {
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { slides } from "../data";
|
||||
import "./Slider.css";
|
||||
|
||||
export default function Slider() {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setIndex((current) => (current + 1) % slides.length);
|
||||
}, 6500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const go = (next: number) => {
|
||||
setIndex((next + slides.length) % slides.length);
|
||||
};
|
||||
|
||||
const active = slides[index];
|
||||
|
||||
return (
|
||||
<section className="slider" id="top" aria-label="Featured highlights">
|
||||
<div className="slider__stage">
|
||||
{slides.map((slide, i) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
className={`slider__slide ${i === index ? "is-active" : ""}`}
|
||||
aria-hidden={i !== index}
|
||||
>
|
||||
<img
|
||||
className="slider__image"
|
||||
src={slide.image}
|
||||
alt=""
|
||||
loading={i === 0 ? "eager" : "lazy"}
|
||||
/>
|
||||
<div className="slider__wash" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="slider__content">
|
||||
<div className="container slider__copy">
|
||||
<p className="slider__brand">NovinTrades</p>
|
||||
<h1 key={active.id} className="slider__title">
|
||||
{active.title}
|
||||
</h1>
|
||||
<p key={`${active.id}-sub`} className="slider__subtitle">
|
||||
{active.subtitle}
|
||||
</p>
|
||||
<div className="slider__actions">
|
||||
<Link className="slider__cta" to={active.href}>
|
||||
{active.cta}
|
||||
</Link>
|
||||
<Link className="slider__link" to="/about">
|
||||
About us
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="slider__controls">
|
||||
<button
|
||||
type="button"
|
||||
className="slider__arrow"
|
||||
aria-label="Previous slide"
|
||||
onClick={() => go(index - 1)}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<div className="slider__dots" role="tablist" aria-label="Slides">
|
||||
{slides.map((slide, i) => (
|
||||
<button
|
||||
key={slide.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
className={`slider__dot ${i === index ? "is-active" : ""}`}
|
||||
onClick={() => setIndex(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="slider__arrow"
|
||||
aria-label="Next slide"
|
||||
onClick={() => go(index + 1)}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export type Slide = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
image: string;
|
||||
cta: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export const slides: Slide[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Connect with verified trade partners",
|
||||
subtitle:
|
||||
"Explore brands, suppliers, and market insights across global industries.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?auto=format&fit=crop&w=2000&q=80",
|
||||
cta: "Browse brands",
|
||||
href: "/brands",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Insights that move markets",
|
||||
subtitle:
|
||||
"Stay ahead with guides, news, and analysis written for modern traders.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?auto=format&fit=crop&w=2000&q=80",
|
||||
cta: "Read the blog",
|
||||
href: "/blog",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Find your industry in one place",
|
||||
subtitle:
|
||||
"From manufacturing to logistics — discover opportunities by category.",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1566576912321-d58ddd7a6088?auto=format&fit=crop&w=2000&q=80",
|
||||
cta: "Explore categories",
|
||||
href: "/#categories",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,112 @@
|
||||
:root {
|
||||
--gray-50: #f4f6f9;
|
||||
--gray-100: #e8eef6;
|
||||
--gray-200: #d5dee9;
|
||||
--gray-400: #8a97ab;
|
||||
--gray-600: #4a5870;
|
||||
--gray-800: #1c2740;
|
||||
--blue-soft: #c5d8ef;
|
||||
--blue: #3b82c4;
|
||||
--blue-deep: #1f5fa8;
|
||||
--dark-blue: #0b2a5b;
|
||||
--dark-blue-mid: #143a73;
|
||||
--white: #ffffff;
|
||||
--font: "Montserrat", sans-serif;
|
||||
--max: 1180px;
|
||||
--header-h: 72px;
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
color: var(--gray-800);
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, var(--blue-soft) 0%, transparent 55%),
|
||||
radial-gradient(900px 500px at 100% 0%, #b8cce4 0%, transparent 50%),
|
||||
linear-gradient(180deg, var(--gray-50) 0%, #eef3f9 35%, #dce7f4 70%, #c5d4ea 100%);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(100% - 2.5rem, var(--max));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.section__eyebrow {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--blue-deep);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section__title {
|
||||
font-size: clamp(1.75rem, 3vw, 2.35rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--dark-blue);
|
||||
line-height: 1.15;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.section__lead {
|
||||
max-width: 36rem;
|
||||
color: var(--gray-600);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.section__head {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.container {
|
||||
width: min(100% - 1.5rem, var(--max));
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 3.5rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<string> {
|
||||
try {
|
||||
const data = (await response.json()) as { error?: unknown };
|
||||
if (typeof data.error === "string") return data.error;
|
||||
return response.statusText || "Request failed";
|
||||
} catch {
|
||||
return response.statusText || "Request failed";
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, await parseError(response));
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function itemDate(item: {
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
}) {
|
||||
return item.publishedAt ?? item.createdAt;
|
||||
}
|
||||
|
||||
export function categoryNames(
|
||||
categories: Array<{ category: { name: string } }>,
|
||||
) {
|
||||
return categories.map((link) => link.category.name);
|
||||
}
|
||||
|
||||
export function categoryIds(
|
||||
categories: Array<{ categoryId: string }>,
|
||||
) {
|
||||
return categories.map((link) => link.categoryId);
|
||||
}
|
||||
|
||||
export function byNewest(
|
||||
a: { publishedAt: string | null; createdAt: string },
|
||||
b: { publishedAt: string | null; createdAt: string },
|
||||
) {
|
||||
return Date.parse(itemDate(b)) - Date.parse(itemDate(a));
|
||||
}
|
||||
|
||||
export function paginate<T>(items: T[], page: number, pageSize: number) {
|
||||
const total = items.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const current = Math.min(Math.max(1, page), totalPages);
|
||||
const start = (current - 1) * pageSize;
|
||||
return {
|
||||
items: items.slice(start, start + pageSize),
|
||||
page: current,
|
||||
totalPages,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export function sharesCategory(
|
||||
item: { categories: Array<{ categoryId: string }> },
|
||||
categoryIdSet: Set<string>,
|
||||
) {
|
||||
return item.categories.some((link) => categoryIdSet.has(link.categoryId));
|
||||
}
|
||||
|
||||
export function filterByCategorySlug<
|
||||
T extends { categories: Array<{ category: { slug: string } }> },
|
||||
>(items: T[], categorySlug: string | null) {
|
||||
if (!categorySlug) return items;
|
||||
return items.filter((item) =>
|
||||
item.categories.some((link) => link.category.slug === categorySlug),
|
||||
);
|
||||
}
|
||||
|
||||
export function similarItems<
|
||||
TCurrent extends { id: string; categories: Array<{ categoryId: string }> },
|
||||
TPool extends { id: string; categories: Array<{ categoryId: string }> },
|
||||
>(current: TCurrent, pool: TPool[], limit = 3): TPool[] {
|
||||
const ids = new Set(categoryIds(current.categories));
|
||||
return pool
|
||||
.filter((item) => item.id !== current.id && sharesCategory(item, ids))
|
||||
.slice(0, limit);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { api } from "./api";
|
||||
import { byNewest, filterByCategorySlug, paginate } from "./format";
|
||||
import type { Blog, Brand, Category, Reportage } from "./types";
|
||||
|
||||
export const PAGE_SIZE = 9;
|
||||
|
||||
export const ADMIN_URL =
|
||||
import.meta.env.VITE_ADMIN_URL ?? "http://novintrades.local:5173";
|
||||
|
||||
export async function fetchTopCategories(): Promise<Category[]> {
|
||||
const categories = await api<Category[]>("/api/categories");
|
||||
return categories
|
||||
.filter((category) => category.parentId === null)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function fetchApprovedBlogs(): Promise<Blog[]> {
|
||||
const blogs = await api<Blog[]>("/api/blogs");
|
||||
return blogs
|
||||
.filter((blog) => blog.verificationStatus === "APPROVED")
|
||||
.sort(byNewest);
|
||||
}
|
||||
|
||||
export async function fetchApprovedReportages(): Promise<Reportage[]> {
|
||||
const reportages = await api<Reportage[]>("/api/reportages");
|
||||
return reportages
|
||||
.filter((item) => item.verificationStatus === "APPROVED")
|
||||
.sort(byNewest);
|
||||
}
|
||||
|
||||
export async function fetchApprovedBrands(): Promise<Brand[]> {
|
||||
const brands = await api<Brand[]>("/api/brands");
|
||||
return brands
|
||||
.filter((brand) => brand.verificationStatus === "APPROVED")
|
||||
.sort(byNewest);
|
||||
}
|
||||
|
||||
export async function fetchLatestBlogs(limit = 3): Promise<Blog[]> {
|
||||
const blogs = await fetchApprovedBlogs();
|
||||
return blogs.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function fetchLatestBrands(limit = 4): Promise<Brand[]> {
|
||||
const brands = await fetchApprovedBrands();
|
||||
return brands.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function fetchBlogBySlug(slug: string): Promise<Blog | null> {
|
||||
const blogs = await fetchApprovedBlogs();
|
||||
return blogs.find((blog) => blog.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export async function fetchReportageBySlug(
|
||||
slug: string,
|
||||
): Promise<Reportage | null> {
|
||||
const reportages = await fetchApprovedReportages();
|
||||
return reportages.find((item) => item.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export async function fetchBrandBySlug(slug: string): Promise<Brand | null> {
|
||||
const brands = await fetchApprovedBrands();
|
||||
return brands.find((brand) => brand.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export function listPage<T extends { categories: Array<{ category: { slug: string } }> }>(
|
||||
items: T[],
|
||||
categorySlug: string | null,
|
||||
page: number,
|
||||
pageSize = PAGE_SIZE,
|
||||
) {
|
||||
return paginate(filterByCategorySlug(items, categorySlug), page, pageSize);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export type VerificationStatus = "PENDING" | "APPROVED" | "REJECTED";
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
parentId: string | null;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
export interface CategoryLink {
|
||||
categoryId: string;
|
||||
category: Category;
|
||||
}
|
||||
|
||||
export interface Blog {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
abstract: string | null;
|
||||
content: string;
|
||||
imageUrl: string | null;
|
||||
tags: string[];
|
||||
verificationStatus: VerificationStatus;
|
||||
categories: CategoryLink[];
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Reportage {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
businessOwner: string;
|
||||
abstract: string | null;
|
||||
content: string;
|
||||
imageUrl: string | null;
|
||||
tags: string[];
|
||||
verificationStatus: VerificationStatus;
|
||||
categories: CategoryLink[];
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BrandContact {
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Brand {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
abstract: string | null;
|
||||
content: string;
|
||||
imageUrl: string | null;
|
||||
galleryUrls: string[];
|
||||
tags: string[];
|
||||
country: string;
|
||||
city: string | null;
|
||||
address: string | null;
|
||||
contacts?: BrandContact[];
|
||||
verificationStatus: VerificationStatus;
|
||||
categories: CategoryLink[];
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ContentItem = Blog | Reportage | Brand;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,183 @@
|
||||
.about-page__section--soft {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.35) 0%,
|
||||
rgba(232, 238, 246, 0.55) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.about-page__split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.about-page__split--reverse .about-page__block {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.about-page__split--reverse .about-page__visual {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.about-page__block {
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.about-page__block p {
|
||||
color: var(--gray-600);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.about-page__visual {
|
||||
position: relative;
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.about-page__panel {
|
||||
position: absolute;
|
||||
border-radius: 0.65rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.about-page__panel--back {
|
||||
inset: 12% 18% 0 0;
|
||||
background: linear-gradient(145deg, var(--blue-soft), var(--blue-deep), var(--dark-blue));
|
||||
transform: rotate(-2.5deg);
|
||||
}
|
||||
|
||||
.about-page__panel--back-alt {
|
||||
inset: 10% 0 0 16%;
|
||||
transform: rotate(2.5deg);
|
||||
}
|
||||
|
||||
.about-page__panel--front {
|
||||
inset: 0 0 12% 8%;
|
||||
box-shadow: 0 18px 40px rgba(11, 42, 91, 0.18);
|
||||
}
|
||||
|
||||
.about-page__split--reverse .about-page__panel--front {
|
||||
inset: 0 8% 12% 0;
|
||||
}
|
||||
|
||||
.about-page__panel--front img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.about-page__media {
|
||||
overflow: hidden;
|
||||
border-radius: 0.65rem;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.about-page__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.about-page__media--wide {
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.about-page__expertise-head {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.about-page__expertise-head .about-page__block {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.about-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.about-page__grid article {
|
||||
padding: 1.25rem 1.15rem;
|
||||
border-radius: 0.55rem;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(11, 42, 91, 0.08);
|
||||
}
|
||||
|
||||
.about-page__grid h3 {
|
||||
font-size: 1.02rem;
|
||||
color: var(--dark-blue);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.about-page__grid p {
|
||||
color: var(--gray-600);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.about-page__join {
|
||||
background: linear-gradient(145deg, var(--blue-deep), var(--dark-blue));
|
||||
color: var(--white);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.about-page__join .section__eyebrow {
|
||||
color: rgba(232, 238, 246, 0.75);
|
||||
}
|
||||
|
||||
.about-page__join .section__title {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.about-page__join p {
|
||||
color: rgba(232, 238, 246, 0.86);
|
||||
max-width: 34rem;
|
||||
margin-bottom: 1.35rem;
|
||||
}
|
||||
|
||||
.about-page__join-copy {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.about-page__media--join {
|
||||
aspect-ratio: 5 / 4;
|
||||
}
|
||||
|
||||
.about-page__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.9rem 1.35rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--white);
|
||||
color: var(--dark-blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.about-page__split,
|
||||
.about-page__split--reverse,
|
||||
.about-page__join-copy,
|
||||
.about-page__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.about-page__split--reverse .about-page__block,
|
||||
.about-page__split--reverse .about-page__visual {
|
||||
order: initial;
|
||||
}
|
||||
|
||||
.about-page__visual {
|
||||
min-height: 280px;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.about-page__media--join {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import PageHero from "../components/PageHero";
|
||||
import "./AboutPage.css";
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="about-page">
|
||||
<PageHero
|
||||
eyebrow="Company"
|
||||
title="About us"
|
||||
lead="NovinTrades connects businesses with trusted brands, industry insights, and practical trade opportunities."
|
||||
image="https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=2000&q=80"
|
||||
/>
|
||||
|
||||
<section className="section about-page__section">
|
||||
<div className="container about-page__split">
|
||||
<div className="about-page__block">
|
||||
<span className="section__eyebrow">Story</span>
|
||||
<h2 className="section__title">Who we are</h2>
|
||||
<p>
|
||||
We are a trade-focused platform built for buyers, suppliers, and operators
|
||||
who need clarity. NovinTrades brings verified brand profiles, reportages,
|
||||
and editorial insights into one English-language destination — so discovery
|
||||
feels professional, structured, and useful.
|
||||
</p>
|
||||
</div>
|
||||
<div className="about-page__visual" aria-hidden="true">
|
||||
<div className="about-page__panel about-page__panel--back" />
|
||||
<div className="about-page__panel about-page__panel--front">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1600880292203-757bb62b4baf?auto=format&fit=crop&w=1200&q=80"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section about-page__section about-page__section--soft">
|
||||
<div className="container about-page__split about-page__split--reverse">
|
||||
<div className="about-page__block">
|
||||
<span className="section__eyebrow">Mission</span>
|
||||
<h2 className="section__title">What we want to achieve</h2>
|
||||
<p>
|
||||
Our goal is to shorten the distance between opportunity and action. We want
|
||||
companies to find the right partners faster, understand markets with better
|
||||
context, and present themselves with credibility across industries.
|
||||
</p>
|
||||
</div>
|
||||
<div className="about-page__visual" aria-hidden="true">
|
||||
<div className="about-page__panel about-page__panel--back about-page__panel--back-alt" />
|
||||
<div className="about-page__panel about-page__panel--front">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?auto=format&fit=crop&w=1200&q=80"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section about-page__section">
|
||||
<div className="container">
|
||||
<div className="about-page__expertise-head">
|
||||
<div className="about-page__block">
|
||||
<span className="section__eyebrow">Capabilities</span>
|
||||
<h2 className="section__title">Our expertise</h2>
|
||||
<p>
|
||||
From category mapping to brand storytelling, we focus on the systems that
|
||||
make trade information searchable and trustworthy.
|
||||
</p>
|
||||
</div>
|
||||
<div className="about-page__media about-page__media--wide">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?auto=format&fit=crop&w=1400&q=80"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-page__grid">
|
||||
<article>
|
||||
<h3>Industry categories</h3>
|
||||
<p>Clear first-level industries and nested specialties for focused browsing.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Brand discovery</h3>
|
||||
<p>Profiles, galleries, and contacts that help partners evaluate fit quickly.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Editorial insight</h3>
|
||||
<p>Blogs and reportages that explain markets, suppliers, and opportunities.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section about-page__join">
|
||||
<div className="container about-page__join-inner">
|
||||
<div className="about-page__join-copy">
|
||||
<div>
|
||||
<span className="section__eyebrow">Careers & partners</span>
|
||||
<h2 className="section__title">Want to join us?</h2>
|
||||
<p>
|
||||
Tell us about your company, your story, or how you’d like to collaborate.
|
||||
</p>
|
||||
<Link className="about-page__cta" to="/contact">
|
||||
Contact us
|
||||
</Link>
|
||||
</div>
|
||||
<div className="about-page__media about-page__media--join">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1556761175-b413da4baf72?auto=format&fit=crop&w=1000&q=80"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import ContentCard from "../components/ContentCard";
|
||||
import RichHtml from "../components/RichHtml";
|
||||
import "../components/ContentCard.css";
|
||||
import { formatDate, itemDate, similarItems } from "../lib/format";
|
||||
import {
|
||||
fetchApprovedBlogs,
|
||||
fetchApprovedBrands,
|
||||
fetchBlogBySlug,
|
||||
} from "../lib/services";
|
||||
import type { Blog, Brand } from "../lib/types";
|
||||
import "./detail.css";
|
||||
|
||||
export default function BlogDetailPage() {
|
||||
const { slug = "" } = useParams();
|
||||
const [blog, setBlog] = useState<Blog | null>(null);
|
||||
const [similarBlogs, setSimilarBlogs] = useState<Blog[]>([]);
|
||||
const [similarProducts, setSimilarProducts] = useState<Brand[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
Promise.all([
|
||||
fetchBlogBySlug(slug),
|
||||
fetchApprovedBlogs(),
|
||||
fetchApprovedBrands(),
|
||||
])
|
||||
.then(([current, blogs, brands]) => {
|
||||
if (!active) return;
|
||||
if (!current) {
|
||||
setError("Article not found");
|
||||
setBlog(null);
|
||||
return;
|
||||
}
|
||||
setBlog(current);
|
||||
setSimilarBlogs(similarItems(current, blogs, 3));
|
||||
setSimilarProducts(similarItems(current, brands, 4));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : "Could not load article");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="section container">
|
||||
<p className="content-status">Loading article…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !blog) {
|
||||
return (
|
||||
<div className="section container">
|
||||
<p className="content-status content-status--error">
|
||||
{error ?? "Article not found"}
|
||||
</p>
|
||||
<Link to="/blog">Back to blog</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dateValue = itemDate(blog);
|
||||
|
||||
return (
|
||||
<div className="detail">
|
||||
<div className="container detail__layout">
|
||||
{blog.imageUrl ? (
|
||||
<div className="detail__hero-media">
|
||||
<img src={blog.imageUrl} alt="" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<div className="detail__meta">
|
||||
<span>{blog.categories[0]?.category.name ?? "Blog"}</span>
|
||||
<time dateTime={dateValue}>{formatDate(dateValue)}</time>
|
||||
</div>
|
||||
<h1 className="detail__title">{blog.title}</h1>
|
||||
{blog.abstract ? <p className="detail__abstract">{blog.abstract}</p> : null}
|
||||
<RichHtml className="detail__content" html={blog.content} />
|
||||
</div>
|
||||
|
||||
{similarBlogs.length > 0 ? (
|
||||
<section className="detail__related">
|
||||
<h2 className="detail__related-title">Similar blogs</h2>
|
||||
<div className="content-grid">
|
||||
{similarBlogs.map((item) => (
|
||||
<ContentCard
|
||||
key={item.id}
|
||||
to={`/blog/${item.slug}`}
|
||||
title={item.title}
|
||||
abstract={item.abstract}
|
||||
imageUrl={item.imageUrl}
|
||||
publishedAt={item.publishedAt}
|
||||
createdAt={item.createdAt}
|
||||
meta={item.categories[0]?.category.name ?? "Blog"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{similarProducts.length > 0 ? (
|
||||
<section className="detail__related">
|
||||
<h2 className="detail__related-title">Similar products</h2>
|
||||
<div className="content-grid">
|
||||
{similarProducts.map((item) => (
|
||||
<ContentCard
|
||||
key={item.id}
|
||||
to={`/brands/${item.slug}`}
|
||||
title={item.title}
|
||||
abstract={item.abstract}
|
||||
imageUrl={item.imageUrl}
|
||||
publishedAt={item.publishedAt}
|
||||
createdAt={item.createdAt}
|
||||
meta="Brand"
|
||||
location={[item.city, item.country].filter(Boolean).join(", ")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import CategoryFilter from "../components/CategoryFilter";
|
||||
import ContentCard from "../components/ContentCard";
|
||||
import PageHero from "../components/PageHero";
|
||||
import Pagination from "../components/Pagination";
|
||||
import "../components/ContentCard.css";
|
||||
import {
|
||||
fetchApprovedBlogs,
|
||||
fetchTopCategories,
|
||||
listPage,
|
||||
} from "../lib/services";
|
||||
import type { Blog, Category } from "../lib/types";
|
||||
|
||||
export default function BlogListPage() {
|
||||
const [params] = useSearchParams();
|
||||
const category = params.get("category");
|
||||
const page = Number(params.get("page") ?? "1") || 1;
|
||||
|
||||
const [blogs, setBlogs] = useState<Blog[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
Promise.all([fetchApprovedBlogs(), fetchTopCategories()])
|
||||
.then(([blogItems, categoryItems]) => {
|
||||
if (!active) return;
|
||||
setBlogs(blogItems);
|
||||
setCategories(categoryItems);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : "Could not load blogs");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const result = useMemo(
|
||||
() => listPage(blogs, category, page),
|
||||
[blogs, category, page],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHero
|
||||
eyebrow="Insights"
|
||||
title="Blog"
|
||||
lead="Guides, market notes, and practical reading for modern traders."
|
||||
image="https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?auto=format&fit=crop&w=2000&q=80"
|
||||
/>
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<CategoryFilter categories={categories} />
|
||||
{loading ? <p className="content-status">Loading articles…</p> : null}
|
||||
{error ? <p className="content-status content-status--error">{error}</p> : null}
|
||||
{!loading && !error && result.total === 0 ? (
|
||||
<p className="content-status">No articles found.</p>
|
||||
) : null}
|
||||
<div className="content-grid">
|
||||
{result.items.map((blog) => (
|
||||
<ContentCard
|
||||
key={blog.id}
|
||||
to={`/blog/${blog.slug}`}
|
||||
title={blog.title}
|
||||
abstract={blog.abstract}
|
||||
imageUrl={blog.imageUrl}
|
||||
publishedAt={blog.publishedAt}
|
||||
createdAt={blog.createdAt}
|
||||
meta={blog.categories[0]?.category.name ?? "Blog"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={result.page} totalPages={result.totalPages} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import ContentCard from "../components/ContentCard";
|
||||
import RichHtml from "../components/RichHtml";
|
||||
import "../components/ContentCard.css";
|
||||
import { formatDate, itemDate, similarItems } from "../lib/format";
|
||||
import {
|
||||
fetchApprovedBrands,
|
||||
fetchBrandBySlug,
|
||||
} from "../lib/services";
|
||||
import type { Brand } from "../lib/types";
|
||||
import "./detail.css";
|
||||
|
||||
export default function BrandDetailPage() {
|
||||
const { slug = "" } = useParams();
|
||||
const [brand, setBrand] = useState<Brand | null>(null);
|
||||
const [similarBrands, setSimilarBrands] = useState<Brand[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
Promise.all([fetchBrandBySlug(slug), fetchApprovedBrands()])
|
||||
.then(([current, brands]) => {
|
||||
if (!active) return;
|
||||
if (!current) {
|
||||
setError("Brand not found");
|
||||
setBrand(null);
|
||||
return;
|
||||
}
|
||||
setBrand(current);
|
||||
setSimilarBrands(similarItems(current, brands, 6));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : "Could not load brand");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="section container">
|
||||
<p className="content-status">Loading brand…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !brand) {
|
||||
return (
|
||||
<div className="section container">
|
||||
<p className="content-status content-status--error">
|
||||
{error ?? "Brand not found"}
|
||||
</p>
|
||||
<Link to="/brands">Back to brands</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dateValue = itemDate(brand);
|
||||
const gallery = brand.galleryUrls ?? [];
|
||||
|
||||
return (
|
||||
<div className="detail">
|
||||
<div className="container detail__layout">
|
||||
{brand.imageUrl ? (
|
||||
<div className="detail__hero-media">
|
||||
<img src={brand.imageUrl} alt="" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<div className="detail__meta">
|
||||
<span>{[brand.city, brand.country].filter(Boolean).join(", ")}</span>
|
||||
<time dateTime={dateValue}>{formatDate(dateValue)}</time>
|
||||
</div>
|
||||
<h1 className="detail__title">{brand.title}</h1>
|
||||
{brand.abstract ? (
|
||||
<p className="detail__abstract">{brand.abstract}</p>
|
||||
) : null}
|
||||
<RichHtml className="detail__content" html={brand.content} />
|
||||
|
||||
{gallery.length > 0 ? (
|
||||
<div className="detail__gallery" aria-label="Image gallery">
|
||||
{gallery.map((url) => (
|
||||
<div key={url} className="detail__gallery-item">
|
||||
<img src={url} alt="" loading="lazy" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{similarBrands.length > 0 ? (
|
||||
<section className="detail__related">
|
||||
<h2 className="detail__related-title">Similar brands</h2>
|
||||
<div className="carousel">
|
||||
{similarBrands.map((item) => (
|
||||
<ContentCard
|
||||
key={item.id}
|
||||
to={`/brands/${item.slug}`}
|
||||
title={item.title}
|
||||
abstract={item.abstract}
|
||||
imageUrl={item.imageUrl}
|
||||
publishedAt={item.publishedAt}
|
||||
createdAt={item.createdAt}
|
||||
meta={item.categories[0]?.category.name ?? "Brand"}
|
||||
location={[item.city, item.country].filter(Boolean).join(", ")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import CategoryFilter from "../components/CategoryFilter";
|
||||
import ContentCard from "../components/ContentCard";
|
||||
import PageHero from "../components/PageHero";
|
||||
import Pagination from "../components/Pagination";
|
||||
import "../components/ContentCard.css";
|
||||
import {
|
||||
fetchApprovedBrands,
|
||||
fetchTopCategories,
|
||||
listPage,
|
||||
} from "../lib/services";
|
||||
import type { Brand, Category } from "../lib/types";
|
||||
|
||||
export default function BrandListPage() {
|
||||
const [params] = useSearchParams();
|
||||
const category = params.get("category");
|
||||
const page = Number(params.get("page") ?? "1") || 1;
|
||||
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
Promise.all([fetchApprovedBrands(), fetchTopCategories()])
|
||||
.then(([items, categoryItems]) => {
|
||||
if (!active) return;
|
||||
setBrands(items);
|
||||
setCategories(categoryItems);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : "Could not load brands");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const result = useMemo(
|
||||
() => listPage(brands, category, page),
|
||||
[brands, category, page],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHero
|
||||
eyebrow="Partner directory"
|
||||
title="Brands"
|
||||
lead="Browse verified companies across industries and markets."
|
||||
image="https://images.unsplash.com/photo-1566576912321-d58ddd7a6088?auto=format&fit=crop&w=2000&q=80"
|
||||
/>
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<CategoryFilter categories={categories} />
|
||||
{loading ? <p className="content-status">Loading brands…</p> : null}
|
||||
{error ? <p className="content-status content-status--error">{error}</p> : null}
|
||||
{!loading && !error && result.total === 0 ? (
|
||||
<p className="content-status">No brands found.</p>
|
||||
) : null}
|
||||
<div className="content-grid">
|
||||
{result.items.map((brand) => (
|
||||
<ContentCard
|
||||
key={brand.id}
|
||||
to={`/brands/${brand.slug}`}
|
||||
title={brand.title}
|
||||
abstract={brand.abstract}
|
||||
imageUrl={brand.imageUrl}
|
||||
publishedAt={brand.publishedAt}
|
||||
createdAt={brand.createdAt}
|
||||
meta={brand.categories[0]?.category.name ?? "Brand"}
|
||||
location={[brand.city, brand.country].filter(Boolean).join(", ")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={result.page} totalPages={result.totalPages} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
.contact-page__intro {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 0.95fr;
|
||||
gap: 2.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.contact-page__intro-copy p {
|
||||
color: var(--gray-600);
|
||||
font-size: 1.02rem;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
.contact-page__intro-media {
|
||||
overflow: hidden;
|
||||
border-radius: 0.65rem;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.contact-page__intro-media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.contact-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1.1fr;
|
||||
gap: 2.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.contact-page__info {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.contact-page__info article {
|
||||
padding: 1.25rem 1.2rem;
|
||||
border-radius: 0.55rem;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(11, 42, 91, 0.08);
|
||||
}
|
||||
|
||||
.contact-page__info h2 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--dark-blue);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.contact-page__info p,
|
||||
.contact-page__info a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--gray-600);
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.contact-page__info a:hover {
|
||||
color: var(--blue-deep);
|
||||
}
|
||||
|
||||
.contact-page__socials {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.contact-page__form-wrap {
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(11, 42, 91, 0.08);
|
||||
}
|
||||
|
||||
.contact-page__form-wrap h2 {
|
||||
font-size: 1.2rem;
|
||||
color: var(--dark-blue);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.contact-page__form {
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.contact-page__form label {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
color: var(--dark-blue);
|
||||
}
|
||||
|
||||
.contact-page__form input,
|
||||
.contact-page__form textarea {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(11, 42, 91, 0.14);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: var(--white);
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.contact-page__form input:focus,
|
||||
.contact-page__form textarea:focus {
|
||||
outline: 2px solid rgba(59, 130, 196, 0.35);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.contact-page__form button {
|
||||
justify-self: start;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-radius: 0.4rem;
|
||||
background: linear-gradient(135deg, var(--blue-deep), var(--dark-blue));
|
||||
color: var(--white);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.contact-page__success {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(31, 157, 108, 0.1);
|
||||
color: #146c4a;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.contact-page__intro,
|
||||
.contact-page__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Globe, Mail, MapPin, Phone, Share2 } from "lucide-react";
|
||||
import PageHero from "../components/PageHero";
|
||||
import "./ContactPage.css";
|
||||
|
||||
export default function ContactPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
function onSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!name.trim() || !email.trim() || !message.trim()) return;
|
||||
setSent(true);
|
||||
setName("");
|
||||
setEmail("");
|
||||
setMessage("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="contact-page">
|
||||
<PageHero
|
||||
eyebrow="Get in touch"
|
||||
title="Contact us"
|
||||
lead="Reach the NovinTrades team for partnerships, listings, and editorial inquiries."
|
||||
image="https://images.unsplash.com/photo-1423666639041-f56000c27a9a?auto=format&fit=crop&w=2000&q=80"
|
||||
/>
|
||||
|
||||
<section className="section">
|
||||
<div className="container contact-page__intro">
|
||||
<div className="contact-page__intro-copy">
|
||||
<span className="section__eyebrow">Reach us</span>
|
||||
<h2 className="section__title">We’re here to help</h2>
|
||||
<p>
|
||||
Whether you’re listing a brand, sharing a reportage, or asking about
|
||||
partnerships — send a note and our team will follow up.
|
||||
</p>
|
||||
</div>
|
||||
<div className="contact-page__intro-media">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=1200&q=80"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container contact-page__grid">
|
||||
<div className="contact-page__info">
|
||||
<article>
|
||||
<h2>
|
||||
<MapPin size={18} />
|
||||
Address & landlines
|
||||
</h2>
|
||||
<p>NovinTrades HQ</p>
|
||||
<p>Valiasr Avenue, Tehran, Iran</p>
|
||||
<p>
|
||||
<Phone size={15} /> +98 21 0000 0000
|
||||
</p>
|
||||
<p>
|
||||
<Phone size={15} /> +98 21 0000 0001
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2>
|
||||
<Mail size={18} />
|
||||
Email address
|
||||
</h2>
|
||||
<a href="mailto:hello@novintrades.com">hello@novintrades.com</a>
|
||||
<a href="mailto:partners@novintrades.com">partners@novintrades.com</a>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2>Social media</h2>
|
||||
<div className="contact-page__socials">
|
||||
<a href="https://linkedin.com" target="_blank" rel="noreferrer">
|
||||
<Share2 size={18} />
|
||||
LinkedIn
|
||||
</a>
|
||||
<a href="https://instagram.com" target="_blank" rel="noreferrer">
|
||||
<Globe size={18} />
|
||||
Instagram
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="contact-page__form-wrap">
|
||||
<h2>Contact us form</h2>
|
||||
{sent ? (
|
||||
<p className="contact-page__success">
|
||||
Thanks — your message has been noted. We’ll get back to you soon.
|
||||
</p>
|
||||
) : null}
|
||||
<form className="contact-page__form" onSubmit={onSubmit} noValidate>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Message
|
||||
<textarea
|
||||
rows={6}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">Send message</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Slider from "../components/Slider";
|
||||
import Categories from "../components/Categories";
|
||||
import LatestBlog from "../components/LatestBlog";
|
||||
import AboutUs from "../components/AboutUs";
|
||||
import LatestBrands from "../components/LatestBrands";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Slider />
|
||||
<Categories />
|
||||
<LatestBlog />
|
||||
<AboutUs />
|
||||
<LatestBrands />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import ContentCard from "../components/ContentCard";
|
||||
import RichHtml from "../components/RichHtml";
|
||||
import "../components/ContentCard.css";
|
||||
import { formatDate, itemDate, similarItems } from "../lib/format";
|
||||
import {
|
||||
fetchApprovedBlogs,
|
||||
fetchReportageBySlug,
|
||||
} from "../lib/services";
|
||||
import type { Blog, Reportage } from "../lib/types";
|
||||
import "./detail.css";
|
||||
|
||||
export default function ReportageDetailPage() {
|
||||
const { slug = "" } = useParams();
|
||||
const [reportage, setReportage] = useState<Reportage | null>(null);
|
||||
const [reportageBlogs, setReportageBlogs] = useState<Blog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
Promise.all([fetchReportageBySlug(slug), fetchApprovedBlogs()])
|
||||
.then(([current, blogs]) => {
|
||||
if (!active) return;
|
||||
if (!current) {
|
||||
setError("Reportage not found");
|
||||
setReportage(null);
|
||||
return;
|
||||
}
|
||||
setReportage(current);
|
||||
setReportageBlogs(similarItems(current, blogs, 3));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load reportage",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="section container">
|
||||
<p className="content-status">Loading reportage…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !reportage) {
|
||||
return (
|
||||
<div className="section container">
|
||||
<p className="content-status content-status--error">
|
||||
{error ?? "Reportage not found"}
|
||||
</p>
|
||||
<Link to="/reportages">Back to reportages</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dateValue = itemDate(reportage);
|
||||
|
||||
return (
|
||||
<div className="detail">
|
||||
<div className="container detail__layout">
|
||||
{reportage.imageUrl ? (
|
||||
<div className="detail__hero-media">
|
||||
<img src={reportage.imageUrl} alt="" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<div className="detail__meta">
|
||||
<span>{reportage.businessOwner}</span>
|
||||
<time dateTime={dateValue}>{formatDate(dateValue)}</time>
|
||||
</div>
|
||||
<h1 className="detail__title">{reportage.title}</h1>
|
||||
{reportage.abstract ? (
|
||||
<p className="detail__abstract">{reportage.abstract}</p>
|
||||
) : null}
|
||||
<RichHtml className="detail__content" html={reportage.content} />
|
||||
</div>
|
||||
|
||||
{reportageBlogs.length > 0 ? (
|
||||
<section className="detail__related">
|
||||
<h2 className="detail__related-title">Reportage blogs</h2>
|
||||
<div className="content-grid">
|
||||
{reportageBlogs.map((item) => (
|
||||
<ContentCard
|
||||
key={item.id}
|
||||
to={`/blog/${item.slug}`}
|
||||
title={item.title}
|
||||
abstract={item.abstract}
|
||||
imageUrl={item.imageUrl}
|
||||
publishedAt={item.publishedAt}
|
||||
createdAt={item.createdAt}
|
||||
meta={item.categories[0]?.category.name ?? "Blog"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import CategoryFilter from "../components/CategoryFilter";
|
||||
import ContentCard from "../components/ContentCard";
|
||||
import PageHero from "../components/PageHero";
|
||||
import Pagination from "../components/Pagination";
|
||||
import "../components/ContentCard.css";
|
||||
import {
|
||||
fetchApprovedReportages,
|
||||
fetchTopCategories,
|
||||
listPage,
|
||||
} from "../lib/services";
|
||||
import type { Category, Reportage } from "../lib/types";
|
||||
|
||||
export default function ReportageListPage() {
|
||||
const [params] = useSearchParams();
|
||||
const category = params.get("category");
|
||||
const page = Number(params.get("page") ?? "1") || 1;
|
||||
|
||||
const [reportages, setReportages] = useState<Reportage[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
Promise.all([fetchApprovedReportages(), fetchTopCategories()])
|
||||
.then(([items, categoryItems]) => {
|
||||
if (!active) return;
|
||||
setReportages(items);
|
||||
setCategories(categoryItems);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load reportages",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const result = useMemo(
|
||||
() => listPage(reportages, category, page),
|
||||
[reportages, category, page],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHero
|
||||
eyebrow="Sponsored stories"
|
||||
title="Reportages"
|
||||
lead="Business features and industry stories from verified partners."
|
||||
image="https://images.unsplash.com/photo-1504384308090-c894fdcc538d?auto=format&fit=crop&w=2000&q=80"
|
||||
/>
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<CategoryFilter categories={categories} />
|
||||
{loading ? <p className="content-status">Loading reportages…</p> : null}
|
||||
{error ? <p className="content-status content-status--error">{error}</p> : null}
|
||||
{!loading && !error && result.total === 0 ? (
|
||||
<p className="content-status">No reportages found.</p>
|
||||
) : null}
|
||||
<div className="content-grid">
|
||||
{result.items.map((item) => (
|
||||
<ContentCard
|
||||
key={item.id}
|
||||
to={`/reportages/${item.slug}`}
|
||||
title={item.title}
|
||||
abstract={item.abstract}
|
||||
imageUrl={item.imageUrl}
|
||||
publishedAt={item.publishedAt}
|
||||
createdAt={item.createdAt}
|
||||
meta={item.businessOwner}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={result.page} totalPages={result.totalPages} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
.detail {
|
||||
padding-top: calc(var(--header-h) + 1.5rem);
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.detail__layout {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.detail__hero-media {
|
||||
overflow: hidden;
|
||||
border-radius: 0.65rem;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.detail__hero-media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.detail__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--gray-600);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.detail__meta time {
|
||||
font-weight: 600;
|
||||
color: var(--blue-deep);
|
||||
}
|
||||
|
||||
.detail__title {
|
||||
font-size: clamp(1.7rem, 3vw, 2.4rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.035em;
|
||||
color: var(--dark-blue);
|
||||
line-height: 1.15;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail__abstract {
|
||||
font-size: 1.05rem;
|
||||
color: var(--gray-600);
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.detail__content {
|
||||
color: var(--gray-800);
|
||||
line-height: 1.7;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.detail__content p,
|
||||
.detail__content ul,
|
||||
.detail__content ol {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail__content img {
|
||||
max-width: 100%;
|
||||
border-radius: 0.45rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.detail__content h2,
|
||||
.detail__content h3 {
|
||||
color: var(--dark-blue);
|
||||
margin: 1.5rem 0 0.65rem;
|
||||
}
|
||||
|
||||
.detail__related {
|
||||
margin-top: 3.5rem;
|
||||
padding-top: 2.5rem;
|
||||
border-top: 1px solid rgba(11, 42, 91, 0.1);
|
||||
}
|
||||
|
||||
.detail__related + .detail__related {
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.detail__related-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--dark-blue);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.detail__gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.detail__gallery-item {
|
||||
overflow: hidden;
|
||||
border-radius: 0.45rem;
|
||||
aspect-ratio: 1;
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.detail__gallery-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.carousel {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(240px, 1fr);
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
.carousel > * {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail__gallery {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
allowedHosts: ["novintrades.local", "novintrades.test"],
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/health": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user