Initial commit: NovinTrades website monorepo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-24 17:16:53 +03:30
co-authored by Cursor
commit 722a520e68
147 changed files with 15748 additions and 0 deletions
+30
View File
@@ -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`
+23
View File
@@ -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>
+27
View File
@@ -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"
}
}
+5
View File
@@ -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

+9
View File
@@ -0,0 +1,9 @@
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app main {
flex: 1;
}
+33
View File
@@ -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>
);
}
+77
View File
@@ -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;
}
}
+40
View File
@@ -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>
);
}
+102
View File
@@ -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;
}
}
+110
View File
@@ -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>
);
}
+113
View File
@@ -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;
}
}
+50
View File
@@ -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>
);
}
+113
View File
@@ -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;
}
}
+69
View File
@@ -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>
);
}
+294
View File
@@ -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;
}
}
+190
View File
@@ -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>
);
}
+134
View File
@@ -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;
}
}
+86
View File
@@ -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>
);
}
+140
View File
@@ -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;
}
}
+79
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+78
View File
@@ -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;
}
+36
View File
@@ -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>
);
}
+35
View File
@@ -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);
}
+49
View File
@@ -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>
);
}
+14
View File
@@ -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 }}
/>
);
}
+215
View File
@@ -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;
}
}
+96
View File
@@ -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>
);
}
+41
View File
@@ -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",
},
];
+112
View File
@@ -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;
}
}
+39
View File
@@ -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;
}
+72
View File
@@ -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);
}
+72
View File
@@ -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);
}
+72
View File
@@ -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;
+10
View File
@@ -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>,
);
+183
View File
@@ -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;
}
}
+125
View File
@@ -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 &amp; partners</span>
<h2 className="section__title">Want to join us?</h2>
<p>
Tell us about your company, your story, or how youd 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>
);
}
+141
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+141
View File
@@ -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;
}
}
+131
View File
@@ -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">Were here to help</h2>
<p>
Whether youre 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 &amp; 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. Well 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>
);
}
+17
View File
@@ -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 />
</>
);
}
+117
View File
@@ -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>
);
}
+88
View File
@@ -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>
);
}
+134
View File
@@ -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));
}
}
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+22
View File
@@ -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,
},
},
},
});