Skip to content

Commit

Permalink
feat(subscriber): Add subscriber fetching functionality and UI compon…
Browse files Browse the repository at this point in the history
…ents
  • Loading branch information
BiswaViraj committed Jan 31, 2025
1 parent 4ce28c2 commit 8c083f7
Show file tree
Hide file tree
Showing 14 changed files with 1,391 additions and 743 deletions.
2 changes: 2 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,11 @@
"react-helmet-async": "^1.3.0",
"react-hook-form": "7.53.2",
"react-icons": "^5.3.0",
"react-phone-number-input": "^3.4.11",
"react-querybuilder": "^8.0.0",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "6.26.2",
"react-timezone-select": "^3.2.8",
"react-use-intercom": "^2.0.0",
"sonner": "^1.7.0",
"tailwind-merge": "^2.4.0",
Expand Down
16 changes: 15 additions & 1 deletion apps/dashboard/src/api/subscribers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DirectionEnum, IEnvironment, IListSubscribersResponseDto } from '@novu/shared';
import type { DirectionEnum, IEnvironment, IGetSubscriberResponseDto, IListSubscribersResponseDto } from '@novu/shared';
import { getV2 } from './api.client';

export const getSubscribers = async ({
Expand Down Expand Up @@ -42,3 +42,17 @@ export const getSubscribers = async ({
});
return data;
};

export const getSubscriber = async ({
environment,
subscriberId,
}: {
environment: IEnvironment;
subscriberId: string;
}) => {
const { data } = await getV2<{ data: IGetSubscriberResponseDto }>(`/subscribers/${subscriberId}`, {
environment,
});

return data;
};
133 changes: 133 additions & 0 deletions apps/dashboard/src/components/primitives/phone-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import * as React from 'react';
import { CheckIcon, ChevronsUpDown } from 'lucide-react';
import * as RPNInput from 'react-phone-number-input';
import flags from 'react-phone-number-input/flags';
import { cn } from '@/utils/ui';
import { InputPure, InputRoot, InputWrapper } from './input';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { Button } from './button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './command';
import { ScrollArea } from './scroll-area';

type PhoneInputProps = Omit<React.ComponentProps<'input'>, 'onChange' | 'value' | 'ref'> &
Omit<RPNInput.Props<typeof RPNInput.default>, 'onChange'> & {
onChange?: (value: RPNInput.Value) => void;
};

const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = React.forwardRef<
React.ElementRef<typeof RPNInput.default>,
PhoneInputProps
>(({ className, onChange, ...props }, ref) => {
return (
<RPNInput.default
ref={ref}
className={cn('flex', className)}
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
smartCaret={false}
/**
* Handles the onChange event.
*
* react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string.
*
* @param {E164Number | undefined} value - The entered value
*/
onChange={(value) => onChange?.(value || ('' as RPNInput.Value))}
{...props}
/>
);
});
PhoneInput.displayName = 'PhoneInput';

type CountryEntry = { label: string; value: RPNInput.Country | undefined };

type CountrySelectProps = {
disabled?: boolean;
value: RPNInput.Country;
options: CountryEntry[];
onChange: (country: RPNInput.Country) => void;
};

const CountrySelect = ({ disabled, value: selectedCountry, options: countryList, onChange }: CountrySelectProps) => {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="secondary"
mode="outline"
className="flex h-9 gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
disabled={disabled}
>
<FlagComponent country={selectedCountry} countryName={selectedCountry} />
<ChevronsUpDown className={cn('-mr-2 size-4 opacity-50', disabled ? 'hidden' : 'opacity-100')} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<CommandEmpty>No country found.</CommandEmpty>
<ScrollArea className="h-72">
<CommandGroup>
{countryList.map(({ value, label }) =>
value ? (
<CountrySelectOption
key={value}
country={value}
countryName={label}
selectedCountry={selectedCountry}
onChange={onChange}
/>
) : null
)}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};

const InputComponent = React.forwardRef<HTMLInputElement, React.ComponentProps<typeof InputPure>>(
({ className, ...props }, ref) => (
<InputRoot size="2xs" className="rounded-s-none">
<InputWrapper className="flex h-full items-center p-2 py-1">
<InputPure className={cn('rounded-e-lg rounded-s-none', className)} ref={ref} {...props} />
</InputWrapper>
</InputRoot>
)
);
InputComponent.displayName = 'InputComponent';

interface CountrySelectOptionProps extends RPNInput.FlagProps {
selectedCountry: RPNInput.Country;
onChange: (country: RPNInput.Country) => void;
}

const CountrySelectOption = ({ country, countryName, selectedCountry, onChange }: CountrySelectOptionProps) => {
return (
<CommandItem className="gap-2" onSelect={() => onChange(country)}>
<FlagComponent country={country} countryName={countryName} />
<span className="flex-1 text-sm">{countryName}</span>
<span className="text-foreground/50 text-sm">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
<CheckIcon className={`ml-auto size-4 ${country === selectedCountry ? 'opacity-100' : 'opacity-0'}`} />
</CommandItem>
);
};

const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
const Flag = flags[country];

return (
<span className="bg-foreground/20 flex h-4 w-6 overflow-hidden rounded-sm [&_svg]:size-full" key={country}>
{Flag ? <Flag title={countryName} /> : <span className="size-full rounded-sm bg-neutral-100" />}
</span>
);
};

export { PhoneInput };
2 changes: 1 addition & 1 deletion apps/dashboard/src/components/primitives/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ type TabsTriggerProps = React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trig

const TabsTrigger = React.forwardRef<React.ElementRef<typeof TabsPrimitive.Trigger>, TabsTriggerProps>(
({ className, variant, ...props }, ref) => (
<TabsPrimitive.Trigger ref={ref} className={tabsTriggerVariants({ variant, className })} {...props} />
<TabsPrimitive.Trigger ref={ref} className={cn(tabsTriggerVariants({ variant, className }))} {...props} />
)
);
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
Expand Down
28 changes: 28 additions & 0 deletions apps/dashboard/src/components/subscribers/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { z } from 'zod';
import { isValidPhoneNumber } from 'react-phone-number-input';

export const SubscriberFormSchema = z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().email().optional(),
phone: z
.string()
.refine(isValidPhoneNumber, { message: 'Invalid phone number' })
.optional()
.or(z.literal(''))
.optional(),
avatar: z.string().optional(),
locale: z.string().optional(),
timezone: z.string().optional(),
data: z
.string()
.transform((str, ctx) => {
try {
return JSON.parse(str);
} catch (e) {
ctx.addIssue({ code: 'custom', message: 'Custom data must be valid JSON' });
return z.NEVER;
}
})
.optional(),
});
61 changes: 61 additions & 0 deletions apps/dashboard/src/components/subscribers/subscriber-drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { motion } from 'motion/react';
import { Sheet, SheetContentBase, SheetDescription, SheetPortal, SheetTitle } from '../primitives/sheet';
import { VisuallyHidden } from '../primitives/visually-hidden';
import { useNavigate } from 'react-router-dom';
import { PropsWithChildren } from 'react';

const transitionSetting = { ease: [0.29, 0.83, 0.57, 0.99], duration: 0.4 };

type SubscriberDrawerProps = PropsWithChildren<{
open: boolean;
}>;

export function SubscriberDrawer({ children, open }: SubscriberDrawerProps) {
const navigate = useNavigate();

const handleCloseSheet = () => {
navigate(-1);
};
return (
<Sheet modal={false} open={open}>
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
className="fixed inset-0 z-50 h-screen w-screen bg-black/20"
transition={transitionSetting}
/>
<SheetPortal>
<SheetContentBase asChild onInteractOutside={handleCloseSheet} onEscapeKeyDown={handleCloseSheet}>
<motion.div
initial={{
x: '100%',
}}
animate={{
x: 0,
}}
exit={{
x: '100%',
}}
transition={transitionSetting}
className={
'bg-background fixed inset-y-0 right-0 z-50 flex h-full w-3/4 flex-col border-l shadow-lg outline-none sm:max-w-[600px]'
}
>
<VisuallyHidden>
<SheetTitle />
<SheetDescription />
</VisuallyHidden>
{children}
</motion.div>
</SheetContentBase>
</SheetPortal>
</Sheet>
);
}
Loading

0 comments on commit 8c083f7

Please sign in to comment.