Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add page for redirect to original url #40

Merged
merged 9 commits into from
Nov 11, 2023
Merged
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
10 changes: 6 additions & 4 deletions __tests__/components/Navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React from 'react';
import Navbar from '@/components/Navbar/';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';

import { fireEvent, render, screen } from '@testing-library/react';

import Navbar from '@/components/Navbar/';
import React from 'react';

describe('Navbar', () => {
it('should render', () => {
const { container } = render(<Navbar />);
expect(container).toHaveTextContent('URL Shortener');
expect(container.querySelector('a')).toHaveAttribute('href', '#');
expect(container.querySelector('a')).toHaveAttribute('href', '/');
});

it('should have dropdown menu', () => {
Expand Down
49 changes: 49 additions & 0 deletions __tests__/pages/[redirect].test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';

import Redirect from '../../src/pages/[redirect]/index';
import { useRouter } from 'next/router';

jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));

describe('Redirect Component', () => {
const mockRouterPush = jest.fn();
const mockRouterReplace = jest.fn();
const mockRouter = {
push: mockRouterPush,
replace: mockRouterReplace,
query: { redirect: 'ffdsfds' },
};

beforeEach(() => {
(useRouter as jest.Mock).mockReturnValue(mockRouter);
});

test('redirects to original URL on Go button click', async () => {
render(<Redirect />);
const goButton = screen.getByText('Go');
await act(async () => {
fireEvent.click(goButton);
});
expect(mockRouterPush).toHaveBeenCalled();
});

test('show tooltip on Go button click', async () => {
render(<Redirect />);
const goButton = screen.getByText('Go');
await act(async () => {
fireEvent.click(goButton);
});
expect(mockRouterPush).toHaveBeenCalled();
const tooltip = screen.getByText('The skip feature is exclusively available to Premium users.');
expect(tooltip).toBeInTheDocument();
});

test('redirects when timer reaches zero', async () => {
jest.useFakeTimers();
render(<Redirect />);
act(() => jest.advanceTimersByTime(5000));
waitFor(() => expect(mockRouterPush).toHaveBeenCalled());
});
});
39 changes: 39 additions & 0 deletions __tests__/utils/fetchOriginalUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import fetchMock from 'jest-fetch-mock';
import fetchOriginalUrl from '../../src/utils/fetchOriginalUrl';
import { act } from 'react-dom/test-utils';

fetchMock.enableMocks();

describe('fetchOriginalUrl', () => {
const shortUrlCode = '442d39ac';
const originalUrl = 'https://github.com/Real-Dev-Squad/tiny-site-frontend/pull/40';

it('fetches and displays the original URL if the response is successful', async () => {
const responseData = { url: { OriginalUrl: originalUrl } };

fetchMock.mockResponse(JSON.stringify(responseData), { status: 200 });
heyrandhir marked this conversation as resolved.
Show resolved Hide resolved

await act(async () => {
const result = await fetchOriginalUrl(shortUrlCode);
expect(result).toEqual(originalUrl);
});
});

it('returns null if the response is not successful', async () => {
fetchMock.mockResponse('', { status: 404 });

await act(async () => {
const result = await fetchOriginalUrl(shortUrlCode);
expect(result).toBeNull();
});
});

it('handles errors gracefully', async () => {
fetchMock.mockReject(new Error('Network error'));

await act(async () => {
const result = await fetchOriginalUrl(shortUrlCode);
expect(result).toBeNull();
});
});
});
7 changes: 7 additions & 0 deletions public/assets/icons/redirect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const RedirectIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" height="5em" viewBox="0 0 512 512" fill="#ffff">
<path d="M227.7 11.7c15.6-15.6 40.9-15.6 56.6 0l216 216c15.6 15.6 15.6 40.9 0 56.6l-216 216c-15.6 15.6-40.9 15.6-56.6 0l-216-216c-15.6-15.6-15.6-40.9 0-56.6l216-216zm87.6 137c-4.6-4.6-11.5-5.9-17.4-3.5s-9.9 8.3-9.9 14.8v56H224c-35.3 0-64 28.7-64 64v48c0 13.3 10.7 24 24 24s24-10.7 24-24V280c0-8.8 7.2-16 16-16h64v56c0 6.5 3.9 12.3 9.9 14.8s12.9 1.1 17.4-3.5l80-80c6.2-6.2 6.2-16.4 0-22.6l-80-80z" />
</svg>
);

export default RedirectIcon;
28 changes: 28 additions & 0 deletions public/assets/icons/share.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const ShareIcon = () => (
<svg
version="1.0"
xmlns="http://www.w3.org/2000/svg"
width="2em"
height="2em"
viewBox="0 0 513.000000 522.000000"
fill="#ffff"
>
<title>Visit</title>
<g transform="translate(0.000000,522.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">
<path
d="M2710 4616 l0 -606 -22 -4 c-13 -2 -77 -9 -143 -16 -501 -49 -995
-249 -1430 -579 -142 -107 -421 -390 -531 -536 -348 -465 -535 -968 -574
-1544 -7 -90 -10 -382 -8 -691 l3 -534 184 424 c201 466 247 561 356 725 358
542 894 941 1530 1138 160 50 400 95 582 110 l53 4 2 -605 3 -604 365 298
c201 165 740 605 1198 978 458 374 832 683 832 687 0 3 -57 53 -127 110 -345
280 -2184 1777 -2225 1812 l-48 39 0 -606z m1124 -696 c443 -360 805 -657 805
-660 0 -3 -365 -303 -812 -667 l-812 -663 -3 440 -2 440 -138 0 c-848 -1
-1640 -340 -2232 -956 -98 -102 -240 -274 -302 -364 l-17 -25 6 40 c25 189 96
446 179 642 294 702 918 1255 1649 1462 238 67 391 90 663 98 l192 6 0 434 c0
271 4 433 10 431 5 -1 371 -298 814 -658z"
/>
</g>
</svg>
);

export default ShareIcon;
5 changes: 3 additions & 2 deletions src/components/Footer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import Link from 'next/link';
import React from 'react';

const Footer: React.FC = () => {
return (
<div className="bg-gray-200 flex justify-center items-center h-[6vh]">
<p className="text-gray-600 text-sm text-center">
The contents of this website are deployed from this{' '}
<a
<Link
href="https://github.com/Real-Dev-Squad/tiny-site-frontend"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500"
>
open sourced repo
</a>
</Link>
</p>
</div>
);
Expand Down
24 changes: 13 additions & 11 deletions src/components/Navbar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React, { useEffect, useState } from 'react';
import { TINY_API_GOOGLE_LOGIN, TINY_API_LOGOUT } from '@/constants/url';

import Button from '@/components/Button';
import ProfileIcon from '../ProfileIcon/ProfileIcon';
import GoogleIcon from '../../../public/assets/icons/google';
import DownArrowIcon from '../../../public/assets/icons/downArrow';
import GoogleIcon from '../../../public/assets/icons/google';
import IsAuthenticated from '@/hooks/isAuthenticated';
import { TINY_API_GOOGLE_LOGIN, TINY_API_LOGOUT } from '@/constants/url';
import Link from 'next/link';
import ProfileIcon from '../ProfileIcon/ProfileIcon';

const Navbar: React.FC = () => {
const [menuOpen, setMenuOpen] = useState(false);
Expand All @@ -30,9 +32,9 @@ const Navbar: React.FC = () => {
return (
<nav className="bg-gray-800 p-4">
<div className="flex items-center justify-between">
<a href="#" className="text-white text-2xl font-bold">
<Link href="/" className="text-white text-2xl font-bold">
URL Shortener
</a>
</Link>

<ul className={'lg:flex space-x-4'}>
<li className="relative group">
Expand All @@ -45,27 +47,27 @@ const Navbar: React.FC = () => {
</div>
</Button>
) : (
<a
<Link
className="flex items-center space-x-2 bg-white text-black px-4 py-2 rounded-md hover:bg-gray-100 cursor-pointer"
href={TINY_API_GOOGLE_LOGIN}
data-testid="google-login"
>
<GoogleIcon />

<span>Sign In</span>
</a>
</Link>
)}
</li>
<ul className={`${menuOpen ? 'block' : 'hidden'} absolute top-[8vh] right-0 bg-gray-800 p-2 z-10`}>
<li>
<a href="#" className="text-white hover:bg-gray-700 block px-4 py-2">
<Link href="#" className="text-white hover:bg-gray-700 block px-4 py-2">
Dashboard
</a>
</Link>
</li>
<li>
<a href={TINY_API_LOGOUT} className="text-white hover:bg-gray-700 block px-4 py-2">
<Link href={TINY_API_LOGOUT} className="text-white hover:bg-gray-700 block px-4 py-2">
Sign Out
</a>
</Link>
</li>
</ul>
</ul>
Expand Down
4 changes: 3 additions & 1 deletion src/constants/url.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const TINY_API_URL = 'https://staging-tinysite-api.realdevsquad.com/v1';
export const TINY_API_GOOGLE_LOGIN = `${TINY_API_URL}/auth/google/login`;
export const TINY_API_LOGOUT = `${TINY_API_URL}/auth/logout`;
export const BASE_SHORT_URL = 'https://staging-tinysite.realdevsquad.com';
export const TINY_SITE = 'https://staging-tinysite.realdevsquad.com';
sahsisunny marked this conversation as resolved.
Show resolved Hide resolved
export const TINY_API_URL_DETAIL = `${TINY_API_URL}/urls`;
export const TINY_API_REDIRECT = `${TINY_API_URL}/tinyurl`;
113 changes: 113 additions & 0 deletions src/pages/[redirect]/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';

import Head from 'next/head';
import Link from 'next/link';
import RedirectIcon from '../../../public/assets/icons/redirect';
import { TINY_SITE } from '@/constants/url';
import fetchOriginalUrl from '@/utils/fetchOriginalUrl';
import { useRouter } from 'next/router';

interface LoaderTimerProps {
timer: number;
goButtonClickHandler: () => void;
}

const LoaderTimer = ({ timer, goButtonClickHandler }: LoaderTimerProps) => {
if (timer < 1) {
return (
<div className="mt-4 flex flex-col items-center space-y-2">
<RedirectIcon />
<p className="text-1xl">Redirecting...</p>
</div>
);
} else {
return (
<>
<div className="loader border-t-4 rounded-full border-gray-500 animate-spin aspect-square w-20 flex justify-center items-center text-yellow-700 text-4xl font-bold mt-4">
{timer}
</div>
<button onClick={goButtonClickHandler} className="mt-4 p-2 bg-blue-500 text-white rounded-md">
Go
</button>
</>
);
}
};

const RedirectFooter = () => (
<div className="absolute bottom-0 right-0 p-2 text-gray-500 w-screen flex justify-center items-center">
<Link
className="text-sm text-gray-400 font-bold cursor-pointer hover:underline"
href={TINY_SITE}
target="_blank"
rel="noopener noreferrer"
>
By <span className="font-bold">Real Dev Squad</span>
</Link>
</div>
);

const Redirect = () => {
const router = useRouter();
const { redirect: shortUrlCode } = router.query as { redirect: string };
const [originalUrl, setOriginalUrl] = useState('');
const [timer, setTimer] = useState(5);
const [isPremiumUser] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);

useEffect(() => {
if (shortUrlCode) {
heyrandhir marked this conversation as resolved.
Show resolved Hide resolved
const fetchOriginalUrlAsync = async () => {
try {
const url = await fetchOriginalUrl(shortUrlCode);
if (url) {
setOriginalUrl(url);
} else {
router.push('/');
}
} catch (error) {
console.error('Error fetching original URL:', error);
}
};
fetchOriginalUrlAsync();
}

if (timer > 0) {
const countdown = setTimeout(() => setTimer(timer - 1), 1000);
return () => clearTimeout(countdown);
} else if (timer === 0) {
router.push(originalUrl);
}
}, [shortUrlCode, timer, originalUrl]);

const handleGoButtonClick = () => {
if (isPremiumUser) {
router.push(originalUrl);
} else {
setShowTooltip(true);
}
};

return (
<>
<Head>
<title>Redirecting...</title>
<meta name="robots" content="noindex" />
</Head>
<div className="w-screen min-h-screen flex flex-col items-center justify-center bg-gray-900 text-white p-4">
<p className="text-lg">You are being redirected to:</p>
<p className="text-blue-500 text-xl font-bold w-1/2 text-center truncate xl:w-1/2">{originalUrl}</p>
<LoaderTimer timer={timer} goButtonClickHandler={handleGoButtonClick} />

{showTooltip && !isPremiumUser && (
<div className="mt-2 p-2 bg-yellow-100 text-yellow-800 rounded-md">
The skip feature is exclusively available to Premium users.
</div>
)}
<RedirectFooter />
</div>
</>
);
};

export default Redirect;
Loading