This commit is contained in:
Iliyan Angelov
2025-11-24 16:47:37 +02:00
parent d7ff5c71e6
commit 0b1cabcfaf
45 changed files with 2021 additions and 28 deletions

26
frontEnd/.dockerignore Normal file
View File

@@ -0,0 +1,26 @@
node_modules
.next
.git
.gitignore
*.log
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
.vscode
.idea
*.swp
*.swo
*~
coverage
.nyc_output
dist
build
README.md
*.md

50
frontEnd/Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# Next.js Frontend Dockerfile
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set environment variables for build
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Build Next.js
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 1087
ENV PORT=1087
ENV HOSTNAME="0.0.0.0"
# Use the standalone server
CMD ["node", "server.js"]

View File

@@ -2,6 +2,7 @@
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Link from "next/link";
import Header from "@/components/shared/layout/header/Header";
import JobSingle from "@/components/pages/career/JobSingle";
import Footer from "@/components/shared/layout/footer/Footer";
@@ -78,9 +79,9 @@ const JobPage = () => {
<p className="mt-24">
The job position you are looking for does not exist or is no longer available.
</p>
<a href="/career" className="btn mt-40">
<Link href="/career" className="btn mt-40">
View All Positions
</a>
</Link>
</div>
</div>
</div>

View File

@@ -41,7 +41,8 @@ const PolicyContent = () => {
url: `/policy?type=${type}`,
});
document.title = metadata.title || `${policyTitles[type]} | GNX Soft`;
const titleString = typeof metadata.title === 'string' ? metadata.title : `${policyTitles[type]} | GNX Soft`;
document.title = titleString;
let metaDescription = document.querySelector('meta[name="description"]');
if (!metaDescription) {
@@ -49,7 +50,8 @@ const PolicyContent = () => {
metaDescription.setAttribute('name', 'description');
document.head.appendChild(metaDescription);
}
metaDescription.setAttribute('content', metadata.description || policyDescriptions[type]);
const descriptionString = typeof metadata.description === 'string' ? metadata.description : policyDescriptions[type];
metaDescription.setAttribute('content', descriptionString);
}, [type]);
if (isLoading) {
@@ -235,7 +237,7 @@ const PolicyContent = () => {
<div className="policy-footer">
<div className="contact-box">
<h3>Questions?</h3>
<p>If you have any questions about this policy, please don't hesitate to contact us.</p>
<p>If you have any questions about this policy, please don&apos;t hesitate to contact us.</p>
<a href="/contact-us" className="btn btn-primary">Contact Us</a>
</div>
</div>

View File

@@ -41,7 +41,7 @@ const BlogSingle = () => {
</div>
<h2 className="text-secondary mb-3">Insight Not Found</h2>
<p className="text-tertiary mb-4">
The insight you're looking for doesn't exist or has been removed.
The insight you&apos;re looking for doesn&apos;t exist or has been removed.
</p>
<Link href="/insights" className="btn btn-primary">
<i className="fa-solid fa-arrow-left me-2"></i>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { JobPosition } from "@/lib/api/careerService";
import JobApplicationForm from "./JobApplicationForm";
@@ -529,7 +530,7 @@ const JobSingle = ({ job }: JobSingleProps) => {
<span className="d-sm-none">~5 min</span>
</p>
<a
<Link
href="/career"
className="btn w-100 mt-12 mt-md-16"
style={{
@@ -560,7 +561,7 @@ const JobSingle = ({ job }: JobSingleProps) => {
<span className="material-symbols-outlined" style={{ fontSize: 'clamp(16px, 3vw, 18px)' }}>arrow_back</span>
<span className="d-none d-sm-inline">Back to Career Page</span>
<span className="d-sm-none">Back</span>
</a>
</Link>
</div>
</div>
</div>

View File

@@ -67,7 +67,7 @@ const CaseSingle = ({ slug }: CaseSingleProps) => {
<div className="col-12">
<div className="error-state">
<h2>Case Study Not Found</h2>
<p>The case study you're looking for doesn't exist or has been removed.</p>
<p>The case study you&apos;re looking for doesn&apos;t exist or has been removed.</p>
<Link href="/case-study" className="btn btn-primary">
View All Case Studies
</Link>

View File

@@ -203,7 +203,7 @@ const CreateTicketForm = ({ onOpenStatusCheck }: CreateTicketFormProps) => {
</button>
</div>
<p className="ticket-info">
We've received your support request and will respond as soon as possible.
We&apos;ve received your support request and will respond as soon as possible.
Please save your ticket number for future reference.
</p>
<div className="success-actions">

View File

@@ -236,7 +236,7 @@ const KnowledgeBase = () => {
</div>
{searchTerm && (
<p className="search-info">
Found {displayArticles.length} {displayArticles.length === 1 ? 'article' : 'articles'} for "{searchTerm}"
Found {displayArticles.length} {displayArticles.length === 1 ? 'article' : 'articles'} for &quot;{searchTerm}&quot;
</p>
)}
</div>

View File

@@ -153,11 +153,56 @@ const Footer = () => {
</div>
<h6 className="cta-title">Ready to Transform Your Business?</h6>
<p className="cta-description">Start your software journey with our enterprise solutions, incident management, and custom development services.</p>
<div className="cta-button-wrapper">
<Link href="/contact-us" className="btn-luxury-cta">
<span>Start Your Journey</span>
<i className="fa-solid fa-arrow-right"></i>
<div className="btn-shine"></div>
</Link>
<div className="goodfirms-wrapper text-center mt-3" style={{ lineHeight: 0 }}>
<Link
href="https://www.goodfirms.co/company/gnx-soft-ltd"
target="_blank"
rel="noopener noreferrer"
className="goodfirms-badge d-inline-block"
title="View our company profile on GoodFirms"
style={{
border: 'none',
outline: 'none',
boxShadow: 'none',
textDecoration: 'none',
padding: 0,
margin: 0,
display: 'inline-block',
lineHeight: 0,
background: 'transparent'
}}
>
<Image
src="/images/gnx-goodfirms.webp"
alt="GoodFirms Company Profile"
width={150}
height={80}
className="goodfirms-image"
style={{
width: '20px',
height: '20px',
objectFit: 'contain',
border: 'none',
outline: 'none',
boxShadow: 'none',
padding: 0,
margin: 0,
display: 'block',
verticalAlign: 'top',
borderWidth: 0,
borderStyle: 'none',
borderColor: 'transparent'
}}
/>
</Link>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
@@ -12,6 +12,7 @@ const Header = () => {
const [isActive, setIsActive] = useState(true);
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
const [isMobile, setIsMobile] = useState(false);
const dropdownTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Fetch services from API
const { services: apiServices, loading: servicesLoading, error: servicesError } = useNavigationServices();
@@ -112,6 +113,36 @@ const Header = () => {
setOpenDropdown(openDropdown === index ? null : index);
};
const handleDropdownEnter = (index: number) => {
if (dropdownTimeoutRef.current) {
clearTimeout(dropdownTimeoutRef.current);
dropdownTimeoutRef.current = null;
}
if (!isMobile) {
setOpenDropdown(index);
}
};
const handleDropdownLeave = (e: React.MouseEvent) => {
if (!isMobile) {
// Check if we're moving to the dropdown menu itself
const relatedTarget = e.relatedTarget as HTMLElement;
const currentTarget = e.currentTarget as HTMLElement;
// If moving to a child element (dropdown menu), don't close
if (relatedTarget && currentTarget.contains(relatedTarget)) {
return;
}
if (dropdownTimeoutRef.current) {
clearTimeout(dropdownTimeoutRef.current);
}
dropdownTimeoutRef.current = setTimeout(() => {
setOpenDropdown(null);
}, 300); // Increased delay to allow for scrolling
}
};
useEffect(() => {
const handleScroll = () => {
const scrollPosition = window.scrollY;
@@ -145,6 +176,9 @@ const Header = () => {
return () => {
window.removeEventListener("resize", handleResize);
if (dropdownTimeoutRef.current) {
clearTimeout(dropdownTimeoutRef.current);
}
};
}, []);
@@ -200,12 +234,12 @@ const Header = () => {
<div className="navbar__menu d-none d-lg-flex">
<ul>
{navigationData.map((item) =>
item.title === "Support Center" ? null : item.submenu ? (
item.title === "Support Center" ? null : item.submenu ? (
<li
className="navbar__item navbar__item--has-children"
key={item.id}
onMouseEnter={() => !isMobile && setOpenDropdown(item.id)}
onMouseLeave={() => !isMobile && setOpenDropdown(null)}
onMouseEnter={() => handleDropdownEnter(item.id)}
onMouseLeave={(e) => handleDropdownLeave(e)}
>
<button
aria-label="dropdown menu"
@@ -222,7 +256,27 @@ const Header = () => {
<span className="loading-indicator"></span>
)}
</button>
<ul className={`navbar__sub-menu ${openDropdown === item.id ? 'show' : ''}`}>
<ul
className={`navbar__sub-menu ${openDropdown === item.id ? 'show' : ''}`}
onMouseEnter={() => {
if (dropdownTimeoutRef.current) {
clearTimeout(dropdownTimeoutRef.current);
dropdownTimeoutRef.current = null;
}
if (!isMobile) {
setOpenDropdown(item.id);
}
}}
onMouseLeave={(e) => handleDropdownLeave(e)}
onWheel={(e) => {
// Prevent dropdown from closing when scrolling
e.stopPropagation();
if (dropdownTimeoutRef.current) {
clearTimeout(dropdownTimeoutRef.current);
dropdownTimeoutRef.current = null;
}
}}
>
{item.title === "Services" && servicesLoading ? (
<li>
<span className="text-muted">Loading services...</span>

View File

@@ -8,10 +8,15 @@
// Production: Use relative URLs (nginx proxy)
// Development: Use full backend URL
// Docker: Use backend service name or port 1086
const isProduction = process.env.NODE_ENV === 'production';
export const API_BASE_URL = isProduction
? '' // Use relative URLs in production (proxied by nginx)
: (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000');
const isDocker = process.env.DOCKER_ENV === 'true';
export const API_BASE_URL = isDocker
? (process.env.NEXT_PUBLIC_API_URL || 'http://backend:1086')
: isProduction
? '' // Use relative URLs in production (proxied by nginx)
: (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000');
export const API_CONFIG = {
// Django API Base URL

View File

@@ -1,6 +1,5 @@
// Image utility functions
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
import { API_BASE_URL } from './config/api';
export const FALLBACK_IMAGES = {
BLOG: '/images/blog/blog-poster.png',

View File

@@ -1,5 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable standalone output for Docker
output: 'standalone',
images: {
remotePatterns: [
{

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -747,6 +747,60 @@
justify-content: center;
}
}
// GoodFirms Badge
.goodfirms-badge {
border: none !important;
outline: none !important;
box-shadow: none !important;
text-decoration: none !important;
padding: 0 !important;
margin: 0 !important;
display: inline-block !important;
background: transparent !important;
line-height: 0 !important;
&:focus,
&:focus-visible,
&:active,
&:hover {
border: none !important;
outline: none !important;
box-shadow: none !important;
text-decoration: none !important;
}
.goodfirms-image,
img {
border: none !important;
outline: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
display: block !important;
vertical-align: top !important;
max-width: 100% !important;
height: auto !important;
}
// Target the actual img element that Next.js Image renders
img {
border: 0 !important;
border-style: none !important;
border-width: 0 !important;
border-color: transparent !important;
border-image: none !important;
}
}
.goodfirms-wrapper {
line-height: 0 !important;
* {
border: none !important;
outline: none !important;
}
}
}
/* ====

View File

@@ -141,13 +141,16 @@
min-width: 280px !important;
max-width: 320px;
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
overflow-y: auto !important;
overflow-x: hidden !important;
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 9999 !important;
backdrop-filter: blur(25px) saturate(180%);
pointer-events: none;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
// Custom scrollbar styles
&::-webkit-scrollbar {
@@ -174,6 +177,19 @@
visibility: visible !important;
transform: translateX(-50%) translateY(0) scale(1) !important;
display: block !important;
pointer-events: auto !important;
}
// Bridge element to cover the gap
&::after {
content: "";
position: absolute;
top: -12px;
left: 0;
right: 0;
height: 12px;
pointer-events: auto;
z-index: -1;
}
&::before {
@@ -188,6 +204,7 @@
border-right: 8px solid transparent;
border-bottom: 8px solid #1a1a1a;
filter: drop-shadow(0 -3px 8px rgba(0, 0, 0, 0.5));
pointer-events: none;
}
li {