Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e57602c
fix(site): wording
HusseinBaraja Apr 2, 2026
72524c5
feat(web): add Hono-based client routing and page structure
HusseinBaraja Apr 2, 2026
a9e1da2
feat(web): implement luxury Contact Us page
HusseinBaraja Apr 2, 2026
0db1b7f
feat(web): implement TrialPage and update links
HusseinBaraja Apr 2, 2026
72de2c8
fix(web): correct SVG import path and vite config type error
HusseinBaraja Apr 2, 2026
9665155
fix(env): not needed
HusseinBaraja Apr 2, 2026
0824fa1
fix(web): move RouterProvider above Layout so nav buttons get real co…
HusseinBaraja Apr 2, 2026
33a4b6a
fix(web): lift RouterProvider above Layout and animate section nav li…
HusseinBaraja Apr 2, 2026
ee09a39
fix(web): prevent FOUC on Contact Page animations
HusseinBaraja Apr 2, 2026
89c91eb
fix(web): localize contact and trial page copy for Yemen
HusseinBaraja Apr 2, 2026
6848d16
Externalize contact details in the web contact page
HusseinBaraja Apr 2, 2026
88b9248
fix(web): clean up layout gsap animations
HusseinBaraja Apr 2, 2026
b137ee4
fix(web): preserve anchor props in router links
HusseinBaraja Apr 2, 2026
8f38529
fix(web): preserve hash history in router links
HusseinBaraja Apr 2, 2026
0d5adc1
fix(web): make pricing cta block level
HusseinBaraja Apr 2, 2026
3b29ba2
fix(web): remove contact form debug logging
HusseinBaraja Apr 2, 2026
5ef9f62
fix(web): hide missing contact details
HusseinBaraja Apr 2, 2026
321213f
fix(web): require contact form message
HusseinBaraja Apr 2, 2026
657192f
fix(web): move trial page shine styles to css
HusseinBaraja Apr 2, 2026
832dcdc
fix(web): remove trial form debug logging
HusseinBaraja Apr 2, 2026
2d65c75
fix(web): label trial form inputs
HusseinBaraja Apr 2, 2026
6facc2d
chore(config): reorder site contact env examples
HusseinBaraja Apr 2, 2026
2330b74
fix(web): type vitest config in vite config
HusseinBaraja Apr 2, 2026
61e8a2f
chore(web): remove duplicate vite js config
HusseinBaraja Apr 2, 2026
05fea19
chore(web): remove stale vite config types
HusseinBaraja Apr 2, 2026
26e9a36
docs(web): add page and router jsdoc
HusseinBaraja Apr 2, 2026
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ API_RATE_LIMIT_WINDOW_MS=60000
# ── Convex ───────────────────────────────────────────
CONVEX_ADMIN_KEY=
CONVEX_URL=
SITE_CONTACT_PHONE_NUMBER=
SITE_CONTACT_EMAIL_ADDRESS=

# ── AI Providers ─────────────────────────────────────
AI_HEALTHCHECK_TIMEOUT_MS=5000
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@gsap/react": "^2.1.2",
"gsap": "^3.12.5",
"hono": "^4.12.9",
"lucide-react": "^0.470.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
Expand Down
33 changes: 17 additions & 16 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { TrieRouter } from 'hono/router/trie-router';

import { Layout } from './components/layout/Layout';
import { HeroSection } from './components/sections/HeroSection';
import { ProblemSection } from './components/sections/ProblemSection';
import { SolutionSection } from './components/sections/SolutionSection';
import { FlowSection } from './components/sections/FlowSection';
import { TrustSection } from './components/sections/TrustSection';
import { PricingSection } from './components/sections/PricingSection';
import { CTASection } from './components/sections/CTASection';
import { RouterProvider, RouteView } from './components/router/HonoRouter';
import { LandingPage } from './pages/LandingPage';
import { ContactPage } from './pages/ContactPage';
import { TrialPage } from './pages/TrialPage';

gsap.registerPlugin(useGSAP, ScrollTrigger);

const router = new TrieRouter<React.FC<any>>();

// Define routes
router.add('GET', '/', LandingPage);
router.add('GET', '/contact', ContactPage);
router.add('GET', '/trial', TrialPage);

function App() {
return (
<Layout>
<HeroSection />
<ProblemSection />
<SolutionSection />
<FlowSection />
<TrustSection />
<PricingSection />
<CTASection />
</Layout>
<RouterProvider>
<Layout>
<RouteView router={router} />
</Layout>
</RouterProvider>
);
}

Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/icons.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ArrowLeft as ArrowLeftIcon,
ArrowRight as ArrowRightIcon,
BarChart as BarChartIcon,
BarChart3 as BarChart3Icon,
Bot as BotIcon,
Expand All @@ -11,9 +12,12 @@ import {
Frown as FrownIcon,
Globe2 as Globe2Icon,
Image as ImageIconSymbol,
Mail as MailIcon,
MapPin as MapPinIcon,
MessageCircle as MessageCircleIcon,
MessageSquareOff as MessageSquareOffIcon,
PackageOpen as PackageOpenIcon,
Phone as PhoneIcon,
Search as SearchIcon,
ShieldCheck as ShieldCheckIcon,
TrendingUp as TrendingUpIcon,
Expand All @@ -25,6 +29,7 @@ import {
const asIcon = (icon: LucideIcon): LucideIcon => icon;

export const ArrowLeft = asIcon(ArrowLeftIcon);
export const ArrowRight = asIcon(ArrowRightIcon);
export const BarChart = asIcon(BarChartIcon);
export const BarChart3 = asIcon(BarChart3Icon);
export const Bot = asIcon(BotIcon);
Expand All @@ -36,9 +41,12 @@ export const Database = asIcon(DatabaseIcon);
export const Frown = asIcon(FrownIcon);
export const Globe2 = asIcon(Globe2Icon);
export const ImageIcon = asIcon(ImageIconSymbol);
export const Mail = asIcon(MailIcon);
export const MapPin = asIcon(MapPinIcon);
export const MessageCircle = asIcon(MessageCircleIcon);
export const MessageSquareOff = asIcon(MessageSquareOffIcon);
export const PackageOpen = asIcon(PackageOpenIcon);
export const Phone = asIcon(PhoneIcon);
export const Search = asIcon(SearchIcon);
export const ShieldCheck = asIcon(ShieldCheckIcon);
export const TrendingUp = asIcon(TrendingUpIcon);
Expand Down
95 changes: 83 additions & 12 deletions apps/web/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,107 @@
import React from 'react';
import React, { useEffect, useRef } from 'react';
import gsap from 'gsap';
import logoUrl from '../../assets/Reda_logo.svg';
import { Link, useLocation } from '../router/HonoRouter';

interface LayoutProps {
children: React.ReactNode;
}

export function Layout({ children }: LayoutProps) {
const { path } = useLocation();
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' });
const sectionLinksRef = useRef<HTMLDivElement>(null);
const isFirstRender = useRef(true);

const isLandingPage = path === '/';

useEffect(() => {
const container = sectionLinksRef.current;
if (!container) return;
const links = container.children;

if (isFirstRender.current) {
// On first render, just set the correct state without animation
isFirstRender.current = false;
if (!isLandingPage) {
gsap.set(links, { opacity: 0, y: -12, scale: 0.9 });
gsap.set(container, { width: 0, marginRight: 0, overflow: 'hidden' });
container.style.pointerEvents = 'none';
}
return;
}

if (isLandingPage) {
// Animate links popping back in
container.style.pointerEvents = 'auto';
gsap.to(container, {
width: 'auto',
marginRight: '',
duration: 0.35,
ease: 'power2.out',
onStart: () => {
gsap.set(container, { overflow: 'visible' });
},
});
gsap.to(links, {
opacity: 1,
y: 0,
scale: 1,
duration: 0.45,
stagger: 0.08,
ease: 'back.out(2)',
delay: 0.1,
});
} else {
// Animate links sliding out
gsap.to(links, {
opacity: 0,
y: -12,
scale: 0.9,
duration: 0.3,
stagger: 0.05,
ease: 'power2.in',
onComplete: () => {
gsap.to(container, {
width: 0,
marginRight: 0,
overflow: 'hidden',
duration: 0.25,
ease: 'power2.inOut',
});
container.style.pointerEvents = 'none';
},
});
}
}, [isLandingPage]);
Comment thread
HusseinBaraja marked this conversation as resolved.

return (
<div className="min-h-screen flex flex-col overflow-x-hidden">
<nav className="w-full fixed top-0 left-0 bg-bg-light/80 backdrop-blur-md z-50 border-b border-primary/5">
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
<a
href="#"
<Link
href="/"
aria-label="CSCB"
className="flex items-center gap-3"
onClick={(event) => {
event.preventDefault();
scrollToTop();
onClick={(event: React.MouseEvent) => {
if (isLandingPage) {
event.preventDefault();
scrollToTop();
}
}}
>
<img src={logoUrl} alt="" className="h-10 w-auto" />
<span className="text-2xl font-black text-primary tracking-tight">رضا</span>
</a>
</Link>
<div className="hidden md:flex gap-8 items-center text-primary/80 font-medium">
<a href="#features" className="hover:text-primary transition-colors">المميزات</a>
<a href="#how-it-works" className="hover:text-primary transition-colors">كيف يعمل</a>
<a href="#pricing" className="hover:text-primary transition-colors">أسعارنا</a>
<button type="button" className="bg-primary text-white px-6 py-2.5 rounded-full hover:bg-primary/90 transition-all font-semibold shadow-sm hover:shadow-md">
<div ref={sectionLinksRef} className="flex gap-8 items-center">
<Link href="/#features" className="hover:text-primary transition-colors whitespace-nowrap">المميزات</Link>
<Link href="/#how-it-works" className="hover:text-primary transition-colors whitespace-nowrap">كيف يعمل</Link>
<Link href="/#pricing" className="hover:text-primary transition-colors whitespace-nowrap">أسعارنا</Link>
</div>
<Link href="/contact" className="bg-primary text-white px-6 py-2.5 rounded-full hover:bg-primary/90 transition-all font-semibold shadow-sm hover:shadow-md">
تواصل معنا
</button>
</Link>
</div>
</div>
</nav>
Expand Down
72 changes: 70 additions & 2 deletions apps/web/src/components/layout/Layout.vitest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,33 @@ import { cleanup, createEvent, fireEvent, render, screen } from '@testing-librar
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Layout } from './Layout';

// Mock gsap for JSDOM environment
vi.mock('gsap', () => ({
default: {
set: vi.fn(),
to: vi.fn(),
},
}));

// Mock useLocation to control the path in tests
const mockNavigate = vi.fn();
let mockPath = '/';

vi.mock('../router/HonoRouter', () => ({
useLocation: () => ({ path: mockPath, navigate: mockNavigate }),
Link: ({ href, children, className, onClick }: any) => (
<a href={href} className={className} onClick={onClick}>
{children}
</a>
),
}));

describe('Layout', () => {
const scrollToMock = vi.fn();

afterEach(() => {
scrollToMock.mockReset();
mockPath = '/';
cleanup();
});

Expand Down Expand Up @@ -46,12 +68,14 @@ describe('Layout', () => {
expect(watermarkImage?.className).toContain('md:h-225');
});

it('uses a top-level anchor with smooth scroll-to-top behavior', () => {
it('uses the logo link with href="/" and scrolls to top when on landing page', () => {
Object.defineProperty(window, 'scrollTo', {
value: scrollToMock,
writable: true,
});

mockPath = '/';

render(
<Layout>
<div>content</div>
Expand All @@ -62,11 +86,55 @@ describe('Layout', () => {
const clickEvent = createEvent.click(navLogoLink);
clickEvent.preventDefault = vi.fn();

expect(navLogoLink.getAttribute('href')).toBe('#');
expect(navLogoLink.getAttribute('href')).toBe('/');

fireEvent(navLogoLink, clickEvent);

expect(clickEvent.preventDefault).toHaveBeenCalled();
expect(scrollToMock).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
});

it('always renders section links in the DOM', () => {
mockPath = '/';

render(
<Layout>
<div>content</div>
</Layout>,
);

expect(screen.getByText('المميزات')).toBeDefined();
expect(screen.getByText('كيف يعمل')).toBeDefined();
expect(screen.getByText('أسعارنا')).toBeDefined();
});

it('renders section links even on non-landing pages (animated via GSAP)', () => {
mockPath = '/contact';

render(
<Layout>
<div>content</div>
</Layout>,
);

// Links are always in the DOM (animated out visually, not removed)
expect(screen.getByText('المميزات')).toBeDefined();
expect(screen.getByText('كيف يعمل')).toBeDefined();
expect(screen.getByText('أسعارنا')).toBeDefined();
// "تواصل معنا" should still be visible
expect(screen.getByText('تواصل معنا')).toBeDefined();
});

it('sets pointer-events none on section links container for non-landing pages', () => {
mockPath = '/contact';

render(
<Layout>
<div>content</div>
</Layout>,
);

const container = screen.getByText('المميزات').closest('div');
expect(container?.style.pointerEvents).toBe('none');
});
});
Loading