- You are being redirected to:
-
+
+
+
+
+ You are being redirected to:
+
{data?.url.originalUrl}
+
+ If you are not redirected within a few seconds, please{' '}
+
+ .
+
+
{showTooltip && !isPremiumUser && (
-
+
The skip feature is exclusively available to Premium users.
)}
@@ -68,4 +90,5 @@ const Redirect = () => {
>
);
};
+
export default Redirect;
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index 38d26d6..7f0abe2 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -4,20 +4,24 @@ import { AppProps } from 'next/app';
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
-import CreateNew from '@/components/CreateNew';
-
interface MyAppProps {
Component: React.FC
;
pageProps: AppProps;
}
-export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 3 } } });
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 3,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
export default function MyApp({ Component, pageProps }: MyAppProps) {
return (
-
);
}
diff --git a/src/pages/app/index.tsx b/src/pages/app/index.tsx
index d04bd45..75f0880 100644
--- a/src/pages/app/index.tsx
+++ b/src/pages/app/index.tsx
@@ -1,112 +1,117 @@
+import { AxiosError } from 'axios';
import React, { useEffect, useState } from 'react';
-import InputSection from '@/components/App/InputSection';
import OutputSection from '@/components/App/OutputSection';
+import ShortenUrlForm from '@/components/App/ShortenUrlForm';
import Layout from '@/components/Layout';
import LoginModal from '@/components/LoginModal';
-import Toast from '@/components/Toast';
+import Modal from '@/components/Modal';
import { TINY_SITE } from '@/constants/url';
import useAuthenticated from '@/hooks/useAuthenticated';
-import useToast from '@/hooks/useToast';
-import { useShortenUrlMutation } from '@/services/api';
-import { ErrorResponse } from '@/types/url.types';
+import { ApiError, useShortenUrlMutation } from '@/services/api';
+import { formatUrl } from '@/utils/formatUrl';
import validateUrl from '@/utils/validateUrl';
const App = () => {
const [url, setUrl] = useState('');
const [shortUrl, setShortUrl] = useState('');
- const [showInputBox, setShowInputBox] = useState(true);
const [showLoginModal, setShowLoginModal] = useState(false);
+ const [showOutputModal, setShowOutputModal] = useState(false);
+ const [error, setError] = useState(null);
- const { showToast, toasts } = useToast();
const { isLoggedIn, userData } = useAuthenticated();
- const shortenUrlMutation = useShortenUrlMutation();
- useEffect(() => {
- const localUrl = localStorage.getItem('url');
-
- if (isLoggedIn && localUrl) {
- setUrl(localUrl);
- generateShortUrl(localUrl);
- localStorage.removeItem('url');
- }
- }, [isLoggedIn]);
-
- const generateShortUrl = async (url: string) => {
- if (!validateUrl(url, showToast)) return;
-
- try {
- const response = await shortenUrlMutation.mutateAsync({
- originalUrl: url,
- userData: userData,
- });
-
- const fullShortUrl = `${TINY_SITE}/${response.shortUrl}`;
+ const mutation = useShortenUrlMutation({
+ onSuccess: (res: { shortUrl: string }) => {
+ setError(null);
+ const fullShortUrl = `${TINY_SITE}/${res.shortUrl}`;
setShortUrl(fullShortUrl);
- setShowInputBox(false);
- } catch (e) {
- const error = e as ErrorResponse;
- if (error.response && error.response.data && error.response.data.message) {
- showToast(error.response.data.message, 3000, 'error');
+ setShowOutputModal(true);
+ localStorage.removeItem('url');
+ },
+ onError: (error: AxiosError) => {
+ if (error.response?.data?.message) {
+ setError(error.response.data.message);
} else {
- showToast('An unexpected error occurred', 3000, 'error');
+ setError('An unexpected error occurred');
}
- setUrl('');
- }
- };
-
- const handleCopyUrl = () => {
- if (shortUrl) {
- navigator.clipboard.writeText(shortUrl);
- showToast('Copied to clipboard', 3000, 'success');
- }
- };
+ },
+ });
const createNewHandler = () => {
setUrl('');
setShortUrl('');
- setShowInputBox(true);
+ setShowOutputModal(false);
+ setError(null);
};
- const handleUrl = () => {
+ const createShortenedUrl = (originalUrl: string) => {
if (!isLoggedIn) {
setShowLoginModal(true);
- if (url) localStorage.setItem('url', url);
- } else {
- if (!shortenUrlMutation.isLoading) {
- generateShortUrl(url);
- }
+ localStorage.setItem('url', originalUrl);
+ return;
}
+
+ const validationResult = validateUrl(url);
+
+ if (!validationResult.isValid) {
+ setError(validationResult.errorMessage);
+ return;
+ }
+
+ const formattedUrl = formatUrl(url);
+ mutation.mutate({ originalUrl: formattedUrl, userData });
};
+ const clearError = () => setError(null);
+
+ useEffect(() => {
+ const localUrl = localStorage.getItem('url');
+ if (!isLoggedIn || !localUrl) return;
+
+ setUrl(localUrl);
+ }, [isLoggedIn]);
+
return (
-
-
-
- {showInputBox ? (
-
- ) : (
-
- )}
-
- {toasts.map((toast) => (
-
- ))}
- {showLoginModal && (
-
setShowLoginModal(false)}
- children={Log in to generate short links
}
- />
- )}
+
+
+
+
+ {showLoginModal && (
+ setShowLoginModal(false)}>
+ Log in to generate short links
+
+ )}
+
+ {showOutputModal && (
+ {
+ setShowOutputModal(false);
+ setUrl('');
+ }}
+ width="550px"
+ height="560px"
+ >
+
+
+ )}
);
};
-
export default App;
diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx
index f658aa2..501836e 100644
--- a/src/pages/dashboard/index.tsx
+++ b/src/pages/dashboard/index.tsx
@@ -29,12 +29,9 @@ const Dashboard = () => {
return (
-
void 0}
- children={
- Login to view your URLs and create new ones
- }
- />
+ void 0}>
+ Login to view your URLs and create new ones
+
);
@@ -53,7 +50,7 @@ const Dashboard = () => {
if (isError) {
return (
-
+
Oops, we're unable to get your links at this time.
@@ -81,8 +78,8 @@ const Dashboard = () => {
return (
-
-
+
+
{urls.map((url) => (
))}
diff --git a/src/services/api.tsx b/src/services/api.tsx
index e52f3ad..1fc930c 100644
--- a/src/services/api.tsx
+++ b/src/services/api.tsx
@@ -1,23 +1,29 @@
-import axios from 'axios';
-import { useMutation, useQuery } from 'react-query';
+import axios, { AxiosError } from 'axios';
+import { useMutation, UseMutationResult, useQuery } from 'react-query';
import { TINY_API_URL, TINY_API_URL_DETAIL } from '@/constants/url';
import { UrlType } from '@/types/url.types';
import { User } from '@/types/user.types';
-
interface ShortenUrlRequest {
OriginalUrl: string;
Comment: string;
CreatedBy: string;
UserId: number;
}
+
+interface ShortenUrlResponse {
+ shortUrl: string;
+}
+
interface MutationParams {
originalUrl: string;
userData: User;
}
-interface ShortenUrlResponse {
- shortUrl: string;
+export interface ApiError {
+ message: string;
+ statusCode: number;
+ details?: string;
}
const useAuthenticatedQuery = () => {
@@ -34,7 +40,6 @@ const useAuthenticatedQuery = () => {
staleTime: 60 * 60 * 1000,
});
};
-
const useGetOriginalUrlQuery = (shortUrlCode: string, options: { enabled: boolean }) => {
return useQuery({
queryKey: ['originalUrl', shortUrlCode],
@@ -43,15 +48,12 @@ const useGetOriginalUrlQuery = (shortUrlCode: string, options: { enabled: boolea
retry: false,
});
};
-
const getUrlsApi = async (): Promise<{ message: string; urls: UrlType[] }> => {
const { data } = await axios.get(`${TINY_API_URL}/urls/self`, {
withCredentials: true,
});
-
return data;
};
-
const useGetUrlsQuery = ({ enabled = true }: { enabled?: boolean }) => {
return useQuery({
queryKey: ['urls'],
@@ -60,7 +62,16 @@ const useGetUrlsQuery = ({ enabled = true }: { enabled?: boolean }) => {
});
};
-const useShortenUrlMutation = () => {
+interface useShortenUrlMutationArgs {
+ onSuccess?: (data: ShortenUrlResponse) => void;
+ onError?: (error: AxiosError) => void;
+}
+
+const useShortenUrlMutation = ({ onSuccess, onError }: useShortenUrlMutationArgs = {}): UseMutationResult<
+ ShortenUrlResponse,
+ AxiosError,
+ MutationParams
+> => {
return useMutation(
async ({ originalUrl, userData }: MutationParams) => {
const response = await axios.post(
@@ -79,10 +90,11 @@ const useShortenUrlMutation = () => {
},
{
retry: false,
+ onSuccess,
+ onError,
}
);
};
-
const deleteUrlApi = async ({ id, userId }: { id: number; userId: number }) => {
const { data } = await axios.delete(`${TINY_API_URL}/urls/${id}`, {
withCredentials: true,
@@ -90,5 +102,4 @@ const deleteUrlApi = async ({ id, userId }: { id: number; userId: number }) => {
});
return data;
};
-
export { deleteUrlApi, useAuthenticatedQuery, useGetOriginalUrlQuery, useGetUrlsQuery, useShortenUrlMutation };
diff --git a/src/styles/global.css b/src/styles/global.css
index b5c61c9..fdf58ff 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1,3 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+
+body {
+ background: linear-gradient(
+ to bottom,
+ rgba(30, 66, 159, 1),
+ rgba(26, 86, 219, 1),
+ rgba(118, 169, 250, 1),
+ rgba(142, 194, 250, 1)
+ );
+}
diff --git a/src/types/button.types.ts b/src/types/button.types.ts
index 7fdf1c2..926bfbe 100644
--- a/src/types/button.types.ts
+++ b/src/types/button.types.ts
@@ -1,8 +1,9 @@
export interface ButtonProps {
- type?: 'button' | 'submit' | 'reset';
+ testId?: string;
className: string;
- onClick: (event: React.MouseEvent) => void;
- children: React.ReactNode;
disabled?: boolean;
- testId?: string;
+ children: React.ReactNode;
+ type?: 'button' | 'submit' | 'reset';
+ onClick?: (event: React.MouseEvent) => void;
+ loading?: boolean;
}
diff --git a/src/utils/formatDate.ts b/src/utils/formatDate.ts
index c5d38f3..7f6b996 100644
--- a/src/utils/formatDate.ts
+++ b/src/utils/formatDate.ts
@@ -26,16 +26,21 @@ function formatDate({ inputDate, relativeDuration = false, fullDate = false }: F
return `${days}d ago`;
}
} else {
- const options: Intl.DateTimeFormatOptions = {
- year: 'numeric',
+ const datePart = dateToFormat.toLocaleDateString('en-GB', {
+ day: '2-digit',
month: 'long',
- day: 'numeric',
- hour: fullDate ? 'numeric' : undefined,
- minute: fullDate ? 'numeric' : undefined,
- second: fullDate ? 'numeric' : undefined,
- };
+ year: 'numeric',
+ });
+
+ const timePart = fullDate
+ ? dateToFormat.toLocaleTimeString('en-GB', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })
+ : '';
- return dateToFormat.toLocaleDateString('en-US', options);
+ return fullDate ? `${datePart} ${timePart}` : datePart;
}
}
diff --git a/src/utils/formatUrl.ts b/src/utils/formatUrl.ts
new file mode 100644
index 0000000..2b2d216
--- /dev/null
+++ b/src/utils/formatUrl.ts
@@ -0,0 +1,6 @@
+export const formatUrl = (url: string): string => {
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
+ return `https://${url}`;
+ }
+ return url;
+};
diff --git a/src/utils/validateUrl.ts b/src/utils/validateUrl.ts
index ee083a5..fcb50de 100644
--- a/src/utils/validateUrl.ts
+++ b/src/utils/validateUrl.ts
@@ -1,19 +1,15 @@
-import { urlRegex } from '@/constants/constants';
+interface ValidationResult {
+ isValid: boolean;
+ errorMessage: string | null;
+}
-const validateUrl = (
- url: string,
- showToast: (message: string, duration?: number, type?: 'success' | 'info' | 'error') => void
-) => {
- if (!url) {
- showToast('Enter the URL', 3000, 'error');
- return false;
+const validateUrl = (url: string): ValidationResult => {
+ try {
+ new URL(url);
+ return { isValid: true, errorMessage: null };
+ } catch (error) {
+ return { isValid: false, errorMessage: 'Enter a valid URL' };
}
-
- if (!urlRegex.test(url)) {
- showToast('Enter a valid URL', 3000, 'info');
- return false;
- }
- return true;
};
export default validateUrl;
diff --git a/tailwind.config.ts b/tailwind.config.ts
index f94251b..5f7fb88 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -8,12 +8,15 @@ const config: Config = {
],
theme: {
extend: {
- backgroundImage: {
- 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
- 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
+ fontFamily: {
+ body: ['Space Mono', 'monospace'],
+ },
+ colors: {
+ 'custom-blue': '#1A56DB',
},
},
},
plugins: [],
};
+
export default config;