Skip to content

Commit

Permalink
Feature/f 63 subscribe page (#29)
Browse files Browse the repository at this point in the history
* fear: init subscribe page

* feat: backend impl

* feat: change subscribe page to popup modal

* feat: subscribe btn animation

* feat: add icon and fix input and select

* feat: change input placeholder color

* fix: change form style

* fix: refactoring duplicate code and fix form issue

* fix: eslint format

* fix: remove unneeded files

* fix: add comment and refactoring form style and isvalid status

---------

Co-authored-by: Haidong Xu <[email protected]>
  • Loading branch information
Esoteriker and Haidong Xu authored Nov 15, 2024
1 parent 740d77a commit 570fb16
Show file tree
Hide file tree
Showing 12 changed files with 10,141 additions and 6,760 deletions.
17 changes: 16 additions & 1 deletion src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,24 @@ import { Link } from '@nextui-org/link';
import clsx from 'clsx';
import { SidebarLeft } from 'iconsax-react';
import NextImage from 'next/image';
import { useState } from 'react';

import { AlertsMenu } from '@/components/AlertsMenu/AlertsMenu';
import { LogoWithText } from '@/components/LogoWithText/LogoWithText';
import { CollapsedSidebar } from '@/components/Sidebar/CollapsedSidebar';
import { ThemeSwitch } from '@/components/Sidebar/ThemeSwitch';
import { pageLinks } from '@/domain/constant/PageLinks';
import { SUBSCRIBE_MODAL_TITLE } from '@/domain/constant/subscribe/Subscribe';
import { useSidebar } from '@/domain/contexts/SidebarContext';
import { AlertsMenuVariant } from '@/domain/enums/AlertsMenuVariant';
import { SidebarOperations } from '@/operations/sidebar/SidebarOperations';

import PopupModal from '../PopupModal/PopupModal';
import Subscribe from '../Subscribe/Subscribe';

export function Sidebar() {
const { isSidebarOpen, toggleSidebar, selectedMapType, setSelectedMapType } = useSidebar();
const [isModalOpen, setIsModalOpen] = useState(false);

if (!isSidebarOpen) {
return <CollapsedSidebar />;
Expand Down Expand Up @@ -68,9 +74,18 @@ export function Sidebar() {
</CardBody>
<CardFooter>
<div className="flex flex-col gap-1">
<Button radius="full" onClick={() => alert('Subscribe!')} size="sm" className="w-fit">
<Button radius="full" onClick={() => setIsModalOpen(!isModalOpen)} size="sm" className="w-fit">
SUBSCRIBE
</Button>
<PopupModal
isModalOpen={isModalOpen}
toggleModal={() => setIsModalOpen(!isModalOpen)}
modalTitle={SUBSCRIBE_MODAL_TITLE}
modalSize="lg"
modalHeight="auto"
>
<Subscribe />
</PopupModal>
<ul className="pl-3">
{pageLinks.map((page) => (
<li key={page.label}>
Expand Down
29 changes: 29 additions & 0 deletions src/components/Subscribe/SocialLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { motion } from 'framer-motion';

import { SocialLinkProps } from '@/domain/props/SocialLinkProps';

/**
* SocialLink component
* @param param0 is an object containing the social media href and Icon component
* @returns a styled anchor tag with the social media Icon component
*/
export function SocialLink({ href, children }: SocialLinkProps) {
return (
<motion.a
href={href}
target="_blank"
rel="noopener noreferrer"
className="relative p-2 rounded-full transition-colors"
whileHover={{ scale: 1.5, zIndex: 1 }}
whileTap={{ scale: 0.95 }}
layout
>
<motion.div
className="absolute inset-0 rounded-full opacity-0"
whileHover={{ opacity: 1, scale: 1.2 }}
transition={{ duration: 0.2 }}
/>
{children}
</motion.a>
);
}
226 changes: 226 additions & 0 deletions src/components/Subscribe/Subscribe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
'use client';

import { Button, Divider, Input, Select, SelectItem } from '@nextui-org/react';
import { motion } from 'framer-motion';
import { ChartCircle, CloseCircle, Facebook, Instagram, TickCircle, Twitch, Youtube } from 'iconsax-react';
import { useCallback, useState } from 'react';

import container from '@/container';
import {
MANDATORY,
SUBSCRIBE,
SUBSCRIBE_MODAL_SUBTITLE,
SUCCESSFUL_SUBSCRIPTION,
UNSUCCESSFUL_SUBSCRIPTION,
} from '@/domain/constant/subscribe/Subscribe';
import { SubscribeStatus, SubscribeTopic } from '@/domain/enums/SubscribeTopic';
import SubscriptionRepository from '@/domain/repositories/SubscriptionRepository';

import { SocialLink } from './SocialLink';

export default function SubscriptionForm() {
const subscribe = container.resolve<SubscriptionRepository>('SubscriptionRepository');
const [name, setName] = useState('');
const [organization, setOrganization] = useState('');
const [email, setEmail] = useState('');
const [selectedTopic, setSelectedTopic] = useState<string>('');

const [isNameInvalid, setIsNameInvalid] = useState(false);
const [isEmailInvalid, setIsEmailInvalid] = useState(false);

const [subscribeStatus, setSubscribeStatus] = useState<SubscribeStatus>(SubscribeStatus.Idle);
const [isWaitingSubResponse, setIsWaitingSubResponse] = useState(false);

const validateEmail = useCallback((newEmail: string): boolean => {
return !!newEmail.match(/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i);
}, []);

const changeName = useCallback((newName: string): void => {
setName(newName);
setIsNameInvalid(!newName);
}, []);

const changeEmail = useCallback(
(newEmail: string): void => {
setEmail(newEmail);
setIsEmailInvalid(!validateEmail(newEmail));
},
[validateEmail]
);

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
// Validate the form
const isFormInvalid = !name || !email || !validateEmail(email);

setIsNameInvalid(!name);
setIsEmailInvalid(!validateEmail(email));

if (!isEmailInvalid && !isNameInvalid && !isWaitingSubResponse && !isFormInvalid) {
setSubscribeStatus(SubscribeStatus.Loading);
// Handle form submission here and interact with the backend
try {
setIsWaitingSubResponse(true);
// TODO: backend integration not working rn
await subscribe
.subscribe({
name,
email,
selectedTopic,
organization,
})
.then((res) => {
if (res) {
setSubscribeStatus(SubscribeStatus.Success);
setIsWaitingSubResponse(false);
} else {
setSubscribeStatus(SubscribeStatus.Error);
setIsWaitingSubResponse(false);
}
});
// TODO: Mock response to be removed later
// console.log({
// name,
// email,
// selectedTopic,
// organization,
// });
// const response = false;
// if (response) {
// setTimeout(() => {
// setSubscribeStatus(SubscribeStatus.Success);
// setIsWaitingSubResponse(false);
// }, 2000);
// } else {
// setTimeout(() => {
// setSubscribeStatus(SubscribeStatus.Error);
// setIsWaitingSubResponse(false);
// }, 2000);
// }
} catch (err) {
throw new Error(err instanceof Error ? err.message : String(err));
}
}
};

return (
<div className="flex flex-col items-center">
<Divider className="bg-subscribeText dark:bg-subscribeText" />
<p className="mb-12 text-justify text-subscribeText dark:text-subscribeText">{SUBSCRIBE_MODAL_SUBTITLE}</p>
<p className="text-sm italic self-start text-subscribeText dark:text-subscribeText">{MANDATORY}</p>
<form onSubmit={handleSubmit} className="flex flex-col space-y-2 mb-3 w-full">
<Input
label="Name"
placeholder="Please enter your name"
color={isNameInvalid ? 'danger' : 'default'}
isInvalid={isNameInvalid}
errorMessage="Name is required"
variant="faded"
isRequired
value={name}
onChange={(changeNameEvent) => changeName(changeNameEvent.target.value)}
/>
<Input
label="Email"
placeholder="please enter your email"
type="email"
variant="faded"
isRequired
isInvalid={isEmailInvalid}
color={isEmailInvalid ? 'danger' : 'default'}
errorMessage="Please enter a valid email"
value={email}
onChange={(changeEmailEvent) => changeEmail(changeEmailEvent.target.value)}
/>
<Input
label="Organization/Institution"
placeholder="Please enter your organization"
color="default"
variant="faded"
errorMessage="Please enter a valid organization"
onChange={(changeOrgEvent) => setOrganization(changeOrgEvent.target.value)}
value={organization}
/>
<Select
label="Topic"
placeholder="Please select a topic"
selectedKeys={selectedTopic ? [selectedTopic] : []}
onSelectionChange={(keys) => setSelectedTopic(Array.from(keys)[0] as string)}
color="default"
variant="faded"
errorMessage="Please select a valid topic"
value={selectedTopic}
>
{Object.entries(SubscribeTopic).map(([key, value]) => (
<SelectItem key={key} value={value}>
{value}
</SelectItem>
))}
</Select>

<Button
type="submit"
className="w-full bg-subscribeText dark:bg-subscribeText text-white dark:text-black shadow-lg self-center"
>
<motion.span initial={{ opacity: 1 }} animate={{ opacity: subscribeStatus === SubscribeStatus.Idle ? 1 : 0 }}>
{SUBSCRIBE}
</motion.span>
<motion.span
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: subscribeStatus === SubscribeStatus.Loading ? 1 : 0 }}
>
<ChartCircle size={24} className="animate-spin" />
</motion.span>
<motion.span
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: subscribeStatus === SubscribeStatus.Success ? 1 : 0 }}
>
<TickCircle size={24} className="text-green-500" />
</motion.span>
<motion.span
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: subscribeStatus === SubscribeStatus.Error ? 1 : 0 }}
>
<CloseCircle size={24} className="text-red-500" />
</motion.span>
</Button>
{subscribeStatus === SubscribeStatus.Success && (
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-green-500 mt-4 text-center"
>
{SUCCESSFUL_SUBSCRIPTION}
</motion.p>
)}
{subscribeStatus === SubscribeStatus.Error && (
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-red-500 mt-4 text-center"
>
{UNSUCCESSFUL_SUBSCRIPTION}
</motion.p>
)}
</form>

<div className="flex gap-1">
<SocialLink href="https://twitch.com/">
<Twitch size={24} color="#6441A4" />
</SocialLink>
<SocialLink href="https://facebook.com/">
<Facebook size={24} color="#1877F2" />
</SocialLink>
<SocialLink href="https://youtube.com/">
<Youtube size={24} color="#FF0000" />
</SocialLink>
<SocialLink href="https://instagram.com/">
<Instagram size={24} color="#E1306C" />
</SocialLink>
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions src/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AlertRepositoryImpl } from './infrastructure/repositories/AlertReposito
import ChatbotRepositoryImpl from './infrastructure/repositories/ChatbotRepositoryImpl';
import CountryRepositoryImpl from './infrastructure/repositories/CountryRepositoryImpl';
import GlobalDataRepositoryImpl from './infrastructure/repositories/GlobalDataRepositoryImpl';
import SubscriptionRepositoryImpl from './infrastructure/repositories/SubscriptionRepositoryImpl';

class Container {
private dependencies: { [key: string]: unknown } = {};
Expand All @@ -24,5 +25,6 @@ container.register('CountryRepository', new CountryRepositoryImpl());
container.register('AlertRepository', new AlertRepositoryImpl());
container.register('GlobalDataRepository', new GlobalDataRepositoryImpl());
container.register('ChatbotRepository', new ChatbotRepositoryImpl());
container.register('SubscriptionRepository', new SubscriptionRepositoryImpl());

export default container;
14 changes: 14 additions & 0 deletions src/domain/constant/subscribe/Subscribe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const SUBSCRIBE_MODAL_TITLE = 'Subscribe to new updates';

export const SUBSCRIBE_MODAL_SUBTITLE =
'WFP’s Hunger Monitoring Unit produces recurrent updates, information, newsletters and snapshots. Add your details below to stay in touch and receive the latest news.';

export const SUBSCRIBE = 'Subscribe';

export const MANDATORY = '*Mandatory fields';

export const FOLLOW_US = 'Follow us on: ';

export const SUCCESSFUL_SUBSCRIPTION = 'Successfully subscribed!';

export const UNSUCCESSFUL_SUBSCRIPTION = 'Subscribed failed. Please try again later.';
6 changes: 6 additions & 0 deletions src/domain/entities/subscribe/Subscribe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface ISubscribe {
name: string;
email: string;
selectedTopic?: string;
organization?: string;
}
12 changes: 12 additions & 0 deletions src/domain/enums/SubscribeTopic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export enum SubscribeTopic {
FOOD_SECURITY = 'Food Security',
NUTRITION = 'Nutrition',
EMERGENCY_RESPONSE = 'Emergency Response',
}

export enum SubscribeStatus {
Idle = 'idle',
Loading = 'loading',
Success = 'success',
Error = 'error',
}
4 changes: 4 additions & 0 deletions src/domain/props/SocialLinkProps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface SocialLinkProps {
href: string;
children: React.ReactNode;
}
10 changes: 10 additions & 0 deletions src/domain/repositories/SubscriptionRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ISubscribe } from '../entities/subscribe/Subscribe';

export default interface SubscriptionRepository {
/**
* Subscribes a user to a topic.
* @param subscribe is the subscription details.
* @returns A promise that resolves to a boolean indicating the success of the operation.
*/
subscribe(subscribe: ISubscribe): Promise<boolean>;
}
24 changes: 24 additions & 0 deletions src/infrastructure/repositories/SubscriptionRepositoryImpl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ISubscribe } from '@/domain/entities/subscribe/Subscribe';
import SubscriptionRepository from '@/domain/repositories/SubscriptionRepository';

export default class SubscriptionRepositoryImpl implements SubscriptionRepository {
async subscribe(subscribe: ISubscribe): Promise<boolean> {
try {
// TODO: endpoint is not clear right now, can change later
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscribe),
});

if (response.ok) {
return Promise.resolve(true);
}
return Promise.resolve(false);
} catch (error) {
return Promise.reject(error);
}
}
}
Loading

0 comments on commit 570fb16

Please sign in to comment.