Merge pull request #4 from Car-Part-Planet/CPP-153

Add Customer Authentication and Order Details
This commit is contained in:
Teodor Raykov
2024-06-21 19:10:44 +03:00
committed by GitHub
52 changed files with 5844 additions and 2384 deletions

View File

@@ -0,0 +1,41 @@
'use client';
type OrderCardsProps = {
orders: any;
};
export function AccountOrdersHistory({ orders }: { orders: any }) {
return (
<div className="mt-6">
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
<h2 className="text-lead font-bold">Order History</h2>
{orders?.length ? <Orders orders={orders} /> : <EmptyOrders />}
</div>
</div>
);
}
function EmptyOrders() {
return (
<div>
<div className="mb-1">You haven&apos;t placed any orders yet.</div>
<div className="w-48">
<button
className="mt-2 w-full text-sm"
//variant="secondary"
>
Start Shopping
</button>
</div>
</div>
);
}
function Orders({ orders }: OrderCardsProps) {
return (
<ul className="false grid grid-flow-row grid-cols-1 gap-2 gap-y-6 sm:grid-cols-3 md:gap-4 lg:gap-6">
{orders.map((order: any) => (
<li key={order.node.id}>{order.node.number}</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import clsx from 'clsx';
import { ArrowRightIcon as LogOutIcon } from '@heroicons/react/24/outline';
import { doLogout } from './actions';
import LoadingDots from 'components/loading-dots';
import { useFormState, useFormStatus } from 'react-dom';
function SubmitButton(props: any) {
const { pending } = useFormStatus();
const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
return (
<>
<button
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Log Out"
aria-disabled={pending}
className={clsx(buttonClasses, {
'hover:opacity-90': true,
'cursor-not-allowed opacity-60 hover:opacity-60': pending
})}
>
<div className="absolute left-0 ml-4">
{pending ? <LoadingDots className="mb-3 bg-white" /> : <LogOutIcon className="h-5" />}
</div>
{pending ? 'Logging out...' : 'Log Out'}
</button>
{props?.message && <div className="my-5">{props?.message}</div>}
</>
);
}
export function AccountProfile() {
const [message, formAction] = useFormState(doLogout, null);
return (
<form action={formAction}>
<SubmitButton message={message} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

View File

@@ -0,0 +1,34 @@
'use server';
import { CUSTOMER_API_URL, ORIGIN_URL, removeAllCookiesServerAction } from 'lib/shopify/auth';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
export async function doLogout() {
const origin = ORIGIN_URL;
const customerAccountApiUrl = CUSTOMER_API_URL;
let logoutUrl;
try {
const idToken = cookies().get('shop_id_token');
const idTokenValue = idToken?.value;
if (!idTokenValue) {
//you can also throw an error here with page and middleware
//throw new Error ("Error No Id Token")
//if there is no idToken, then sending to logout url will redirect shopify, so just
//redirect to login here and delete cookies (presumably they don't even exist)
logoutUrl = new URL(`${origin}/login`);
} else {
logoutUrl = new URL(
`${customerAccountApiUrl}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}`
);
}
await removeAllCookiesServerAction();
} catch (e) {
console.log('Error', e);
//you can throw error here or return - return goes back to form b/c of state, throw will throw the error boundary
//throw new Error ("Error")
return 'Error logging out. Please try again';
}
redirect(`${logoutUrl}`); // Navigate to the new post page
}

View File

@@ -0,0 +1,46 @@
'use client';
import { ShoppingCartIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
import Text from 'components/ui/text';
import { Disclosure, DisclosureButton, DisclosurePanel, Transition } from '@headlessui/react';
import Divider from 'components/divider';
import Price from 'components/price';
import { Order } from 'lib/shopify/types';
import OrderSummary from './order-summary';
export default function OrderSummaryMobile({ order }: { order: Order }) {
return (
<div className="block lg:hidden">
<Disclosure>
{({ open }) => (
<>
<DisclosureButton className="flex w-full justify-between p-6">
<div className="flex items-center gap-2 text-primary">
<ShoppingCartIcon className="w-6" />
<Text>{open ? 'Hide order summary' : 'Show order summary'}</Text>
{open ? <ChevronUpIcon className="w-4" /> : <ChevronDownIcon className="w-4" />}
</div>
<Price
amount={order.totalPrice!.amount}
currencyCode={order.totalPrice!.currencyCode}
/>
</DisclosureButton>
<Transition
enter="duration-200 ease-out"
enterFrom="opacity-0 -translate-y-6"
enterTo="opacity-100 translate-y-0"
leave="duration-300 ease-out"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-6"
>
<DisclosurePanel className="origin-top p-6 text-gray-500 transition">
<OrderSummary order={order} />
</DisclosurePanel>
</Transition>
</>
)}
</Disclosure>
<Divider hasSpacing={false} />
</div>
);
}

View File

@@ -0,0 +1,73 @@
import Image from 'next/image';
import Price from 'components/price';
import Badge from 'components/ui/badge';
import Heading from 'components/ui/heading';
import Label from 'components/ui/label';
import Text from 'components/ui/text';
import { Order } from 'lib/shopify/types';
export default function OrderSummary({ order }: { order: Order }) {
return (
<div className="flex flex-col gap-6">
<Heading size="sm">Order Summary</Heading>
<div className="flex flex-col gap-6">
{order.lineItems.map((lineItem, index) => (
<div key={index} className="flex items-center gap-4">
<Badge content={lineItem.quantity!}>
<Image
src={lineItem.image.url}
alt={lineItem.image.altText}
width={lineItem.image.width}
height={lineItem.image.height}
className="rounded border"
/>
</Badge>
<div className="flex flex-col gap-2">
<Text>{lineItem.title}</Text>
<Label>{lineItem.sku}</Label>
</div>
<Price
className="text-sm"
amount={lineItem.price!.amount}
currencyCode={lineItem.price!.currencyCode}
/>
</div>
))}
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col">
<div className="flex items-center justify-between">
<Text>Subtotal</Text>
<Price
className="text-sm font-semibold"
amount={order.totalPrice!.amount}
currencyCode={order.totalPrice!.currencyCode}
/>
</div>
<div className="flex items-center justify-between">
<Text>Shipping</Text>
{order.shippingMethod?.price.amount !== '0.0' ? (
<Price
className="text-sm font-semibold"
amount={order.shippingMethod!.price.amount}
currencyCode={order.shippingMethod!.price.currencyCode}
/>
) : (
<Text className="font-semibold">Free</Text>
)}
</div>
</div>
<div className="flex items-center justify-between">
<Heading as="span" size="sm">
Total
</Heading>
<Price
className="font-semibold"
amount={order.totalPrice!.amount}
currencyCode={order.totalPrice!.currencyCode}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
'use server';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import {
generateCodeVerifier,
generateCodeChallenge,
generateRandomString,
CUSTOMER_API_CLIENT_ID,
ORIGIN_URL,
CUSTOMER_API_URL
} from 'lib/shopify/auth';
export async function doLogin(_: any) {
const customerAccountApiUrl = CUSTOMER_API_URL;
const clientId = CUSTOMER_API_CLIENT_ID;
const origin = ORIGIN_URL;
const loginUrl = new URL(`${customerAccountApiUrl}/auth/oauth/authorize`);
try {
loginUrl.searchParams.set('client_id', clientId);
loginUrl.searchParams.append('response_type', 'code');
loginUrl.searchParams.append('redirect_uri', `${origin}/authorize`);
loginUrl.searchParams.set(
'scope',
'openid email https://api.customers.com/auth/customer.graphql'
);
const verifier = await generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
cookies().set('shop_verifier', verifier as string, {});
const state = await generateRandomString();
const nonce = await generateRandomString();
cookies().set('shop_state', state as string, {});
cookies().set('shop_nonce', nonce as string, {});
loginUrl.searchParams.append('state', state);
loginUrl.searchParams.append('nonce', nonce);
loginUrl.searchParams.append('code_challenge', challenge);
loginUrl.searchParams.append('code_challenge_method', 'S256');
} catch (e) {
console.log('Error', e);
return 'Error logging in. Please try again';
}
redirect(`${loginUrl}`); // Navigate to the new post page
}
export async function isLoggedIn() {
const customerToken = cookies().get('shop_customer_token')?.value;
const refreshToken = cookies().get('shop_refresh_token')?.value;
if (!customerToken && !refreshToken) {
return false;
} else {
return true;
}
}

View File

@@ -0,0 +1,51 @@
'use client';
import clsx from 'clsx';
import { doLogin } from './actions';
import { useFormState, useFormStatus } from 'react-dom';
function SubmitButton(props: any) {
const { pending } = useFormStatus();
const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
//const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
return (
<>
{props?.message && <div className="my-5">{props?.message}</div>}
<button
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
if (pending) e.preventDefault();
}}
aria-label="Log in"
aria-disabled={pending}
className={clsx(buttonClasses, {
'hover:opacity-90': true,
'cursor-not-allowed opacity-60 hover:opacity-60': pending
})}
>
{pending ? (
<>
<span>Logging In...</span>
</>
) : (
<>
<span>Log-In</span>
</>
)}
</button>
</>
);
}
export function LoginShopify() {
const [message, formAction] = useFormState(doLogin, null);
return (
<form action={formAction}>
<SubmitButton message={message} />
<p aria-live="polite" className="sr-only" role="status">
{message}
</p>
</form>
);
}

View File

@@ -0,0 +1,8 @@
export function LoginMessage() {
return (
<div>
<h2>Error</h2>
<span>Your session has expired. Please log in again.</span>
</div>
);
}

16
components/auth/login.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { cookies } from 'next/headers';
import { LoginShopify } from 'components/auth/login-form';
import { UserIcon } from 'components/auth/user-icon';
export default async function Login() {
const customerToken = cookies().get('shop_customer_token')?.value;
const refreshToken = cookies().get('shop_refresh_token')?.value;
let isLoggedIn;
if (!customerToken && !refreshToken) {
isLoggedIn = false;
} else {
isLoggedIn = true;
}
console.log('LoggedIn', isLoggedIn);
return isLoggedIn ? <UserIcon /> : <LoginShopify />;
}

View File

@@ -0,0 +1,30 @@
'use client';
import { UserIcon as User2Icon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
function UserButton(props: any) {
const buttonClasses =
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white';
//const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
return (
<>
<button
aria-label="My Profile"
className={clsx(buttonClasses, {
'hover:opacity-90': true
})}
>
{/*Purposesly a href here and NOT Link component b/c of router caching*/}
<a href="/account">
<User2Icon className="mr-2 h-4 w-4" />
<span>Profile</span>
</a>
</button>
</>
);
}
export function UserIcon() {
return <UserButton />;
}

View File

@@ -1,5 +1,4 @@
import { getCollection, getMenu, getProduct } from 'lib/shopify';
import { findParentCollection } from 'lib/utils';
import { Fragment } from 'react';
import {
Breadcrumb,
@@ -9,6 +8,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator
} from './breadcrumb-list';
import { findParentCollection } from 'lib/utils';
type BreadcrumbProps = {
type: 'product' | 'collection';

137
components/button.tsx Normal file
View File

@@ -0,0 +1,137 @@
'use client';
import React from 'react';
import { Button as ButtonBase, ButtonProps as ButtonBaseProps } from '@headlessui/react';
import { tv, type VariantProps } from 'tailwind-variants';
import clsx from 'clsx';
import Spinner from './spinner';
const buttonVariants = tv({
slots: {
root: [
// base
'relative inline-flex items-center justify-center rounded-md',
// text
'text-center font-medium',
// transition
'transition-all duration-100 ease-in-out',
// disabled
'disabled:pointer-events-none disabled:shadow-none'
],
loading: 'pointer-events-none flex shrink-0 items-center justify-center gap-1.5'
},
variants: {
size: {
sm: {
root: 'text-xs px-2.5 py-1.5'
},
md: {
root: 'text-sm px-3 py-2'
},
lg: {
root: 'text-base px-4 py-2.5'
}
},
color: {
primary: {},
content: {}
},
variant: {
solid: {},
outlined: {
root: 'border bg-white'
},
text: {}
}
},
compoundVariants: [
{
color: 'primary',
variant: 'solid',
class: {
root: [
// border
'border-transparent',
// text color
'text-white',
// background color
'bg-primary',
// hover color
'hover:bg-primary-empahsis',
// disabled
'disabled:bg-primary-muted',
'pressed:bg-primary-emphasis/80'
]
}
},
{
color: 'primary',
variant: 'outlined',
class: {
root: [
// border
'border-primary',
// text color
'text-primary',
// background color
'bg-white',
// hover color
'hover:bg-primary/10',
// disabled
'disabled:border-primary-muted disabled:text-primary-muted'
]
}
}
],
defaultVariants: {
variant: 'solid',
color: 'primary',
size: 'md'
}
});
interface ButtonProps extends Omit<ButtonBaseProps, 'color'>, VariantProps<typeof buttonVariants> {
isLoading?: boolean;
loadingText?: string;
className?: string;
disabled?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
className,
disabled,
isLoading,
loadingText = 'Loading',
size,
variant,
...props
}: ButtonProps,
forwardedRef
) => {
const { loading, root } = buttonVariants({ variant, size });
return (
<ButtonBase
ref={forwardedRef}
className={clsx(root(), className)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<span className={loading()}>
<Spinner />
<span className="sr-only">{loadingText}</span>
<span>{loadingText}</span>
</span>
) : (
children
)}
</ButtonBase>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants, type ButtonProps };

View File

@@ -65,8 +65,10 @@ const LineItem = ({ item, closeCart }: LineItemProps) => {
className="h-full w-full object-cover"
width={64}
height={64}
alt={item.merchandise.product.featuredImage.altText || item.merchandise.product.title}
src={item.merchandise.product.featuredImage.url}
alt={
item.merchandise.product?.featuredImage?.altText || item.merchandise.product.title
}
src={item.merchandise.product?.featuredImage?.url}
/>
</div>

View File

@@ -14,12 +14,15 @@ import CloseCart from './close-cart';
import LineItem from './line-item';
import OpenCart from './open-cart';
import VehicleDetails, { VehicleFormSchema, vehicleFormSchema } from './vehicle-details';
import useAuth from 'hooks/use-auth';
export default function CartModal({ cart }: { cart: Cart | undefined }) {
const { isAuthenticated } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const quantityRef = useRef(cart?.totalQuantity);
const openCart = () => setIsOpen(true);
const closeCart = () => setIsOpen(false);
const [checkoutUrl, setCheckoutUrl] = useState<string | undefined>(cart?.checkoutUrl);
const { control, handleSubmit } = useForm<VehicleFormSchema>({
resolver: zodResolver(vehicleFormSchema),
defaultValues: {
@@ -45,6 +48,20 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
}
}, [isOpen, cart?.totalQuantity, quantityRef]);
useEffect(() => {
if (!cart) return;
if (isAuthenticated) {
const newCheckoutUrl = new URL(cart.checkoutUrl);
newCheckoutUrl.searchParams.append('logged_in', 'true');
return setCheckoutUrl(newCheckoutUrl.toString());
}
if (checkoutUrl !== cart.checkoutUrl) {
setCheckoutUrl(cart.checkoutUrl);
}
}, [cart, isAuthenticated, checkoutUrl]);
const onSubmit = async (data: VehicleFormSchema) => {
if (!cart) return;
@@ -136,7 +153,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
/>
</div>
</div>
<a href={cart.checkoutUrl} ref={linkRef} className="hidden">
<a href={checkoutUrl} ref={linkRef} className="hidden">
Proceed to Checkout
</a>
<button

54
components/divider.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { tv } from 'tailwind-variants';
const divider = tv({
slots: {
root: '',
element: 'bg-gray-200'
},
variants: {
orientation: {
horizontal: {
root: 'w-full mx-auto flex justify-between items-center text-tremor-default text-tremor-content',
element: 'w-full h-[1px] '
},
vertical: {
root: 'flex justify-between items-stretch text-tremor-default text-tremor-content',
element: 'h-full w-[1px]'
}
},
hasSpacing: {
true: {},
false: {}
}
},
compoundVariants: [
{
orientation: 'horizontal',
hasSpacing: true,
class: {
root: 'my-6'
}
},
{
orientation: 'vertical',
hasSpacing: true,
class: {
root: 'mx-6'
}
}
]
});
type DividerProps = {
orientation?: 'horizontal' | 'vertical';
hasSpacing?: boolean;
};
export default function Divider({ orientation = 'horizontal', hasSpacing = true }: DividerProps) {
const { root, element } = divider({ orientation, hasSpacing });
return (
<div className={root()}>
<span className={element()} />
</div>
);
}

View File

@@ -3,12 +3,14 @@ import clsx from 'clsx';
const Price = ({
amount,
className,
as,
currencyCode = 'USD',
currencyCodeClassName,
showCurrency = false,
prefix
}: {
amount: string;
as?: 'p' | 'span';
className?: string;
currencyCode: string;
currencyCodeClassName?: string;
@@ -23,9 +25,10 @@ const Price = ({
return <p className={className}>Included</p>;
}
const Component = as || 'p';
// Otherwise, format and display the price
return (
<p suppressHydrationWarning={true} className={className}>
<Component suppressHydrationWarning={true} className={className}>
{prefix}
{new Intl.NumberFormat(undefined, {
style: 'currency',
@@ -35,7 +38,7 @@ const Price = ({
{showCurrency && (
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{currencyCode}</span>
)}
</p>
</Component>
);
};

View File

@@ -1,16 +1,46 @@
'use client';
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
import { CloseButton, Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
import { ArrowRightIcon } from '@heroicons/react/16/solid';
import { Menu } from 'lib/shopify/types';
import { Fragment } from 'react';
import { Fragment, useState } from 'react';
import OpenProfile from './open-profile';
import { useFormState, useFormStatus } from 'react-dom';
import { doLogin } from 'components/auth/actions';
import { Button } from 'components/button';
import useAuth from 'hooks/use-auth';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
type ProfilePopoverProps = {
menu: Menu[];
};
function SubmitButton(props: any) {
const { pending } = useFormStatus();
return (
<>
{props?.message && <div className="my-5">{props?.message}</div>}
<Button
type="submit"
aria-label="Log in"
aria-disabled={pending}
disabled={pending}
isLoading={pending}
loadingText="Signing In..."
className="w-full"
>
Sign In
</Button>
</>
);
}
const ProfilePopover = ({ menu }: ProfilePopoverProps) => {
const [message, action] = useFormState(doLogin, null);
const { isAuthenticated, loading } = useAuth();
const [loggingOut, setLoggingOut] = useState(false);
const router = useRouter();
return (
<Popover className="relative">
<PopoverButton aria-label="Open Profile Menu" className="flex">
@@ -25,29 +55,52 @@ const ProfilePopover = ({ menu }: ProfilePopoverProps) => {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute -right-10 z-10 mt-2 w-72 max-w-lg px-4 sm:px-0 lg:right-0">
<PopoverPanel className="absolute -right-10 z-50 mt-2 w-72 max-w-lg px-4 sm:px-0 lg:right-0">
<div className="flex flex-col gap-2 overflow-hidden rounded-md bg-white px-4 py-3 text-black shadow-xl ring-1 ring-black/5">
<span className="text-sm font-medium">My Account</span>
<a
href="#"
className="mt-1 rounded-sm bg-primary p-2 text-center text-xs font-medium uppercase text-white hover:bg-secondary "
>
Sign in
</a>
{!isAuthenticated && !loading && (
<form action={action}>
<SubmitButton message={message} />
</form>
)}
{menu.length ? (
<ul className="mt-2 flex w-full flex-col divide-y text-sm">
<ul className="flex w-full flex-col divide-y text-sm">
{isAuthenticated && (
<li className="cursor-pointer py-2 hover:underline">
<CloseButton
as={Link}
className="flex w-full flex-row items-center justify-between"
href="/account"
>
My Orders <ArrowRightIcon className="h-3" />
</CloseButton>
</li>
)}
{menu.map((menuItem) => (
<li className="cursor-pointer py-2 hover:underline" key={menuItem.title}>
<a
<CloseButton
as={Link}
className="flex w-full flex-row items-center justify-between"
href={menuItem.path}
>
{menuItem.title} <ArrowRightIcon className="h-3" />
</a>
</CloseButton>
</li>
))}
</ul>
) : null}
{isAuthenticated && !loading && (
<Button
disabled={loggingOut}
onClick={() => {
setLoggingOut(true);
router.push('/logout');
}}
variant="outlined"
>
{loggingOut ? 'Logging Out...' : 'Log Out'}
</Button>
)}
</div>
</PopoverPanel>
</Transition>

19
components/spinner.tsx Normal file
View File

@@ -0,0 +1,19 @@
import clsx from 'clsx';
import React from 'react';
export default function Spinner({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={clsx('flex-1 animate-spin stroke-current stroke-[3]', className)}
fill="none"
viewBox="0 0 24 24"
>
<path
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
className="stroke-current opacity-25"
/>
<path d="M12 2C6.47715 2 2 6.47715 2 12C2 14.7255 3.09032 17.1962 4.85857 19" />
</svg>
);
}

35
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,35 @@
import React from 'react';
import { VariantProps, tv } from 'tailwind-variants';
const badgeStyles = tv({
base: [
'absolute -right-2 -top-2 h-5 w-5',
'flex items-center justify-center rounded-full text-xs font-semibold'
],
variants: {
color: {
primary: 'bg-primary text-white',
secondary: 'bg-secondary text-white',
content: 'bg-content text-white'
}
},
defaultVariants: {
color: 'content'
}
});
interface BadgeProps extends VariantProps<typeof badgeStyles> {
content: string | number;
className?: string;
children: React.ReactNode;
}
export default function Badge({ className, color, children, content }: BadgeProps) {
return (
<span className="relative flex-none">
{children}
<span className={badgeStyles({ color, className })}>{content}</span>
</span>
);
}

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

@@ -0,0 +1,44 @@
import React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { VariantProps, tv } from 'tailwind-variants';
const cardStyles = tv({
base: 'rounded p-6 text-left w-full',
variants: {
outlined: {
true: 'border bg-white',
false: ''
},
elevated: {
true: 'shadow-lg bg-white',
false: ''
}
},
defaultVariants: {
outlined: true,
elevated: false
}
});
interface CardProps extends React.ComponentPropsWithoutRef<'div'>, VariantProps<typeof cardStyles> {
asChild?: boolean;
}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, asChild, outlined, elevated, ...props }, forwardedRef) => {
const Component = asChild ? Slot : 'div';
return (
<Component
ref={forwardedRef}
className={cardStyles({ outlined, elevated, className })}
{...props}
/>
);
}
);
Card.displayName = 'Card';
export { Card, type CardProps };

26
components/ui/heading.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { VariantProps, tv } from 'tailwind-variants';
const heading = tv({
base: [''],
variants: {
size: {
sm: 'text-heading-sm',
md: 'text-heading-md',
lg: 'text-heading-lg'
}
},
defaultVariants: {
size: 'md'
}
});
interface HeadingProps extends VariantProps<typeof heading> {
className?: string;
children: React.ReactNode;
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p';
}
export default function Heading({ children, className, size, as }: HeadingProps) {
const Component = as || 'h2';
return <Component className={heading({ size, className })}>{children}</Component>;
}

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

@@ -0,0 +1,32 @@
import { VariantProps, tv } from 'tailwind-variants';
const label = tv(
{
base: 'text-content',
variants: {
size: {
sm: 'text-label-sm',
md: 'text-label-md',
lg: 'text-label-lg'
}
},
defaultVariants: {
size: 'md'
}
},
{
twMerge: false
}
);
interface LabelProps extends VariantProps<typeof label> {
className?: string;
children: React.ReactNode;
as?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p';
}
export default function Label({ children, className, size, as }: LabelProps) {
const Component = as || 'span';
return <Component className={label({ size, className })}>{children}</Component>;
}

View File

@@ -0,0 +1,13 @@
import { VariantProps, tv } from 'tailwind-variants';
const skeleton = tv({
base: 'animate-pulse rounded bg-gray-100 w-full h-6'
});
interface SkeletonProps extends VariantProps<typeof skeleton> {
className?: string;
}
export default function Skeleton({ className }: SkeletonProps) {
return <div className={skeleton({ className })} />;
}

32
components/ui/text.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { VariantProps, tv } from 'tailwind-variants';
const text = tv(
{
base: '',
variants: {
size: {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-md'
}
},
defaultVariants: {
size: 'md'
}
},
{
twMerge: false
}
);
interface TextProps extends VariantProps<typeof text> {
className?: string;
children: React.ReactNode;
as?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p';
}
export default function Text({ children, className, size, as }: TextProps) {
const Component = as || 'p';
return <Component className={text({ size, className })}>{children}</Component>;
}