style: added shadcn-ui

This commit is contained in:
Riccardo
2023-12-05 20:13:29 +01:00
parent 1b0919a460
commit 78de374cba
45 changed files with 1463 additions and 1340 deletions

View File

@@ -0,0 +1,41 @@
import { ReactNode } from 'react';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '../../components/ui/card';
import Footer from './footer';
type CustomCardProps = {
title: string;
description?: string;
content: ReactNode;
style?: string;
footer?: boolean;
};
export const CustomCard = ({
title,
description,
content,
style,
footer = true,
}: CustomCardProps) => {
return (
<Card className={style ?? 'w-full sm:w-2/3 md:w-2/5 lg:w-1/3 xl:w-1/4'}>
<CardHeader className="text-center">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{content}</CardContent>
{footer && (
<CardFooter className="flex justify-center space-x-4">
<Footer />
</CardFooter>
)}
</Card>
);
};

View File

@@ -0,0 +1,5 @@
'use client';
export default function ErrorMessage() {
return 'Oops. Something went wrong. Please try later :(';
}

View File

@@ -0,0 +1,28 @@
import { usePathname } from 'next/navigation';
import { Link } from './link';
const links = [
{ name: 'Subscribe', path: '/' },
{ name: 'Privacy Policy', path: '/privacy' },
];
function Footer() {
const pathname = usePathname();
return (
<ul className="flex justify-center space-x-4">
{links.map(
(link) =>
pathname !== link.path &&
!(pathname === '/confirmation' && link.path === '/subscribe') && (
<Link key={link.path} path={link.path} text={link.name} />
)
)}
{pathname === '/privacy' && (
<Link path="/unsubscribe" text="Unsubscribe" />
)}
</ul>
);
}
export default Footer;

View File

@@ -0,0 +1,15 @@
import NextLink from 'next/link';
import { Button } from '../ui/button';
type LinkProps = {
path: string;
text: string;
};
export function Link({ path, text }: LinkProps) {
return (
<Button asChild>
<NextLink href={path}>{text}</NextLink>
</Button>
);
}

View File

@@ -1,14 +0,0 @@
import Link from 'next/link';
type CustomLinkProps = {
path: string;
text: string;
};
export function CustomLink({ path, text }: CustomLinkProps) {
return (
<Link href={path} className="overflow-hidden rounded-md">
<h1>{text}</h1>
</Link>
);
}

View File

@@ -1,10 +0,0 @@
import { HomeLink } from './homeLink';
export default function ErrorComponent() {
return (
<div>
<h1>Oops. Something went wrong. Please try later :(</h1>
<HomeLink />
</div>
);
}

View File

@@ -1,5 +0,0 @@
import { CustomLink } from './customLink';
export function HomeLink() {
return <CustomLink path={`/`} text={`Home`} />;
}

View File

@@ -1,11 +0,0 @@
import { HomeLink } from './homeLink';
export function SuccessComponent(message: string) {
return (
<div>
<h1>Success!</h1>
<h3>{message}</h3>
<HomeLink />
</div>
);
}

View File

@@ -2,18 +2,65 @@ import { Container } from '@react-email/container';
import { Html } from '@react-email/html';
import { Section } from '@react-email/section';
import { Text } from '@react-email/text';
import { container, main, paragraph } from './utils/styling';
import { z } from 'zod';
import { NewsSchema } from '../../utils/types';
export default function NewsletterEmail(ids: number[]) {
return (
<Html>
<Section style={main}>
<Container style={container}>
<Text style={paragraph}>
These were the ids retrieved: {ids.join(', ')}
</Text>
</Container>
</Section>
</Html>
);
export default function NewsletterTemplate(
stories: z.infer<typeof NewsSchema>[]
) {
return {
subject: `What's new from Hackernews?`,
template: (
<Html>
<Section style={main}>
<Container style={container}>
<Text style={paragraph}>
{stories.map(story => {
return (
<div
key={story.id}
style={{
padding: '10px',
border: '1px solid #ccc',
boxShadow: '2px 2px 5px rgba(0, 0, 0, 0.1)'
}}
>
<h1>{story.title}</h1>
<p>{story.by}</p>
{story.text && (
<p
dangerouslySetInnerHTML={{
__html:
story.text.length > 500
? story.text.substring(0, 500) + '...'
: story.text
}}
/>
)}
{story.url && <a href={story.url}>Read more</a>}
</div>
);
})}
</Text>
</Container>
</Section>
</Html>
)
};
}
const main = {
backgroundColor: '#ffffff'
};
const container = {
margin: '0 auto',
padding: '20px 0 48px',
width: '580px'
};
const paragraph = {
fontSize: '18px',
lineHeight: '1.4',
color: '#484848'
};

View File

@@ -1,23 +1,22 @@
import { Container } from '@react-email/container';
import { Html } from '@react-email/html';
import { Section } from '@react-email/section';
import { Text } from '@react-email/text';
import { container, main, paragraph } from './utils/styling';
import Email from './template';
export default function SubscribeEmail(code: string) {
return (
<Html>
<Section style={main}>
<Container style={container}>
<Text style={paragraph}>
To confirm the subscription, please click{' '}
export default function ConfirmationEmail(code: string) {
return {
subject: 'Welcome!',
template: (
<Email
title={'Welcome!'}
body={
<>
Thank you for subscribing. Please confirm your email address by
clicking{' '}
<a href={`${process.env.HOME_URL}/confirmation?code=${code}`}>
here
</a>
.
</Text>
</Container>
</Section>
</Html>
);
</>
}
/>
)
};
}

View File

@@ -0,0 +1,43 @@
import { Container } from '@react-email/container';
import { Html } from '@react-email/html';
import { Section } from '@react-email/section';
import { Text } from '@react-email/text';
type EmailProps = {
title: string;
body: JSX.Element;
};
export default function Email({ title, body }: EmailProps) {
return (
<Html>
<Section style={main}>
<Container style={container}>
<Text style={heading}>{title}</Text>
<Text style={paragraph}>{body}</Text>
</Container>
</Section>
</Html>
);
}
const main = {
backgroundColor: '#ffffff',
};
const container = {
margin: '0 auto',
padding: '20px 0 48px',
width: '580px',
};
const heading = {
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '16px',
};
const paragraph = {
fontSize: '16px',
marginBottom: '16px',
};

View File

@@ -1,19 +1,13 @@
import { Container } from '@react-email/container';
import { Html } from '@react-email/html';
import { Section } from '@react-email/section';
import { Text } from '@react-email/text';
import { container, main, paragraph } from './utils/styling';
import Email from './template';
export default function UnsubscribeEmail() {
return (
<Html>
<Section style={main}>
<Container style={container}>
<Text style={paragraph}>
You have unsubscribed from the newsletter.
</Text>
</Container>
</Section>
</Html>
);
export default function UnsubscribeTemplate() {
return {
subject: 'Unsubscribe confirmation',
template: (
<Email
title="We're sad you're leaving :("
body={<>You have unsubscribed from the newsletter.</>}
/>
),
};
}

View File

@@ -1,15 +0,0 @@
export const main = {
backgroundColor: '#ffffff'
};
export const container = {
margin: '0 auto',
padding: '20px 0 48px',
width: '580px'
};
export const paragraph = {
fontSize: '18px',
lineHeight: '1.4',
color: '#484848'
};

View File

@@ -1,58 +0,0 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { ResponseSchema } from '../../utils/types';
import { HomeLink } from '../elements/homeLink';
export const ConfirmationPage = () => {
const router = useRouter();
const searchParams = useSearchParams();
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const code = searchParams.get('code');
useEffect(() => {
if (!code) {
router.push('/');
}
fetch('/api/confirmation', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: code,
}),
})
.then(async (res) => {
if (!res.ok) {
router.push('/');
}
const response: z.infer<typeof ResponseSchema> = await res.json();
return response;
})
.then((response) => {
setMessage(response.message);
setLoading(false);
});
}, [code, router]);
if (!loading) {
return (
<div>
<h1>{message}</h1>
<HomeLink />
</div>
);
}
return (
<div>
<h1>Verifying...</h1>
<HomeLink />
</div>
);
};

View File

@@ -1,87 +0,0 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { z } from 'zod';
import { ResponseSchema } from '../../utils/types';
import { CustomLink } from '../elements/customLink';
import ErrorComponent from '../elements/error';
import { HomeLink } from '../elements/homeLink';
import { SuccessComponent } from '../elements/success';
export const SubscribeForm = () => {
const [completed, setCompleted] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState(false);
const honeypotRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (honeypotRef.current) {
honeypotRef.current.style.display = 'none';
}
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
if (data.get('name')) {
return;
}
try {
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: data.get('email'),
}),
});
if (!response?.ok) {
throw new Error(`Invalid response: ${response.status}`);
}
const formResponse: z.infer<typeof ResponseSchema> =
await response.json();
setMessage(formResponse.message);
setCompleted(true);
} catch (error) {
console.log('Subscribe error', error);
setError(true);
}
}
if (error) {
return ErrorComponent();
}
if (completed) {
return SuccessComponent(message);
}
return (
<div>
<form className="container" onSubmit={handleSubmit}>
<h1>Subscribe to newsletter</h1>
<div className="email block">
<label htmlFor="frm-email">Email</label>
<input
placeholder="example@email.com"
id="email"
type="email"
name="email"
required
/>
</div>
<input type="text" name="name" ref={honeypotRef} />
<div className="button block">
<button type="submit">Subscribe</button>
</div>
</form>
<HomeLink />
<CustomLink path={`/unsubscribe`} text="Unsubscribe" />
</div>
);
};

View File

@@ -1,87 +0,0 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { z } from 'zod';
import { ResponseSchema } from '../../utils/types';
import { CustomLink } from '../elements/customLink';
import ErrorComponent from '../elements/error';
import { HomeLink } from '../elements/homeLink';
import { SuccessComponent } from '../elements/success';
export const UnsubscribeForm = () => {
const [completed, setCompleted] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState(false);
const honeypotRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (honeypotRef.current) {
honeypotRef.current.style.display = 'none';
}
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
if (data.get('name')) {
return;
}
try {
const response = await fetch('/api/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: data.get('email'),
}),
});
if (!response?.ok) {
throw new Error(`Invalid response: ${response.status}`);
}
const formResponse: z.infer<typeof ResponseSchema> =
await response.json();
setMessage(formResponse.message);
setCompleted(true);
} catch (error) {
console.log('Unsubscribe error', error);
setError(true);
}
}
if (error) {
return ErrorComponent();
}
if (completed) {
return SuccessComponent(message);
}
return (
<div>
<form className="container" onSubmit={handleSubmit}>
<h1>Unsubscribe from newsletter</h1>
<div className="email block">
<label htmlFor="frm-email">Email</label>
<input
placeholder="example@email.com"
id="email"
type="email"
name="email"
required
/>
</div>
<input type="text" name="name" ref={honeypotRef} />
<div className="button block">
<button type="submit">Unsubscribe</button>
</div>
</form>
<HomeLink />
<CustomLink path={`/subscribe`} text="Subscribe" />
</div>
);
};

54
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '../../utils/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
export type ButtonProps = {
asChild?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

85
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,85 @@
import * as React from 'react';
import { cn } from '../../utils/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'bg-card text-card-foreground rounded-lg border shadow-sm',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
};

176
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,176 @@
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext
} from 'react-hook-form';
import { Label } from '../../components/ui/label';
import { cn } from '../../utils/utils';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-destructive text-sm font-medium', className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField
};

22
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '../../utils/utils';
const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = 'Input';
export { Input };

25
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,25 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '../../utils/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };