build ok,
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import * as React from 'react';
|
||||
import TimelineConnector from '@mui/lab/TimelineConnector';
|
||||
import TimelineContent from '@mui/lab/TimelineContent';
|
||||
import TimelineDot from '@mui/lab/TimelineDot';
|
||||
import TimelineItem from '@mui/lab/TimelineItem';
|
||||
import TimelineSeparator from '@mui/lab/TimelineSeparator';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Link from '@mui/material/Link';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
export type Event = { id: string; createdAt: Date } & (
|
||||
| { type: 'new_company'; author: { name: string; avatar?: string }; company: { name: string } }
|
||||
| { type: 'new_member'; author: { name: string; avatar?: string }; member: { name: string } }
|
||||
| { type: 'new_job'; author: { name: string; avatar?: string }; job: { title: string } }
|
||||
);
|
||||
|
||||
interface ActivityItemProps {
|
||||
connector: boolean;
|
||||
event: Event;
|
||||
}
|
||||
|
||||
export function ActivityItem({ event, connector }: ActivityItemProps): React.JSX.Element {
|
||||
return (
|
||||
<TimelineItem>
|
||||
<TimelineSeparator>
|
||||
<TimelineDot>{event.author ? <Avatar src={event.author.avatar} /> : null}</TimelineDot>
|
||||
{connector ? <TimelineConnector /> : null}
|
||||
</TimelineSeparator>
|
||||
<TimelineContent>
|
||||
<ActivityContent event={event} />
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
{dayjs(event.createdAt).format('MMM D, hh:mm A')}
|
||||
</Typography>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActivityContentProps {
|
||||
event: Event;
|
||||
}
|
||||
|
||||
function ActivityContent({ event }: ActivityContentProps): React.JSX.Element {
|
||||
if (event.type === 'new_company') {
|
||||
return (
|
||||
<Typography variant="body2">
|
||||
<Typography component="span" variant="subtitle2">
|
||||
{event.author.name}
|
||||
</Typography>{' '}
|
||||
<Typography component="span" variant="inherit">
|
||||
created
|
||||
</Typography>{' '}
|
||||
<Typography component="span" variant="subtitle2">
|
||||
{event.company.name}
|
||||
</Typography>{' '}
|
||||
company
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === 'new_member') {
|
||||
return (
|
||||
<Typography variant="body2">
|
||||
<Typography component="span" variant="subtitle2">
|
||||
{event.author.name}
|
||||
</Typography>{' '}
|
||||
<Typography component="span" variant="inherit">
|
||||
added
|
||||
</Typography>{' '}
|
||||
<Typography component="span" variant="subtitle2">
|
||||
{event.member.name}
|
||||
</Typography>{' '}
|
||||
<Typography component="span" variant="inherit">
|
||||
as a team member
|
||||
</Typography>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === 'new_job') {
|
||||
return (
|
||||
<Typography variant="body2">
|
||||
<Typography component="span" variant="subtitle2">
|
||||
{event.author.name}
|
||||
</Typography>{' '}
|
||||
<Typography component="span" variant="inherit">
|
||||
added a new job
|
||||
</Typography>{' '}
|
||||
<Link variant="subtitle2">{event.job.title}</Link>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return <div />;
|
||||
}
|
66
002_source/cms/src/components/dashboard/jobs/asset-card.tsx
Normal file
66
002_source/cms/src/components/dashboard/jobs/asset-card.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardActions from '@mui/material/CardActions';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
|
||||
import { File as FileIcon } from '@phosphor-icons/react/dist/ssr/File';
|
||||
|
||||
import type { Asset } from './types';
|
||||
|
||||
export interface AssetCardProps {
|
||||
asset: Asset;
|
||||
}
|
||||
|
||||
export function AssetCard({ asset }: AssetCardProps): React.JSX.Element {
|
||||
const isImage = asset.mimeType.includes('image/');
|
||||
|
||||
return (
|
||||
<Card sx={{ borderRadius: 1 }} variant="outlined">
|
||||
{isImage ? (
|
||||
<CardMedia image={asset.url} sx={{ height: '140px' }} />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
bgcolor: 'var(--mui-palette-background-level2)',
|
||||
display: 'flex',
|
||||
height: '140px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<FileIcon fontSize="var(--icon-fontSize-lg)" />
|
||||
</Box>
|
||||
)}
|
||||
<CardContent sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{asset.name}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" sx={{ whiteSpace: 'nowrap' }} variant="caption">
|
||||
{asset.size}
|
||||
</Typography>
|
||||
</Box>
|
||||
<div>
|
||||
<Tooltip title="More options">
|
||||
<IconButton edge="end">
|
||||
<DotsThreeIcon weight="bold" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardContent>
|
||||
<Divider />
|
||||
<CardActions sx={{ justifyContent: 'center' }}>
|
||||
<Button color="secondary" size="small">
|
||||
Download
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
}
|
@@ -0,0 +1,91 @@
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Link from '@mui/material/Link';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { SealCheck as SealCheckIcon } from '@phosphor-icons/react/dist/ssr/SealCheck';
|
||||
import { Star as StarIcon } from '@phosphor-icons/react/dist/ssr/Star';
|
||||
import { Users as UsersIcon } from '@phosphor-icons/react/dist/ssr/Users';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
|
||||
import { JobsCard } from './jobs-card';
|
||||
import type { Job } from './types';
|
||||
|
||||
export interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
description?: string;
|
||||
employees: string;
|
||||
rating: number;
|
||||
isVerified: boolean;
|
||||
jobs: Job[];
|
||||
}
|
||||
|
||||
export interface CompanyCardProps {
|
||||
company: Company;
|
||||
}
|
||||
|
||||
export function CompanyCard({ company }: CompanyCardProps): React.JSX.Element {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: 'flex-start' }}>
|
||||
<Avatar
|
||||
component={RouterLink}
|
||||
href={paths.dashboard.jobs.companies.overview('1')}
|
||||
src={company.logo}
|
||||
variant="rounded"
|
||||
/>
|
||||
<Stack spacing={1}>
|
||||
<div>
|
||||
<Link
|
||||
color="text.primary"
|
||||
component={RouterLink}
|
||||
href={paths.dashboard.jobs.companies.overview('1')}
|
||||
variant="h6"
|
||||
>
|
||||
{company.name}
|
||||
</Link>
|
||||
<Typography variant="body2">{company.description}</Typography>
|
||||
</div>
|
||||
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<UsersIcon fontSize="var(--icon-fontSize-md)" />
|
||||
<Typography color="text.secondary" noWrap variant="overline">
|
||||
{company.employees}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<StarIcon color="var(--mui-palette-warning-main)" fontSize="var(--icon-fontSize-md)" weight="fill" />
|
||||
<Typography color="text.secondary" noWrap variant="overline">
|
||||
{company.rating}
|
||||
/5
|
||||
</Typography>
|
||||
</Stack>
|
||||
{company.isVerified ? (
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
|
||||
<SealCheckIcon
|
||||
color="var(--mui-palette-success-main)"
|
||||
fontSize="var(--icon-fontSize-md)"
|
||||
weight="fill"
|
||||
/>
|
||||
<Typography color="success" noWrap variant="overline">
|
||||
Verified
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{company.jobs ? <JobsCard jobs={company.jobs} /> : null}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import RouterLink from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
|
||||
import { paths } from '@/paths';
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Overview', value: 'overview', href: paths.dashboard.jobs.companies.overview('1') },
|
||||
{ label: 'Reviews', value: 'reviews', href: paths.dashboard.jobs.companies.reviews('1') },
|
||||
{ label: 'Activity', value: 'activity', href: paths.dashboard.jobs.companies.activity('1') },
|
||||
{ label: 'Team', value: 'team', href: paths.dashboard.jobs.companies.team('1') },
|
||||
{ label: 'Assets', value: 'assets', href: paths.dashboard.jobs.companies.assets('1') },
|
||||
] as const;
|
||||
|
||||
function useSegment(): string {
|
||||
const pathname = usePathname();
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
|
||||
return segments[4] ?? 'overview';
|
||||
}
|
||||
|
||||
export function CompanyTabs(): React.JSX.Element {
|
||||
const segment = useSegment();
|
||||
|
||||
return (
|
||||
<Tabs sx={{ px: 3 }} value={segment} variant="scrollable">
|
||||
{tabs.map((tab) => (
|
||||
<Tab {...tab} component={RouterLink} key={tab.value} tabIndex={0} />
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
21
002_source/cms/src/components/dashboard/jobs/description.tsx
Normal file
21
002_source/cms/src/components/dashboard/jobs/description.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Markdown from 'react-markdown';
|
||||
|
||||
export interface DescriptionProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function Description({ content }: DescriptionProps): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
'& h2': { fontWeight: 500, fontSize: '1.5rem', lineHeight: 1.2, mb: 3 },
|
||||
'& h3': { fontWeight: 500, fontSize: '1.25rem', lineHeight: 1.2, mb: 3 },
|
||||
'& p': { fontWeight: 400, fontSize: '1rem', lineHeight: 1.5, mb: 2, mt: 0 },
|
||||
}}
|
||||
>
|
||||
<Markdown>{content}</Markdown>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import RadioGroup from '@mui/material/RadioGroup';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ArrowRight as ArrowRightIcon } from '@phosphor-icons/react/dist/ssr/ArrowRight';
|
||||
|
||||
const categoryOptions = [
|
||||
{ title: 'Freelancers', description: 'Best for small, friendly-pocket projects', value: 'freelancers' },
|
||||
{
|
||||
title: 'Contractors',
|
||||
description: 'Limited-time projects with highly experienced individuals',
|
||||
value: 'contractors',
|
||||
},
|
||||
{ title: 'Employees', description: 'Unlimited term contracts', value: 'employees', disabled: true },
|
||||
] satisfies { title: string; description: string; value: string; disabled?: boolean }[];
|
||||
|
||||
export interface JobCategoryStepProps {
|
||||
onNext?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function JobCategoryStep({ onNext }: JobCategoryStepProps): React.JSX.Element {
|
||||
const [category, setCategory] = React.useState<string>(categoryOptions[1].value);
|
||||
|
||||
const handleCategoryChange = React.useCallback((newCategory: string) => {
|
||||
setCategory(newCategory);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<div>
|
||||
<Typography variant="h6">I'm looking for...</Typography>
|
||||
</div>
|
||||
<RadioGroup
|
||||
onChange={(event) => {
|
||||
handleCategoryChange(event.target.value);
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiFormControlLabel-root': {
|
||||
border: '1px solid var(--mui-palette-divider)',
|
||||
borderRadius: 1,
|
||||
gap: 2,
|
||||
p: 2,
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
borderRadius: 'inherit',
|
||||
bottom: 0,
|
||||
content: '" "',
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
},
|
||||
'&.Mui-disabled': { bgcolor: 'var(--mui-palette-background-level1)' },
|
||||
},
|
||||
}}
|
||||
value={category}
|
||||
>
|
||||
{categoryOptions.map((option) => (
|
||||
<FormControlLabel
|
||||
control={<Radio />}
|
||||
disabled={option.disabled}
|
||||
key={option.value}
|
||||
label={
|
||||
<div>
|
||||
<Typography
|
||||
sx={{
|
||||
color: option.disabled ? 'var(--mui-palette-action-disabled)' : 'var(--mui-palette-text-primary)',
|
||||
}}
|
||||
variant="inherit"
|
||||
>
|
||||
{option.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: option.disabled ? 'var(--mui-palette-action-disabled)' : 'var(--mui-palette-text-secondary)',
|
||||
}}
|
||||
variant="body2"
|
||||
>
|
||||
{option.description}
|
||||
</Typography>
|
||||
</div>
|
||||
}
|
||||
sx={{
|
||||
...(option.value === category && {
|
||||
'&::before': { boxShadow: '0 0 0 2px var(--mui-palette-primary-main)' },
|
||||
}),
|
||||
}}
|
||||
value={option.value}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button endIcon={<ArrowRightIcon />} onClick={onNext} variant="contained">
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Step from '@mui/material/Step';
|
||||
import StepContent from '@mui/material/StepContent';
|
||||
import type { StepIconProps } from '@mui/material/StepIcon';
|
||||
import StepLabel from '@mui/material/StepLabel';
|
||||
import Stepper from '@mui/material/Stepper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Check as CheckIcon } from '@phosphor-icons/react/dist/ssr/Check';
|
||||
|
||||
import { JobCategoryStep } from './job-category-step';
|
||||
import { JobDescriptionStep } from './job-description-step';
|
||||
import { JobDetailsStep } from './job-details-step';
|
||||
import { JobPreview } from './job-preview';
|
||||
|
||||
function StepIcon({ active, completed, icon }: StepIconProps): React.JSX.Element {
|
||||
const highlight = active || completed;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
sx={{
|
||||
...(highlight && {
|
||||
bgcolor: 'var(--mui-palette-primary-main)',
|
||||
color: 'var(--mui-palette-primary-contrastText)',
|
||||
}),
|
||||
}}
|
||||
variant="rounded"
|
||||
>
|
||||
{completed ? <CheckIcon /> : icon}
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
export function JobCreateForm(): React.JSX.Element {
|
||||
const [activeStep, setActiveStep] = React.useState<number>(0);
|
||||
const [isComplete, setIsComplete] = React.useState<boolean>(false);
|
||||
|
||||
const handleNext = React.useCallback(() => {
|
||||
setActiveStep((prevState) => prevState + 1);
|
||||
}, []);
|
||||
|
||||
const handleBack = React.useCallback(() => {
|
||||
setActiveStep((prevState) => prevState - 1);
|
||||
}, []);
|
||||
|
||||
const handleComplete = React.useCallback(() => {
|
||||
setIsComplete(true);
|
||||
}, []);
|
||||
|
||||
const steps = React.useMemo(() => {
|
||||
return [
|
||||
{ label: 'Category', content: <JobCategoryStep onBack={handleBack} onNext={handleNext} /> },
|
||||
{ label: 'Job Details', content: <JobDetailsStep onBack={handleBack} onNext={handleNext} /> },
|
||||
{ label: 'Description', content: <JobDescriptionStep onBack={handleBack} onNext={handleComplete} /> },
|
||||
];
|
||||
}, [handleBack, handleNext, handleComplete]);
|
||||
|
||||
if (isComplete) {
|
||||
return <JobPreview />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stepper
|
||||
activeStep={activeStep}
|
||||
orientation="vertical"
|
||||
sx={{
|
||||
'& .MuiStepConnector-root': { ml: '19px' },
|
||||
'& .MuiStepConnector-line': { borderLeft: '2px solid var(--mui-palette-divider)' },
|
||||
'& .MuiStepLabel-iconContainer': { paddingRight: '16px' },
|
||||
'& .MuiStepContent-root': { borderLeft: '2px solid var(--mui-palette-divider)', ml: '19px' },
|
||||
'& .MuiStep-root:last-of-type .MuiStepContent-root': { borderColor: 'transparent' },
|
||||
}}
|
||||
>
|
||||
{steps.map((step) => {
|
||||
return (
|
||||
<Step key={step.label}>
|
||||
<StepLabel StepIconComponent={StepIcon}>
|
||||
<Typography variant="overline">{step.label}</Typography>
|
||||
</StepLabel>
|
||||
<StepContent>
|
||||
<Box sx={{ px: 2, py: 3 }}>{step.content}</Box>
|
||||
</StepContent>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
);
|
||||
}
|
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
|
||||
import type { EditorEvents } from '@tiptap/react';
|
||||
|
||||
import { TextEditor } from '@/components/core/text-editor/text-editor';
|
||||
|
||||
export interface JobDescriptionStepProps {
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
}
|
||||
|
||||
export function JobDescriptionStep({ onBack, onNext }: JobDescriptionStepProps): React.JSX.Element {
|
||||
const [content, setContent] = React.useState<string>('');
|
||||
|
||||
const handleContentChange = React.useCallback(({ editor }: EditorEvents['update']) => {
|
||||
setContent(editor.getText());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Stack spacing={3}>
|
||||
<div>
|
||||
<Typography variant="h6">How would you describe the job post?</Typography>
|
||||
</div>
|
||||
<Box sx={{ '& .tiptap-container': { height: '400px' } }}>
|
||||
<TextEditor content={content} onUpdate={handleContentChange} placeholder="Write something" />
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||
<Button color="secondary" onClick={onBack} startIcon={<ArrowLeftIcon />}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onNext} variant="contained">
|
||||
Create job
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
|
||||
import { ArrowRight as ArrowRightIcon } from '@phosphor-icons/react/dist/ssr/ArrowRight';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
export interface JobDetailsStepProps {
|
||||
onNext?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function JobDetailsStep({ onBack, onNext }: JobDetailsStepProps): React.JSX.Element {
|
||||
const [tagValue, setTagValue] = React.useState<string>('');
|
||||
const [tags, setTags] = React.useState<Set<string>>(new Set());
|
||||
const [startDate, setStartDate] = React.useState<Date | null>(dayjs().toDate());
|
||||
const [endDate, setEndDate] = React.useState<Date | null>(dayjs().add(1, 'month').toDate());
|
||||
|
||||
const handleStartDateChange = React.useCallback((newDate: Dayjs | null) => {
|
||||
setStartDate(newDate?.toDate() ?? null);
|
||||
}, []);
|
||||
|
||||
const handleEndDateChange = React.useCallback((newDate: Dayjs | null) => {
|
||||
setEndDate(newDate?.toDate() ?? null);
|
||||
}, []);
|
||||
|
||||
const handleTagAdd = React.useCallback(() => {
|
||||
if (!tagValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTags((prevState) => {
|
||||
const copy = new Set(prevState);
|
||||
copy.add(tagValue);
|
||||
return copy;
|
||||
});
|
||||
|
||||
setTagValue('');
|
||||
}, [tagValue]);
|
||||
|
||||
const handleTagDelete = React.useCallback((deletedTag: string) => {
|
||||
setTags((prevState) => {
|
||||
const copy = new Set(prevState);
|
||||
copy.delete(deletedTag);
|
||||
return copy;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Stack spacing={3}>
|
||||
<div>
|
||||
<Typography variant="h6">What is the job about?</Typography>
|
||||
</div>
|
||||
<Stack spacing={3}>
|
||||
<FormControl>
|
||||
<InputLabel>Job title</InputLabel>
|
||||
<OutlinedInput name="jobTitle" placeholder="e.g Salesforce Analyst" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<InputLabel>Tags</InputLabel>
|
||||
<OutlinedInput
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<Button color="secondary" onClick={handleTagAdd} size="small">
|
||||
Add
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
}
|
||||
name="tags"
|
||||
onChange={(event) => {
|
||||
setTagValue(event.target.value);
|
||||
}}
|
||||
value={tagValue}
|
||||
/>
|
||||
</FormControl>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{Array.from(tags.values()).map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
label={tag}
|
||||
onDelete={() => {
|
||||
handleTagDelete(tag);
|
||||
}}
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack spacing={3}>
|
||||
<div>
|
||||
<Typography variant="h6">When is the project starting?</Typography>
|
||||
</div>
|
||||
<Stack direction="row" spacing={3} sx={{ alignItems: 'center' }}>
|
||||
<DatePicker
|
||||
format="MMM D, YYYY"
|
||||
label="Start date"
|
||||
onChange={handleStartDateChange}
|
||||
sx={{ flex: '1 1 auto' }}
|
||||
value={dayjs(startDate)}
|
||||
/>
|
||||
<DatePicker
|
||||
format="MMM D, YYYY"
|
||||
label="End date"
|
||||
onChange={handleEndDateChange}
|
||||
sx={{ flex: '1 1 auto' }}
|
||||
value={dayjs(endDate)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack spacing={3}>
|
||||
<div>
|
||||
<Typography variant="h6">What is the budget?</Typography>
|
||||
</div>
|
||||
<Stack direction="row" spacing={3}>
|
||||
<FormControl sx={{ flex: '1 1 auto' }}>
|
||||
<InputLabel>Minimum</InputLabel>
|
||||
<OutlinedInput
|
||||
fullWidth
|
||||
inputProps={{ inputMode: 'numeric' }}
|
||||
name="minBudget"
|
||||
placeholder="e.g 1000"
|
||||
type="number"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl sx={{ flex: '1 1 auto' }}>
|
||||
<InputLabel>Maximum</InputLabel>
|
||||
<OutlinedInput
|
||||
fullWidth
|
||||
inputProps={{ inputMode: 'numeric' }}
|
||||
name="maxBudget"
|
||||
placeholder="e.g 5000"
|
||||
type="number"
|
||||
/>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||
<Button color="secondary" onClick={onBack} startIcon={<ArrowLeftIcon />}>
|
||||
Back
|
||||
</Button>
|
||||
<Button endIcon={<ArrowRightIcon />} onClick={onNext} variant="contained">
|
||||
Continue
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
55
002_source/cms/src/components/dashboard/jobs/job-preview.tsx
Normal file
55
002_source/cms/src/components/dashboard/jobs/job-preview.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Check as CheckIcon } from '@phosphor-icons/react/dist/ssr/Check';
|
||||
|
||||
export function JobPreview(): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Avatar
|
||||
sx={{
|
||||
'--Icon-fontSize': 'var(--icon-fontSize-lg)',
|
||||
bgcolor: 'var(--mui-palette-success-main)',
|
||||
color: 'var(--mui-palette-success-contrastText)',
|
||||
}}
|
||||
>
|
||||
<CheckIcon fontSize="var(--Icon-fontSize)" />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography variant="h6">All done!</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Here's a preview of your newly created job
|
||||
</Typography>
|
||||
</div>
|
||||
<Card variant="outlined">
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ alignItems: 'center', flexWrap: 'wrap', justifyContent: 'space-between', px: 2, py: 1.5 }}
|
||||
>
|
||||
<div>
|
||||
<Typography variant="subtitle1">Senior Backend Engineer</Typography>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
Remote possible •{' '}
|
||||
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact' }).format(
|
||||
150000
|
||||
)}{' '}
|
||||
-{' '}
|
||||
{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact' }).format(
|
||||
210000
|
||||
)}
|
||||
</Typography>
|
||||
</div>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
1 minute ago
|
||||
</Typography>
|
||||
<Button size="small">Apply</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
56
002_source/cms/src/components/dashboard/jobs/jobs-card.tsx
Normal file
56
002_source/cms/src/components/dashboard/jobs/jobs-card.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
import type { Job } from './types';
|
||||
|
||||
export interface JobsCardProps {
|
||||
jobs?: Job[];
|
||||
}
|
||||
|
||||
export function JobsCard({ jobs = [] }: JobsCardProps): React.JSX.Element {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<Stack divider={<Divider />}>
|
||||
{jobs.map((job) => {
|
||||
const location = job.isRemote ? 'Remote possible' : `(${job.country}, ${job.state}, ${job.city})`;
|
||||
const salary = `${job.currency} ${new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: job.currency,
|
||||
notation: 'compact',
|
||||
}).format(job.budgetMin)} - ${job.currency} ${new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: job.currency,
|
||||
notation: 'compact',
|
||||
}).format(job.budgetMax)}`;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
key={job.id}
|
||||
sx={{ alignItems: 'center', flexWrap: 'wrap', justifyContent: 'space-between', px: 2, py: 1.5 }}
|
||||
>
|
||||
<div>
|
||||
<Typography variant="subtitle1">{job.title}</Typography>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
{location} • {salary}
|
||||
</Typography>
|
||||
</div>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
{dayjs(job.publishedAt).fromNow()}
|
||||
</Typography>
|
||||
<Button size="small">Apply</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
124
002_source/cms/src/components/dashboard/jobs/jobs-filters.tsx
Normal file
124
002_source/cms/src/components/dashboard/jobs/jobs-filters.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Input from '@mui/material/Input';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
|
||||
|
||||
import { MultiSelect } from '@/components/core/multi-select';
|
||||
|
||||
interface FilterChip {
|
||||
label: string;
|
||||
field: 'type' | 'level' | 'location' | 'role';
|
||||
value: unknown;
|
||||
displayValue?: string;
|
||||
}
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'Freelance', value: 'freelance' },
|
||||
{ label: 'Full Time', value: 'full_time' },
|
||||
{ label: 'Part Time', value: 'part_time' },
|
||||
{ label: 'Internship', value: 'internship' },
|
||||
] as const;
|
||||
|
||||
const levelOptions = [
|
||||
{ label: 'Novice', value: 'novice' },
|
||||
{ label: 'Expert', value: 'expert' },
|
||||
] as const;
|
||||
|
||||
const locationOptions = [
|
||||
{ label: 'Africa', value: 'africa' },
|
||||
{ label: 'Asia', value: 'asia' },
|
||||
{ label: 'Europe', value: 'europe' },
|
||||
{ label: 'North America', value: 'north_america' },
|
||||
{ label: 'South America', value: 'south_america' },
|
||||
] as const;
|
||||
|
||||
const roleOptions = [
|
||||
{ label: 'Frontend Developer', value: 'frontend_developer' },
|
||||
{ label: 'Backend Engineer', value: 'backend_engineer' },
|
||||
{ label: 'iOS Developer', value: 'ios_developer' },
|
||||
] as const;
|
||||
|
||||
export function JobsFilters(): React.JSX.Element {
|
||||
const chips: FilterChip[] = [
|
||||
{ label: 'Type', field: 'type', value: 'freelance', displayValue: 'Freelance' },
|
||||
{ label: 'Type', field: 'type', value: 'internship', displayValue: 'Internship' },
|
||||
{ label: 'Level', field: 'level', value: 'novice', displayValue: 'Novice' },
|
||||
{ label: 'Location', field: 'location', value: 'asia', displayValue: 'Asia' },
|
||||
{ label: 'Role', field: 'role', value: 'frontend_developer', displayValue: 'Frontend Developer' },
|
||||
];
|
||||
|
||||
// You should memoize the values to avoid unnecessary filtering
|
||||
const typeValues = chips.filter((chip) => chip.field === 'type').map((chip) => chip.value);
|
||||
const levelValues = chips.filter((chip) => chip.field === 'level').map((chip) => chip.value);
|
||||
const locationValues = chips.filter((chip) => chip.field === 'location').map((chip) => chip.value);
|
||||
const roleValues = chips.filter((chip) => chip.field === 'role').map((chip) => chip.value);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Input
|
||||
fullWidth
|
||||
placeholder="Enter a keyword"
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<MagnifyingGlassIcon />
|
||||
</InputAdornment>
|
||||
}
|
||||
sx={{ px: 3, py: 2 }}
|
||||
/>
|
||||
<Divider />
|
||||
{chips.length ? (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap', p: 2 }}>
|
||||
{chips.map((chip) => (
|
||||
<Chip
|
||||
key={`${chip.field}-${chip.displayValue}`}
|
||||
label={
|
||||
<Typography variant="inherit">
|
||||
<Typography component="span" sx={{ fontWeight: 600 }} variant="inherit">
|
||||
{chip.label}:
|
||||
</Typography>{' '}
|
||||
{chip.displayValue}
|
||||
</Typography>
|
||||
}
|
||||
onDelete={() => {
|
||||
// noop
|
||||
}}
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography color="text.secondary" variant="subtitle2">
|
||||
No filters applied
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Divider />
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{ alignItems: 'center', flexWrap: 'wrap', justifyContent: 'space-between', p: 1 }}
|
||||
>
|
||||
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap' }}>
|
||||
<MultiSelect label="Type" options={typeOptions} value={typeValues} />
|
||||
<MultiSelect label="Level" options={levelOptions} value={levelValues} />
|
||||
<MultiSelect label="Location" options={locationOptions} value={locationValues} />
|
||||
<MultiSelect label="Role" options={roleOptions} value={roleValues} />
|
||||
</Stack>
|
||||
<div>
|
||||
<FormControlLabel control={<Checkbox defaultChecked />} label="In network" />
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
38
002_source/cms/src/components/dashboard/jobs/member-card.tsx
Normal file
38
002_source/cms/src/components/dashboard/jobs/member-card.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import type { Member } from './types';
|
||||
|
||||
export interface MemberCardProps {
|
||||
member: Member;
|
||||
}
|
||||
|
||||
export function MemberCard({ member }: MemberCardProps): React.JSX.Element {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<Avatar src={member.avatar} />
|
||||
<div>
|
||||
<Typography variant="subtitle2">{member.name}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{member.role}
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{(member.skills ?? []).map((skill) => (
|
||||
<Chip key={skill} label={skill} size="small" variant="soft" />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
34
002_source/cms/src/components/dashboard/jobs/review-add.tsx
Normal file
34
002_source/cms/src/components/dashboard/jobs/review-add.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Button from '@mui/material/Button';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Rating from '@mui/material/Rating';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
const user = {
|
||||
id: 'USR-000',
|
||||
name: 'Sofia Rivers',
|
||||
avatar: '/assets/avatar.png',
|
||||
email: 'sofia@devias.io',
|
||||
} satisfies User;
|
||||
|
||||
export function CompanyReviewAdd(): React.JSX.Element {
|
||||
return (
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start' }}>
|
||||
<Avatar src={user.avatar} />
|
||||
<Stack spacing={3} sx={{ flex: '1 1 auto' }}>
|
||||
<OutlinedInput multiline placeholder="Send your review" rows={3} />
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: 'center', flexWrap: 'wrap', justifyContent: 'space-between' }}
|
||||
>
|
||||
<Rating value={5} />
|
||||
<Button variant="contained">Send review</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
56
002_source/cms/src/components/dashboard/jobs/review-card.tsx
Normal file
56
002_source/cms/src/components/dashboard/jobs/review-card.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Rating from '@mui/material/Rating';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import { dayjs } from '@/lib/dayjs';
|
||||
|
||||
import type { Review } from './types';
|
||||
|
||||
export interface ReviewCardProps {
|
||||
review: Review;
|
||||
}
|
||||
|
||||
export function ReviewCard({ review }: ReviewCardProps): React.JSX.Element {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ alignItems: { xs: 'flex-start', sm: 'center' } }}
|
||||
>
|
||||
<Avatar src={review.author.avatar} />
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle1">{review.title}</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
divider={<span>•</span>}
|
||||
spacing={2}
|
||||
sx={{ alignItems: 'center', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Rating max={1} precision={0.1} readOnly value={review.rating / 5} />
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{review.rating}/5
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{review.author.name}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" noWrap variant="body2">
|
||||
{dayjs(review.createdAt).fromNow()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Typography variant="body1">{review.comment}</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import Card from '@mui/material/Card';
|
||||
import Rating from '@mui/material/Rating';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export interface ReviewsSummaryProps {
|
||||
averageRating: number;
|
||||
totalReviews: number;
|
||||
}
|
||||
|
||||
export function ReviewsSummary({ averageRating, totalReviews }: ReviewsSummaryProps): React.JSX.Element {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ alignItems: { xs: 'flex-start', sm: 'center' }, flexWrap: 'wrap', p: 3 }}
|
||||
>
|
||||
<Typography variant="subtitle2">Overall reviews</Typography>
|
||||
<Stack direction="row" divider={<span>•</span>} spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Rating max={1} precision={0.1} readOnly value={averageRating / 5} />
|
||||
<Typography noWrap variant="subtitle2">
|
||||
{averageRating}/5
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{totalReviews} reviews in total
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
37
002_source/cms/src/components/dashboard/jobs/types.d.ts
vendored
Normal file
37
002_source/cms/src/components/dashboard/jobs/types.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface Job {
|
||||
id: string;
|
||||
title: string;
|
||||
currency: string;
|
||||
budgetMin: number;
|
||||
budgetMax: number;
|
||||
isRemote?: boolean;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
publishedAt: Date;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
role: string;
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
id: string;
|
||||
title: string;
|
||||
comment?: string;
|
||||
rating: number;
|
||||
author: { name: string; avatar?: string };
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Asset {
|
||||
id: string;
|
||||
mimeType: string;
|
||||
name: string;
|
||||
size: string;
|
||||
url: string;
|
||||
}
|
Reference in New Issue
Block a user