Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/[locale]/notifications/tenders/TendersList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ interface TendersListProps {
deletingId?: number | null;
}

export default TendersList;

export function TendersList({
tenders,
Comment on lines 29 to 33

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export default TendersList; appears before TendersList is declared, which is a compile-time error. If you want a default export, export the function as default (e.g., export default function TendersList(...)) or move the default export to after the function declaration.

Copilot uses AI. Check for mistakes.
locale,
Expand Down
30 changes: 27 additions & 3 deletions app/[locale]/notifications/tenders/TendersPageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
'use client';

import React from 'react';
import Link from 'next/link';
import { useState } from 'react';
import { FaPlus } from 'react-icons/fa';

import { Button } from '~/components/buttons';
import { DynamicLazy } from '~/components/lazy/DynamicLazy';
import {
Comment on lines 7 to 10

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove the // Removed stray comment note—this kind of meta-comment adds noise and isn’t useful for future readers.

Copilot uses AI. Check for mistakes.
deleteTenderAction,
type TenderWithStatus,
} from '~/server/actions/tenders';
import type { TendersTranslations } from '~/i18n/translate/tenders';

import { TendersList } from './TendersList';
type LazyTendersListProps = React.JSX.IntrinsicAttributes & {
tenders: TenderWithStatus[];
locale: string;
canManage: boolean;
text: TendersTranslations;
isArchived: boolean;
onDelete?: (id: number) => Promise<void>;
deletingId?: number | null;
};

const LazyTendersList = DynamicLazy<LazyTendersListProps>(
() => import('./TendersList').then((mod) => ({ default: mod.default })),
{
fallback: (
<div className="rounded-lg border border-neutral-200 bg-neutral-50 p-8 text-center text-neutral-600">
Loading tenders...
</div>
),
}
);

interface TendersPageClientProps {
allTenders: TenderWithStatus[];
Expand Down Expand Up @@ -41,7 +62,6 @@ export function TendersPageClient({
// Handle delete - updates state for both tabs
const handleDelete = async (id: number) => {
if (!confirm(text.confirmDelete)) return;

setDeletingId(id);
try {
const result = await deleteTenderAction(id);
Expand Down Expand Up @@ -78,6 +98,8 @@ export function TendersPageClient({
<div className="mb-6 flex gap-2">
<button
onClick={() => setActiveTab('live')}
onMouseEnter={() => void LazyTendersList.preload()}
onFocus={() => void LazyTendersList.preload()}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'live'
? 'text-white bg-primary-700'
Expand All @@ -88,6 +110,8 @@ export function TendersPageClient({
</button>
<button
onClick={() => setActiveTab('archived')}
onMouseEnter={() => void LazyTendersList.preload()}
onFocus={() => void LazyTendersList.preload()}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'archived'
? 'text-white bg-primary-700'
Expand All @@ -99,7 +123,7 @@ export function TendersPageClient({
</div>

{/* Tenders List */}
<TendersList
<LazyTendersList
tenders={displayedTenders}
locale={locale}
canManage={canManage}
Expand Down
17 changes: 6 additions & 11 deletions app/[locale]/notifications/tenders/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { Suspense } from 'react';

import { getTranslations } from '~/i18n/translations';
import { canManageNotifications, getServerAuthSession } from '~/server/auth';
import ImageHeader from '~/components/image-header';
import Loading from '~/components/loading';
import { getAllTenders } from '~/server/actions/tenders';

import { TendersPageClient } from './TendersPageClient';
Expand All @@ -29,14 +26,12 @@ export default async function TendersPage({
<>
<ImageHeader title={text.title} src="assets/academics.png" />

<Suspense fallback={<Loading />}>
<TendersPageClient
allTenders={allTenders}
locale={locale}
canManage={canManage}
text={text}
/>
</Suspense>
<TendersPageClient
allTenders={allTenders}
locale={locale}
canManage={canManage}
text={text}
/>
</>
);
}
46 changes: 46 additions & 0 deletions components/lazy/DynamicLazy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React, {
type ComponentType,
lazy,
type LazyExoticComponent,
Suspense,
} from 'react';

export interface DynamicLazyOptions {
fallback?: React.ReactNode;
}

export interface DynamicLazyComponent<P> {
(props: P): JSX.Element;
preload: () => Promise<void>;
}

export function DynamicLazy<P extends React.JSX.IntrinsicAttributes>(
loader: () => Promise<{ default: React.ComponentType<P> }>,
options?: DynamicLazyOptions
): DynamicLazyComponent<P> {
let LoadedComponent: LazyExoticComponent<ComponentType<P>> | null = null;
let loadPromise: Promise<void> | null = null;

function preload(): Promise<void> {
if (!loadPromise) {
loadPromise = loader().then((mod) => {
LoadedComponent = lazy(() => Promise.resolve(mod));
});
}
return loadPromise ?? Promise.resolve();
}

const LazyComponent = (props: P) => {
if (!LoadedComponent) {
LoadedComponent = lazy(loader);
}
const Component = LoadedComponent;
return (
<Suspense fallback={options?.fallback ?? null}>
<Component {...(props as React.PropsWithRef<P>)} />
</Suspense>
);
};
(LazyComponent as DynamicLazyComponent<P>).preload = preload;
return LazyComponent as DynamicLazyComponent<P>;
}
105 changes: 105 additions & 0 deletions components/lazy/LazyLoadBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';

import { useIntersectionObserver } from './useIntersectionObserver';
import { useIdleCallback } from './useIdleCallback';

export type LazyLoadTrigger = 'viewport' | 'interaction' | 'immediate';

export interface LazyLoadBoundaryProps {
trigger?: LazyLoadTrigger;
fallback?: React.ReactNode;
children: React.ReactNode;
rootMargin?: string;
threshold?: number;
preloadOnHover?: boolean;
preloadOnIdle?: boolean;
keepMounted?: boolean;
onPreload?: () => void;
}

export const LazyLoadBoundary: React.FC<LazyLoadBoundaryProps> = ({
trigger = 'viewport',
fallback = null,
children,
rootMargin = '0px',
threshold = 0,
preloadOnHover = false,
preloadOnIdle = false,
keepMounted = false,
onPreload,
}) => {
const [mounted, setMounted] = useState(trigger === 'immediate');
const [hasInteracted, setHasInteracted] = useState(false);
const [ref, inView] = useIntersectionObserver({ rootMargin, threshold });
const hasMountedRef = useRef<boolean>(false);

// Preload on hover
const handleMouseEnter = useCallback(() => {
if (preloadOnHover && onPreload) onPreload();
if (trigger === 'interaction') setHasInteracted(true);
}, [preloadOnHover, onPreload, trigger]);

// Preload on idle
useIdleCallback(() => {
if (preloadOnIdle && onPreload) onPreload();
}, [preloadOnIdle, onPreload]);

// Mount logic
useEffect(() => {
if (trigger === 'immediate') {
setMounted(true);
} else if (trigger === 'viewport' && inView) {
setMounted(true);
} else if (trigger === 'interaction' && hasInteracted) {
setMounted(true);
}
}, [trigger, inView, hasInteracted]);

// Keep mounted
useEffect(() => {
if (mounted && keepMounted) {
hasMountedRef.current = true;
}
}, [mounted, keepMounted]);

// Clean up interaction state if not keepMounted
useEffect(() => {
return () => {
if (!keepMounted) {
setMounted(false);
setHasInteracted(false);
}
};
}, [keepMounted]);

// Render logic
if (mounted || hasMountedRef.current) {
return (
<div
ref={
trigger === 'viewport'
? (ref as React.RefObject<HTMLDivElement>)
: undefined
}
>
{children}
</div>
);
}
return (
<div
ref={
trigger === 'viewport'
? (ref as React.RefObject<HTMLDivElement>)
: undefined
}
onClick={
trigger === 'interaction' ? () => setHasInteracted(true) : undefined
}
onMouseEnter={handleMouseEnter}
style={{ width: '100%', height: '100%' }}
>
{fallback}
</div>
);
};
57 changes: 57 additions & 0 deletions components/lazy/examples.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';

import type { CarouselProps } from '~/components/carousels';

// import type { CarouselProps } from '~/components/carousels';

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s a duplicated (commented-out) CarouselProps import left in the file. Please remove commented-out imports to keep the examples clean and avoid confusion.

Suggested change
// import type { CarouselProps } from '~/components/carousels';

Copilot uses AI. Check for mistakes.
import { LazyLoadBoundary } from './LazyLoadBoundary';
import { DynamicLazy } from './DynamicLazy';

// Match the actual EventItem type from event-section.tsx
interface EventItem {
title: string;
categories?: string[];
startDate: string | Date;
endDate?: string | Date;
time?: string;
description: string;
images: string[];
location?: string;
id: number;
}

interface EventSectionProps extends React.JSX.IntrinsicAttributes {
events: EventItem[];
locale: string;
showViewAll?: boolean;
viewAllText?: string;
viewAllHref?: string;
}

const EventsGallery = DynamicLazy<EventSectionProps>(() =>
import('../events/event-section').then((mod) => ({ default: mod.default }))
);

interface GalleryCarouselProps extends React.JSX.IntrinsicAttributes {
carouselProps?: CarouselProps;
children: React.ReactNode[];
className?: string;
itemClassName?: string;
}

const GalleryBelowFold = DynamicLazy<GalleryCarouselProps>(() =>
import('../carousels/gallery').then((mod) => ({
default: mod.GalleryCarousel,
}))
);
Comment on lines +34 to +45

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GalleryBelowFold is declared but never used, which will trigger @typescript-eslint/no-unused-vars warnings. Either use it in an exported example component or remove the unused constant (and any now-unused types/imports).

Copilot uses AI. Check for mistakes.

export function ExampleEventsGallery() {
return (
<LazyLoadBoundary
trigger="interaction"
fallback={<div>Loading Gallery...</div>}
>
<EventsGallery events={[]} locale="en" />
</LazyLoadBoundary>
);
// All examples above demonstrate different trigger modes and usage patterns.
}
31 changes: 31 additions & 0 deletions components/lazy/useIdleCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useEffect } from 'react';

interface WindowWithIdleCallback extends Window {
requestIdleCallback: (callback: IdleRequestCallback) => number;
cancelIdleCallback: (id: number) => void;
setTimeout: (callback: () => void, ms: number) => number;
clearTimeout: (id: number) => void;
}

export function useIdleCallback(
cb: () => void,
_deps: React.DependencyList = []
) {
Comment on lines +10 to +13

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React.DependencyList is referenced but React isn’t imported, which will fail type-checking. Import type DependencyList from react (or import type React from 'react') and use that type instead.

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +13

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_deps is accepted but ignored, and the effect only depends on cb. Since callers typically pass an inline callback, this ends up scheduling/canceling an idle callback on every render. Use the provided deps list as the effect dependencies and/or keep the latest callback in a ref so you don’t resubscribe every render.

Copilot uses AI. Check for mistakes.
useEffect(() => {
let id: number | null = null;
if ('requestIdleCallback' in window) {
id = (window as WindowWithIdleCallback).requestIdleCallback(cb);
} else {
id = (window as WindowWithIdleCallback).setTimeout(cb, 200);
}
return () => {
if (id !== null) {
if ('cancelIdleCallback' in window) {
(window as WindowWithIdleCallback).cancelIdleCallback(id);
} else {
clearTimeout(id);
}
}
};
}, [cb]);
}
Comment on lines +27 to +31

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effect depends only on [cb] (line with }, [cb]);), which will retrigger when an inline callback is recreated each render, cancelling/rescheduling the idle task repeatedly. Consider depending on the explicit deps list instead and invoking the latest callback via a ref.

Copilot uses AI. Check for mistakes.
41 changes: 41 additions & 0 deletions components/lazy/useIntersectionObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useEffect, useRef, useState } from 'react';

export interface UseIntersectionObserverOptions {
root?: Element | null;
rootMargin?: string;
threshold?: number | number[];
freezeOnceVisible?: boolean;
}

export function useIntersectionObserver<T extends Element>(
options: UseIntersectionObserverOptions = {}
): [React.RefObject<T>, boolean] {
const {
Comment on lines +10 to +13

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type uses React.RefObject<T> but this file doesn’t import the React namespace/types, which will fail under TS strict ("Cannot find namespace 'React'"). Prefer importing type RefObject from react and returning RefObject<T | null>, or add import type React from 'react' and ensure the ref type matches null initialization.

Copilot uses AI. Check for mistakes.
root = null,
rootMargin = '0px',
threshold = 0,
freezeOnceVisible = true,
} = options;
const ref = useRef<T>(null);
Comment on lines +12 to +19

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useRef<T>(null) is not type-safe with strictNullChecks (enabled in tsconfig) because null isn’t assignable to T. Use useRef<T | null>(null) (and reflect that in the returned ref type) or initialize with null! if you intentionally want a non-null ref contract.

Suggested change
): [React.RefObject<T>, boolean] {
const {
root = null,
rootMargin = '0px',
threshold = 0,
freezeOnceVisible = true,
} = options;
const ref = useRef<T>(null);
): [React.RefObject<T | null>, boolean] {
const {
root = null,
rootMargin = '0px',
threshold = 0,
freezeOnceVisible = true,
} = options;
const ref = useRef<T | null>(null);

Copilot uses AI. Check for mistakes.
const [isIntersecting, setIntersecting] = useState<boolean>(false);
const frozen = isIntersecting && freezeOnceVisible;

useEffect(() => {
if (frozen) return;
const node = ref.current;
if (!node) return;
let observer: IntersectionObserver | null = null;
observer = new IntersectionObserver(
([entry]) => {
setIntersecting(entry.isIntersecting);
},
{ root, rootMargin, threshold }
);
observer.observe(node);
return () => {
observer && observer.disconnect();
};
}, [root, rootMargin, threshold, frozen]);

return [ref, isIntersecting];
}