mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-14 08:42:08 +10:00
feat(homepage): add new sections to homepage
This commit is contained in:
@ -1,4 +1,3 @@
|
||||
import { t } from "@lingui/macro";
|
||||
import { useTheme } from "@reactive-resume/hooks";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
@ -26,7 +25,7 @@ export const Icon = ({ size = 32, className }: Props) => {
|
||||
src={src}
|
||||
width={size}
|
||||
height={size}
|
||||
alt={t`Reactive Resume`}
|
||||
alt="Reactive Resume"
|
||||
className={cn("rounded-sm", className)}
|
||||
/>
|
||||
);
|
||||
|
||||
79
apps/client/src/components/locale-switch.tsx
Normal file
79
apps/client/src/components/locale-switch.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { t } from "@lingui/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Check, Translate } from "@phosphor-icons/react";
|
||||
import {
|
||||
Button,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
ScrollArea,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
import { changeLanguage } from "../providers/locale";
|
||||
import { useLanguages } from "../services/resume/translation";
|
||||
|
||||
export const LocaleSwitch = () => {
|
||||
const { i18n } = useLingui();
|
||||
const { languages } = useLanguages();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const options = languages.map((language) => ({
|
||||
label: language.name,
|
||||
value: language.locale,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<Translate size={20} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={t`Search for a language`} />
|
||||
<CommandEmpty>{t`No results found`}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<ScrollArea orientation="vertical">
|
||||
<div className="max-h-60">
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value.toLowerCase().trim()}
|
||||
onSelect={async (selectedValue) => {
|
||||
const option = options.find(
|
||||
(option) => option.value.toLowerCase().trim() === selectedValue,
|
||||
);
|
||||
|
||||
if (!option) return null;
|
||||
|
||||
await changeLanguage(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 opacity-0",
|
||||
i18n.locale === option.value && "opacity-100",
|
||||
)}
|
||||
/>
|
||||
{option.label}{" "}
|
||||
<span className="ml-1.5 text-xs opacity-50">({option.value})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
import { t } from "@lingui/macro";
|
||||
import { useTheme } from "@reactive-resume/hooks";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
@ -26,7 +25,7 @@ export const Logo = ({ size = 32, className }: Props) => {
|
||||
src={src}
|
||||
width={size}
|
||||
height={size}
|
||||
alt={t`Reactive Resume`}
|
||||
alt="Reactive Resume"
|
||||
className={cn("rounded-sm", className)}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,30 +1,17 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
import { axios } from "./axios";
|
||||
|
||||
type Locale = "en-US" | "de-DE" | "zu-ZA";
|
||||
|
||||
export const defaultLocale = "en-US";
|
||||
|
||||
export const getLocales = () => {
|
||||
const locales = {
|
||||
"en-US": t`English`,
|
||||
"de-DE": t`German`,
|
||||
} as Record<Locale, string>;
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// eslint-disable-next-line lingui/no-unlocalized-strings
|
||||
locales["zu-ZA"] = "Pseudolocalization";
|
||||
}
|
||||
|
||||
return locales;
|
||||
};
|
||||
axios(`translation/${defaultLocale}`).then((response) => {
|
||||
const messages = response.data;
|
||||
i18n.loadAndActivate({ locale: defaultLocale, messages });
|
||||
});
|
||||
|
||||
export async function dynamicActivate(locale: string) {
|
||||
const response = await axios(`translation/${locale}`);
|
||||
const messages = await response.data;
|
||||
const messages = response.data;
|
||||
|
||||
i18n.load(locale, messages);
|
||||
i18n.activate(locale);
|
||||
i18n.loadAndActivate({ locale, messages });
|
||||
}
|
||||
|
||||
1640
apps/client/src/locales/en-US/messages.po
Normal file
1640
apps/client/src/locales/en-US/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,9 @@ import { t } from "@lingui/macro";
|
||||
import { useMemo } from "react";
|
||||
import { Link, matchRoutes, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
import { LocaleSwitch } from "@/client/components/locale-switch";
|
||||
import { Logo } from "@/client/components/logo";
|
||||
import { ThemeSwitch } from "@/client/components/theme-switch";
|
||||
|
||||
import { SocialAuth } from "./_components/social-auth";
|
||||
|
||||
@ -15,10 +17,17 @@ export const AuthLayout = () => {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen">
|
||||
<div className="flex w-full flex-col justify-center gap-y-8 px-12 sm:mx-auto sm:basis-[420px] sm:px-0 lg:basis-[480px] lg:px-12">
|
||||
<Link to="/" className="h-24 w-24">
|
||||
<Logo className="-ml-3" size={96} />
|
||||
</Link>
|
||||
<div className="relative flex w-full flex-col justify-center gap-y-8 px-12 sm:mx-auto sm:basis-[420px] sm:px-0 lg:basis-[480px] lg:px-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/" className="h-24 w-24">
|
||||
<Logo className="-ml-3" size={96} />
|
||||
</Link>
|
||||
|
||||
<div className="inset-x-0 bottom-0 space-x-2 text-right lg:absolute lg:p-12 lg:text-center">
|
||||
<LocaleSwitch />
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Outlet />
|
||||
|
||||
@ -45,7 +54,7 @@ export const AuthLayout = () => {
|
||||
<img
|
||||
width={1920}
|
||||
height={1080}
|
||||
alt={t`Open books on a table`}
|
||||
alt="Open books on a table"
|
||||
className="h-screen w-full object-cover object-center"
|
||||
src="/backgrounds/patrick-tomasso-Oaqk7qqNh_c-unsplash.jpg"
|
||||
/>
|
||||
|
||||
@ -36,6 +36,7 @@ type Props = { id: SectionKey };
|
||||
|
||||
export const SectionOptions = ({ id }: Props) => {
|
||||
const { open } = useDialog(id);
|
||||
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const removeSection = useResumeStore((state) => state.removeSection);
|
||||
|
||||
|
||||
@ -8,7 +8,6 @@ import { ThemeSwitch } from "@/client/components/theme-switch";
|
||||
import { ExportSection } from "./sections/export";
|
||||
import { InformationSection } from "./sections/information";
|
||||
import { LayoutSection } from "./sections/layout";
|
||||
import { LocaleSection } from "./sections/locale";
|
||||
import { NotesSection } from "./sections/notes";
|
||||
import { PageSection } from "./sections/page";
|
||||
import { SharingSection } from "./sections/sharing";
|
||||
@ -40,8 +39,6 @@ export const RightSidebar = () => {
|
||||
<Separator />
|
||||
<PageSection />
|
||||
<Separator />
|
||||
<LocaleSection />
|
||||
<Separator />
|
||||
<SharingSection />
|
||||
<Separator />
|
||||
<StatisticsSection />
|
||||
|
||||
@ -26,7 +26,6 @@ const DonateCard = () => (
|
||||
If you like the app and want to support keeping it free forever, please donate whatever
|
||||
you can afford to give.
|
||||
</p>
|
||||
<p>Your donations could be tax-deductible, depending on your location.</p>
|
||||
</Trans>
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
import { t } from "@lingui/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Combobox, Label } from "@reactive-resume/ui";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { dynamicActivate, getLocales } from "@/client/libs/lingui";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "../shared/section-icon";
|
||||
|
||||
export const LocaleSection = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const locale = useResumeStore((state) => state.resume.data.metadata.locale);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return Object.entries(getLocales()).map(([value, label]) => ({
|
||||
label,
|
||||
value,
|
||||
}));
|
||||
}, [_]);
|
||||
|
||||
const onChangeLanguage = async (value: string) => {
|
||||
setValue("metadata.locale", value);
|
||||
await dynamicActivate(value);
|
||||
|
||||
// Update resume section titles with new locale
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="locale" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("locale")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">{t`Locale`}</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid gap-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t`Language`}</Label>
|
||||
<Combobox value={locale} onValueChange={onChangeLanguage} options={options} />
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -168,17 +168,26 @@ export const TypographySection = () => {
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t`Options`}</Label>
|
||||
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Switch
|
||||
id="metadata.typography.underlineLinks"
|
||||
checked={typography.underlineLinks}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue("metadata.typography.underlineLinks", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="metadata.typography.underlineLinks">{t`Underline Links`}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 py-2">
|
||||
<Switch
|
||||
id="metadata.typography.hideIcons"
|
||||
checked={typography.hideIcons}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue("metadata.typography.hideIcons", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="metadata.typography.hideIcons">{t`Hide Icons`}</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-4 py-2">
|
||||
<Switch
|
||||
id="metadata.typography.underlineLinks"
|
||||
checked={typography.underlineLinks}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue("metadata.typography.underlineLinks", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="metadata.typography.underlineLinks">{t`Underline Links`}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -85,10 +85,10 @@ export const ResumeCard = ({ resume }: Props) => {
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
src={url}
|
||||
loading="lazy"
|
||||
alt={resume.title}
|
||||
className="h-full w-full object-cover"
|
||||
src={`${url}?cache=${new Date().getTime()}`}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@ -129,10 +129,10 @@ export const ResumeListItem = ({ resume }: Props) => {
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
src={url}
|
||||
loading="lazy"
|
||||
alt={resume.title}
|
||||
className="aspect-[1/1.4142] w-60 rounded-sm object-cover"
|
||||
src={`${url}?cache=${new Date().getTime()}`}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@ -9,7 +9,7 @@ import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { dynamicActivate, getLocales } from "@/client/libs/lingui";
|
||||
import { useLanguages } from "@/client/services/resume/translation";
|
||||
import { useUpdateUser, useUser } from "@/client/services/user";
|
||||
|
||||
const formSchema = z.object({
|
||||
@ -21,6 +21,7 @@ type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const ProfileSettings = () => {
|
||||
const { user } = useUser();
|
||||
const { languages } = useLanguages();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { updateUser, loading } = useUpdateUser();
|
||||
|
||||
@ -45,8 +46,10 @@ export const ProfileSettings = () => {
|
||||
setTheme(data.theme);
|
||||
|
||||
if (user.locale !== data.locale) {
|
||||
await dynamicActivate(data.locale);
|
||||
window.localStorage.setItem("locale", data.locale);
|
||||
await updateUser({ locale: data.locale });
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
form.reset(data);
|
||||
@ -96,10 +99,7 @@ export const ProfileSettings = () => {
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
options={Object.entries(getLocales()).map(([value, label]) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
options={languages.map(({ locale, name }) => ({ label: name, value: locale }))}
|
||||
/>
|
||||
</div>
|
||||
<FormDescription>
|
||||
|
||||
@ -2,6 +2,7 @@ import { t } from "@lingui/macro";
|
||||
import { Separator } from "@reactive-resume/ui";
|
||||
|
||||
import { Copyright } from "@/client/components/copyright";
|
||||
import { LocaleSwitch } from "@/client/components/locale-switch";
|
||||
import { Logo } from "@/client/components/logo";
|
||||
import { ThemeSwitch } from "@/client/components/theme-switch";
|
||||
|
||||
@ -23,7 +24,8 @@ export const Footer = () => (
|
||||
</div>
|
||||
|
||||
<div className="relative col-start-4">
|
||||
<div className="absolute bottom-0 right-0">
|
||||
<div className="absolute bottom-0 right-0 space-x-2">
|
||||
<LocaleSwitch />
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import { t } from "@lingui/macro";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
import { ContributorsSection } from "./sections/contributors";
|
||||
import { FAQSection } from "./sections/faq";
|
||||
import { FeaturesSection } from "./sections/features";
|
||||
import { HeroSection } from "./sections/hero";
|
||||
import { LogoCloudSection } from "./sections/logo-cloud";
|
||||
import { SampleResumesSection } from "./sections/sample-resumes";
|
||||
import { StatisticsSection } from "./sections/statistics";
|
||||
import { SupportSection } from "./sections/support";
|
||||
import { TestimonialsSection } from "./sections/testimonials";
|
||||
|
||||
export const HomePage = () => (
|
||||
<main className="relative isolate mb-[400px] overflow-hidden bg-background">
|
||||
@ -16,5 +22,11 @@ export const HomePage = () => (
|
||||
<HeroSection />
|
||||
<LogoCloudSection />
|
||||
<StatisticsSection />
|
||||
<FeaturesSection />
|
||||
<SampleResumesSection />
|
||||
<TestimonialsSection />
|
||||
<SupportSection />
|
||||
<FAQSection />
|
||||
<ContributorsSection />
|
||||
</main>
|
||||
);
|
||||
|
||||
67
apps/client/src/pages/home/sections/contributors/index.tsx
Normal file
67
apps/client/src/pages/home/sections/contributors/index.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { t } from "@lingui/macro";
|
||||
import { Avatar, AvatarFallback, AvatarImage, Tooltip } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { motion } from "framer-motion";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useContributors } from "@/client/services/resume/contributors";
|
||||
|
||||
export const ContributorsSection = () => {
|
||||
const { github, crowdin, loading } = useContributors();
|
||||
|
||||
const contributors = useMemo(() => {
|
||||
if (github && crowdin) return [...github, ...crowdin];
|
||||
return [];
|
||||
}, [github, crowdin]);
|
||||
|
||||
return (
|
||||
<section id="contributors" className="container relative space-y-12 py-24 sm:py-32">
|
||||
<div className="space-y-6 text-center">
|
||||
<h1 className="text-4xl font-bold">{t`By the community, for the community.`}</h1>
|
||||
<p className="mx-auto max-w-3xl leading-loose">
|
||||
{t`Reactive Resume thrives thanks to its vibrant community. This project owes its progress to numerous individuals who've dedicated their time and skills. Below, we celebrate the coders who've enhanced its features on GitHub and the linguists whose translations on Crowdin have made it accessible to a broader audience.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-center gap-3">
|
||||
{Array(30)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
viewport={{ once: true }}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
whileInView={{ opacity: 1, scale: 1, transition: { delay: index * 0.05 } }}
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarFallback></AvatarFallback>
|
||||
</Avatar>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-center gap-3">
|
||||
{contributors.map((contributor, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
viewport={{ once: true }}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
whileInView={{ opacity: 1, scale: 1, transition: { delay: index * 0.025 } }}
|
||||
className={cn(index > 30 && "hidden lg:block")}
|
||||
>
|
||||
<a href={contributor.url} target="_blank" rel="noreferrer">
|
||||
<Tooltip content={contributor.name}>
|
||||
<Avatar>
|
||||
<AvatarImage src={contributor.avatar} alt={contributor.name} />
|
||||
<AvatarFallback>{contributor.name}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
247
apps/client/src/pages/home/sections/faq/index.tsx
Normal file
247
apps/client/src/pages/home/sections/faq/index.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
/* eslint-disable lingui/text-restrictions */
|
||||
/* eslint-disable lingui/no-unlocalized-strings */
|
||||
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
import { useLanguages } from "@/client/services/resume/translation";
|
||||
|
||||
// Who are you, and why did you build Reactive Resume?
|
||||
const Question1 = () => (
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger>Who are you, and why did you build Reactive Resume?</AccordionTrigger>
|
||||
<AccordionContent className="prose max-w-none dark:prose-invert">
|
||||
<p>
|
||||
I'm Amruth Pillai, just another run-off-the-mill developer working at Elara Digital GmbH in
|
||||
Berlin, Germany. I'm married to my beautiful and insanely supportive wife who has helped me
|
||||
in more ways than one in seeing this project to it's fruition. I am originally from
|
||||
Bengaluru, India where I was a developer at Postman (the API testing tool) for a short
|
||||
while.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Back in my university days, I designed a really cool dark mode resume (link on my website)
|
||||
using Figma and I had a line of friends and strangers asking me to design their resume for
|
||||
them.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
While I could have charged everyone a hefty sum and retired even before I began, I decided
|
||||
to build the first version of Reactive Resume in 2019. Since then, it's gone through
|
||||
multiple iterations as I've learned a lot of better coding practices over the years.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
At the time of writing, Reactive Resume is probably one of the only handful of resume
|
||||
builders out there available to the world for free and without an annoying paywall at the
|
||||
end. While being free is often associated with software that's not of good quality, I strive
|
||||
to prove them wrong and build a product that people love using and are benefitted by it.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
My dream has always been to build something that at least a handful people use on a daily
|
||||
basis, and I'm extremely proud to say that Reactive Resume, over it's years of development,
|
||||
has **helped over half a million people build their resume**, and I hope it only increases
|
||||
from here and reaches more people who are in need of a good resume to kickstart their career
|
||||
but can't afford to pay for one.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
// How much does it cost to run Reactive Resume?
|
||||
const Question2 = () => (
|
||||
<AccordionItem value="2">
|
||||
<AccordionTrigger>How much does it cost to run Reactive Resume?</AccordionTrigger>
|
||||
<AccordionContent className="prose max-w-none dark:prose-invert">
|
||||
<p>
|
||||
It's not much honestly.{" "}
|
||||
<a href="https://pillai.xyz/digitalocean" rel="noreferrer" target="_blank">
|
||||
DigitalOcean
|
||||
</a>{" "}
|
||||
has graciously sponsored their infrastructure to allow me to host Reactive Resume on their
|
||||
platform. There's only the fee I pay to dependent services to send emails, renew the domain,
|
||||
etc.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I've spent countless hours and sleepless nights building the application though, and I
|
||||
honestly do not expect anything in return but to hear from you on how the app has helped you
|
||||
with your career.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
But if you do feel like supporting the developer and the future development of Reactive
|
||||
Resume, please donate (<em>only if you have some extra money lying around</em>) on my{" "}
|
||||
<a href="https://github.com/sponsors/AmruthPillai/" rel="noreferrer" target="_blank">
|
||||
GitHub Sponsors page
|
||||
</a>
|
||||
. You can choose to donate one-time or sponsor a recurring donation.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Alternatively, if you are in the US, or you are a part of a large educational institution or
|
||||
corporate organization, you can{" "}
|
||||
<a href="https://opencollective.com/reactive-resume" rel="noreferrer" target="_blank">
|
||||
support the project through Open Collective
|
||||
</a>
|
||||
. We are fiscally hosted through Open Collective Europe, which means your donations and
|
||||
sponsorships could also be made tax-deductible.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
// Other than donating, how can I support you?
|
||||
const Question3 = () => (
|
||||
<AccordionItem value="3">
|
||||
<AccordionTrigger>Other than donating, how can I support you?</AccordionTrigger>
|
||||
<AccordionContent className="prose max-w-none dark:prose-invert">
|
||||
<p>
|
||||
<strong>If you speak a language other than English</strong>, sign up to be a translator on
|
||||
Crowdin, our translation management service. You can help translate the product to your
|
||||
language and share it among your community. Even if the language is already translated, it
|
||||
helps to sign up as you would be notified when there are new phrases to be translated.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>If you work in the media, are an influencer or have lots of friends</strong>, share
|
||||
the app with your circles and let them know so it can reach the people who need it the most.
|
||||
I'm also <a href="mailto:hello@amruthpillai.com">open to giving tech talks</a>, although
|
||||
that's wishful thinking. But if you do mention Reactive Resume on your blog, let me know so
|
||||
that I can link back to you here.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>If you found a bug or have an idea for a feature</strong>, raise an issue on GitHub
|
||||
or shoot me a message and let me know what you'd like to see. I can't promise that it'll be
|
||||
done soon, but juggling work, life and open-source, I'll definitely get to it when I can.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
// What languages are supported on Reactive Resume?
|
||||
const Question4 = () => {
|
||||
const { languages } = useLanguages();
|
||||
|
||||
return (
|
||||
<AccordionItem value="4">
|
||||
<AccordionTrigger>What languages are supported on Reactive Resume?</AccordionTrigger>
|
||||
<AccordionContent className="prose max-w-none dark:prose-invert">
|
||||
<p>
|
||||
Here are the languages currently supported by Reactive Resume, along with their respective
|
||||
completion percentages.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-start justify-start gap-x-2 gap-y-4">
|
||||
{languages.map((language) => (
|
||||
<a
|
||||
key={language.id}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="no-underline"
|
||||
href={`https://crowdin.com/translate/reactive-resume/all/en-${language.editorCode}`}
|
||||
>
|
||||
<div className="relative bg-secondary-accent font-medium">
|
||||
<span className="px-2 py-1">{language.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inset-0 bg-warning px-1.5 py-1 text-xs text-white",
|
||||
language.progress < 40 && "bg-error",
|
||||
language.progress > 80 && "bg-success",
|
||||
)}
|
||||
>
|
||||
{language.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
If you'd like to improve the translations for your language, please{" "}
|
||||
<a href="https://crowdin.com/project/reactive-resume" rel="noreferrer" target="_blank">
|
||||
sign up as a translator on Crowdin
|
||||
</a>{" "}
|
||||
and join the project. You can also choose to be notified of any new phrases that get added
|
||||
to the app.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If a language is missing from this list, please raise an issue on GitHub requesting its
|
||||
inclusion, and I will make sure to add it as soon as possible.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
|
||||
// How does the OpenAI Integration work?
|
||||
const Question5 = () => (
|
||||
<AccordionItem value="5">
|
||||
<AccordionTrigger>How does the OpenAI Integration work?</AccordionTrigger>
|
||||
<AccordionContent className="prose max-w-none dark:prose-invert">
|
||||
<p>
|
||||
OpenAI has been a game-changer for all of us. I cannot tell you how much ChatGPT has helped
|
||||
me in my everyday work and with the development of Reactive Resume. It only makes sense that
|
||||
you leverage what AI has to offer and let it help you build the perfect resume.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
While most applications out there charge you a fee to use their AI services (rightfully so,
|
||||
because it isn't cheap), you can choose to enter your own OpenAI API key on the Settings
|
||||
page (under OpenAI Integration).{" "}
|
||||
<strong>The key is stored in your browser's local storage</strong>, which means that if you
|
||||
uninstall your browser, or even clear your data, the key is gone with it. All requests made
|
||||
to OpenAI are also sent directly to their service and does not hit the app servers at all.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The policy behind "bring your own key" (BYOK) is{" "}
|
||||
<a href="https://community.openai.com/t/openais-bring-your-own-key-policy/14538/46">
|
||||
still being discussed
|
||||
</a>{" "}
|
||||
and probably might change over a period of time, but while it's available, I would keep the
|
||||
feature on the app.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You are free to turn off all AI features (and not be aware of it's existence) simply by not
|
||||
adding a key in the Settings page and still make use of all of the useful features that
|
||||
Reactive Resume has to offer. I would even suggest you to take the extra step of using
|
||||
ChatGPT to write your content, and simply copy it over to Reactive Resume.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
export const FAQSection = () => {
|
||||
return (
|
||||
<section id="faq" className="container relative py-24 sm:py-32">
|
||||
<div className="grid grid-cols-3 gap-x-12">
|
||||
<div className="col-span-1 space-y-6">
|
||||
<h2 className="text-3xl font-bold">Frequently Asked Questions</h2>
|
||||
|
||||
<p className="text-base leading-loose">
|
||||
Here are some questions I often get asked about Reactive Resume.
|
||||
</p>
|
||||
|
||||
<p className="text-sm leading-loose">
|
||||
Unfortunately, this section is available only in English, as I do not want to burden
|
||||
translators with having to translate these large paragraphs of text.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Accordion collapsible type="single">
|
||||
<Question1 />
|
||||
<Question2 />
|
||||
<Question3 />
|
||||
<Question4 />
|
||||
<Question5 />
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
140
apps/client/src/pages/home/sections/features/index.tsx
Normal file
140
apps/client/src/pages/home/sections/features/index.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { t } from "@lingui/macro";
|
||||
import {
|
||||
Brain,
|
||||
Cloud,
|
||||
CloudSun,
|
||||
CurrencyDollarSimple,
|
||||
EnvelopeSimple,
|
||||
Eye,
|
||||
File,
|
||||
Files,
|
||||
Folder,
|
||||
GitBranch,
|
||||
GithubLogo,
|
||||
GoogleChromeLogo,
|
||||
GoogleLogo,
|
||||
IconContext,
|
||||
Layout,
|
||||
Lock,
|
||||
Note,
|
||||
Prohibit,
|
||||
Scales,
|
||||
StackSimple,
|
||||
Star,
|
||||
Swatches,
|
||||
TextAa,
|
||||
Translate,
|
||||
} from "@phosphor-icons/react";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type Feature = {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const featureLabel = cn(
|
||||
"flex cursor-default items-center justify-center gap-x-2 rounded bg-secondary px-4 py-3 text-sm font-medium leading-none text-primary transition-colors hover:bg-primary hover:text-background",
|
||||
);
|
||||
|
||||
export const FeaturesSection = () => {
|
||||
const features: Feature[] = [
|
||||
{ icon: <CurrencyDollarSimple />, title: t`Free, forever` },
|
||||
{ icon: <GitBranch />, title: t`Open Source` },
|
||||
{ icon: <Scales />, title: t`MIT License` },
|
||||
{ icon: <Prohibit />, title: t`No user tracking or advertising` },
|
||||
{ icon: <Cloud />, title: t`Self-host with Docker` },
|
||||
{ icon: <Translate />, title: t`Available in 20+ languages` },
|
||||
{ icon: <Brain />, title: t`OpenAI Integration` },
|
||||
{ icon: <GithubLogo />, title: t`Sign in with GitHub` },
|
||||
{ icon: <GoogleLogo />, title: t`Sign in with Google` },
|
||||
{ icon: <EnvelopeSimple />, title: t`Sign in with Email` },
|
||||
{ icon: <Lock />, title: t`Secure with two-factor authentication` },
|
||||
{ icon: <StackSimple />, title: t`8 design templates to choose from, more on the way` },
|
||||
{ icon: <Files />, title: t`Design single/multi page resumes` },
|
||||
{ icon: <Folder />, title: t`Manage multiple resumes` },
|
||||
{ icon: <Swatches />, title: t`Customisable colour palettes` },
|
||||
{ icon: <Layout />, title: t`Customisable layouts` },
|
||||
{ icon: <Star />, title: t`Custom resume sections` },
|
||||
{ icon: <Note />, title: t`Personal notes for each resume` },
|
||||
{ icon: <Lock />, title: t`Lock a resume to prevent editing` },
|
||||
{ icon: <File />, title: t`Supports A4/Letter page formats` },
|
||||
{ icon: <TextAa />, title: t`Pick any font from Google Fonts` },
|
||||
{ icon: <GoogleChromeLogo />, title: t`Host your resume publicly` },
|
||||
{ icon: <Eye />, title: t`Track views and downloads` },
|
||||
{ icon: <CloudSun />, title: t`Light or dark theme` },
|
||||
{
|
||||
icon: (
|
||||
<div className="flex items-center space-x-1">
|
||||
<img src="https://cdn.simpleicons.org/react" alt="React" width={14} height={14} />
|
||||
<img src="https://cdn.simpleicons.org/vite" alt="Vite" width={14} height={14} />
|
||||
<img
|
||||
src="https://cdn.simpleicons.org/tailwindcss"
|
||||
alt="TailwindCSS"
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
<img src="https://cdn.simpleicons.org/nestjs" alt="NestJS" width={14} height={14} />
|
||||
<img
|
||||
src="https://cdn.simpleicons.org/googlechrome"
|
||||
alt="Google Chrome"
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
<img
|
||||
src="https://cdn.simpleicons.org/postgresql"
|
||||
alt="PostgreSQL"
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
<img src="https://cdn.simpleicons.org/redis" alt="Redis" width={14} height={14} />
|
||||
</div>
|
||||
),
|
||||
title: t`Powered by`,
|
||||
className: "flex-row-reverse",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="features" className="relative bg-secondary-accent py-24 sm:py-32">
|
||||
<div className="container">
|
||||
<div className="space-y-6 leading-loose">
|
||||
<h2 className="text-4xl font-bold">{t`Rich in features, not in pricing.`}</h2>
|
||||
<p className="max-w-4xl text-base leading-relaxed">
|
||||
{t`Reactive Resume is a passion project of over 3 years of hard work, and with that comes a number of re-iterated ideas and features that have been built to (near) perfection.`}
|
||||
</p>
|
||||
|
||||
<IconContext.Provider value={{ size: 14, weight: "bold" }}>
|
||||
<div className="!mt-12 flex flex-wrap items-center gap-4">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
viewport={{ once: true }}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
whileInView={{ opacity: 1, x: 0, transition: { delay: index * 0.1 } }}
|
||||
className={cn(featureLabel, feature.className, index > 8 && "hidden lg:flex")}
|
||||
>
|
||||
{feature.icon}
|
||||
<h4>{feature.title}</h4>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<motion.p
|
||||
viewport={{ once: true }}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
whileInView={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { delay: (features.length + 1) * 0.1 },
|
||||
}}
|
||||
>
|
||||
{t`and many more...`}
|
||||
</motion.p>
|
||||
</div>
|
||||
</IconContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -10,13 +10,14 @@ import { HeroCTA } from "./call-to-action";
|
||||
import { Decoration } from "./decoration";
|
||||
|
||||
export const HeroSection = () => (
|
||||
<section className="relative">
|
||||
<section id="hero" className="relative">
|
||||
<Decoration.Grid />
|
||||
<Decoration.Gradient />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 lg:flex lg:h-screen lg:items-center lg:px-12">
|
||||
<motion.div
|
||||
className="mx-auto max-w-3xl shrink-0 lg:mx-0 lg:max-w-xl lg:pt-8"
|
||||
viewport={{ once: true }}
|
||||
initial={{ opacity: 0, x: -100 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
>
|
||||
@ -47,13 +48,17 @@ export const HeroSection = () => (
|
||||
|
||||
<div className="mx-auto mt-16 flex max-w-2xl sm:mt-24 lg:ml-10 lg:mr-0 lg:mt-0 lg:max-w-none lg:flex-none xl:ml-20">
|
||||
<div className="max-w-3xl flex-none sm:max-w-5xl lg:max-w-none">
|
||||
<motion.div initial={{ opacity: 0, x: 100 }} whileInView={{ opacity: 1, x: 0 }}>
|
||||
<motion.div
|
||||
viewport={{ once: true }}
|
||||
initial={{ opacity: 0, x: 100 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
>
|
||||
<Tilt {...defaultTiltProps}>
|
||||
<img
|
||||
width={3600}
|
||||
height={2078}
|
||||
src="/screenshots/builder.png"
|
||||
alt={t`Reactive Resume - Screenshot - Builder Screen`}
|
||||
alt="Reactive Resume - Screenshot - Builder Screen"
|
||||
className="w-[76rem] rounded-lg bg-background/5 shadow-2xl ring-1 ring-foreground/10"
|
||||
/>
|
||||
</Tilt>
|
||||
|
||||
@ -33,7 +33,7 @@ const Logo = ({ company }: LogoProps) => (
|
||||
const logoList: string[] = ["amazon", "google", "postman", "twilio", "zalando"];
|
||||
|
||||
export const LogoCloudSection = () => (
|
||||
<section className="relative py-24 sm:py-32">
|
||||
<section id="logo-cloud" className="relative py-24 sm:py-32">
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<p className="text-center text-lg leading-relaxed">
|
||||
{t`Reactive Resume has helped people land jobs at these great companies:`}
|
||||
|
||||
59
apps/client/src/pages/home/sections/sample-resumes/index.tsx
Normal file
59
apps/client/src/pages/home/sections/sample-resumes/index.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { t } from "@lingui/macro";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const resumes = [
|
||||
"/sample-resumes/ditto",
|
||||
"/sample-resumes/ditto",
|
||||
"/sample-resumes/ditto",
|
||||
"/sample-resumes/ditto",
|
||||
];
|
||||
|
||||
export const SampleResumesSection = () => (
|
||||
<section id="sample-resumes" className="relative py-24 sm:py-32">
|
||||
<div className="container flex flex-col gap-12 lg:min-h-[600px] lg:flex-row lg:items-start">
|
||||
<div className="space-y-4 lg:mt-16 lg:basis-96">
|
||||
<h2 className="text-4xl font-bold">{t`Sample Resumes`}</h2>
|
||||
<p className="text-base leading-relaxed">
|
||||
{t`Have a look at some of the resume created to showcase the templates available on Reactive Resume. They also serve some great examples to help guide the creation of your own resume.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full overflow-hidden lg:absolute lg:right-0 lg:max-w-[55%]">
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, -400],
|
||||
transition: {
|
||||
x: {
|
||||
duration: 30,
|
||||
repeat: Infinity,
|
||||
repeatType: "mirror",
|
||||
},
|
||||
},
|
||||
}}
|
||||
className="flex items-center gap-x-6"
|
||||
>
|
||||
{resumes.map((resume, index) => (
|
||||
<motion.a
|
||||
key={index}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`${resume}.pdf`}
|
||||
className="max-w-none flex-none"
|
||||
viewport={{ once: true }}
|
||||
initial={{ opacity: 0, x: -100 }}
|
||||
whileInView={{ opacity: 1, x: 0, transition: { delay: (index + 1) * 0.5 } }}
|
||||
>
|
||||
<img
|
||||
alt={resume}
|
||||
src={`${resume}.jpg`}
|
||||
className=" aspect-[1/1.4142] h-[400px] rounded object-cover lg:h-[600px]"
|
||||
/>
|
||||
</motion.a>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 hidden w-1/2 bg-gradient-to-r from-background to-transparent lg:block" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@ -1,40 +1,16 @@
|
||||
import { animate, motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { animate, motion, useInView } from "framer-motion";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type CounterProps = { from: number; to: number };
|
||||
|
||||
export const Counter = ({ from, to }: CounterProps) => {
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
const nodeRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const isInView = useInView(nodeRef, { once: true });
|
||||
|
||||
useEffect(() => {
|
||||
const node = nodeRef.current;
|
||||
|
||||
if (!node) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
|
||||
return () => {
|
||||
observer.unobserve(node);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return;
|
||||
|
||||
const node = nodeRef.current;
|
||||
if (!node) return;
|
||||
if (!isInView || !node) return;
|
||||
|
||||
const controls = animate(from, to, {
|
||||
duration: 1,
|
||||
@ -51,7 +27,7 @@ export const Counter = ({ from, to }: CounterProps) => {
|
||||
ref={nodeRef}
|
||||
transition={{ duration: 0.5 }}
|
||||
initial={{ opacity: 0, scale: 0.1 }}
|
||||
animate={isInView ? { opacity: 1, scale: 1 } : {}}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -15,7 +15,7 @@ export const StatisticsSection = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="relative py-24 sm:py-32">
|
||||
<section id="statistics" className="relative py-24 sm:py-32">
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<dl className="grid grid-cols-1 gap-x-8 gap-y-16 text-center lg:grid-cols-3">
|
||||
{stats.map((stat, index) => (
|
||||
|
||||
88
apps/client/src/pages/home/sections/support/index.tsx
Normal file
88
apps/client/src/pages/home/sections/support/index.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
export const SupportSection = () => (
|
||||
<section
|
||||
id="support"
|
||||
className="relative space-y-12 bg-secondary-accent py-24 text-primary sm:py-32"
|
||||
>
|
||||
<div className="container space-y-6">
|
||||
<h1 className="text-4xl font-bold">{t`Supporting Reactive Resume`}</h1>
|
||||
|
||||
<p className="max-w-4xl leading-loose">
|
||||
{t`Reactive Resume is a free and open-source project crafted mostly by me, and your support would be greatly appreciated. If you're inclined to contribute, and only if you can afford to, consider making a donation through any of the listed platforms. Additionally, donations to Reactive Resume through Open Collective are tax-exempt, as the project is fiscally hosted by Open Collective Europe.`}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-x-10">
|
||||
<a
|
||||
href="https://github.com/sponsors/AmruthPillai"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
src="/support-logos/github-sponsors-light.svg"
|
||||
className="hidden max-h-[42px] dark:block"
|
||||
// eslint-disable-next-line lingui/no-unlocalized-strings
|
||||
alt="GitHub Sponsors"
|
||||
/>
|
||||
<img
|
||||
src="/support-logos/github-sponsors-dark.svg"
|
||||
className="block max-h-[42px] dark:hidden"
|
||||
// eslint-disable-next-line lingui/no-unlocalized-strings
|
||||
alt="GitHub Sponsors"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="https://opencollective.com/Reactive-Resume"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
src="/support-logos/open-collective-light.svg"
|
||||
className="hidden max-h-[38px] dark:block"
|
||||
// eslint-disable-next-line lingui/no-unlocalized-strings
|
||||
alt="Open Collective"
|
||||
/>
|
||||
<img
|
||||
src="/support-logos/open-collective-dark.svg"
|
||||
className="block max-h-[38px] dark:hidden"
|
||||
// eslint-disable-next-line lingui/no-unlocalized-strings
|
||||
alt="Open Collective"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://paypal.me/amruthde" rel="noreferrer noopener nofollow" target="_blank">
|
||||
{/* eslint-disable-next-line lingui/no-unlocalized-strings */}
|
||||
<img src="/support-logos/paypal.svg" className=" max-h-[28px]" alt="PayPal" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="max-w-4xl leading-loose">
|
||||
{t`If you're multilingual, we'd love your help in bringing the app to more languages and
|
||||
communities. Don't worry if you don't see your language on the list - just give me a
|
||||
shout-out on GitHub, and I'll make sure to include it. Ready to get started? Jump into
|
||||
translation over at Crowdin by clicking the link below.`}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-x-10">
|
||||
<img
|
||||
src="/support-logos/crowdin-light.svg"
|
||||
className="hidden max-h-[32px] dark:block"
|
||||
// eslint-disable-next-line lingui/no-unlocalized-strings
|
||||
alt="Crowdin"
|
||||
/>
|
||||
<img
|
||||
src="/support-logos/crowdin-dark.svg"
|
||||
className="block max-h-[32px] dark:hidden"
|
||||
// eslint-disable-next-line lingui/no-unlocalized-strings
|
||||
alt="Crowdin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="max-w-4xl leading-loose">
|
||||
{t`Even if you're not in a position to contribute financially, you can still make a difference by
|
||||
giving the GitHub repository a star, spreading the word to your friends, or dropping a quick
|
||||
message to let me know how Reactive Resume has helped you. Your feedback and support are
|
||||
always welcome and much appreciated!`}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
106
apps/client/src/pages/home/sections/testimonials/index.tsx
Normal file
106
apps/client/src/pages/home/sections/testimonials/index.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
/* eslint-disable lingui/text-restrictions */
|
||||
/* eslint-disable lingui/no-unlocalized-strings */
|
||||
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { Quotes } from "@phosphor-icons/react";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const email = "hello@amruthpillai.com";
|
||||
|
||||
type Testimonial = {
|
||||
quote: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const testimonials: Testimonial[][] = [
|
||||
[
|
||||
{
|
||||
name: "N. Elnour",
|
||||
quote:
|
||||
"This is really a thank you for Reactive Resume. Drafting resumes was never a strength of mine, so your app really made the whole process easy and smooth!",
|
||||
},
|
||||
{
|
||||
name: "S. Bhaije",
|
||||
quote:
|
||||
"Hi Amruth! First off, many thanks for making RxResume! This is one of the best resume-building tools I have ever found. Have also recommended it to many of my university friends...",
|
||||
},
|
||||
{
|
||||
name: "K. Lietzau",
|
||||
quote:
|
||||
"Hi, I just found your resume builder, and I just want to say, I really appreciate it! The moment I saw it was open source, I closed all the other CV sites I was considering. Thank you for your service.",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: "R. Sinnot",
|
||||
quote:
|
||||
"Hey, Just wanted to let you know you not only helped me get a job, you helped my partner and my childhood friend, who then used your site to help one of her friends get a job. I sponsored you on Github to give back a bit but I wanted to let you know you really made a difference with your resume builder.",
|
||||
},
|
||||
{
|
||||
name: "P. Jignesh",
|
||||
quote:
|
||||
"Hey, I am a Mechanical engineer, not understand coding, messy AI, and computer systems, But wait, what drags me here is your creativity, Your website RxResume is all good! using it and the efforts you made to keep this free is remarkable. keeping doing great work.",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: "A. Rehman",
|
||||
quote:
|
||||
"Hey Amruth, I have loved your Reactive Resume Website. Thank you so much for making this kind of thing.",
|
||||
},
|
||||
{
|
||||
name: "S. Innocent",
|
||||
quote:
|
||||
"First of all, I appreciate your effort for making reactive resume a free tool for the community. Very much better than many premium resume builder...",
|
||||
},
|
||||
{
|
||||
name: "M. Fritza",
|
||||
quote:
|
||||
"Hello sir, I just wanted to write a thank you message for developing RxResume. It's easy to use, intuitive and it's much more practical than many others that made you pay up after spending an hour to create your CV. I'll be sure to buy you a coffee after I get my first job. I wish you everything best in life!",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
export const TestimonialsSection = () => (
|
||||
<section id="testimonials" className="container relative space-y-12 py-24 sm:py-32">
|
||||
<div className="space-y-6 text-center">
|
||||
<h1 className="text-4xl font-bold">{t`Testimonials`}</h1>
|
||||
<p className="mx-auto max-w-2xl leading-relaxed">
|
||||
<Trans>
|
||||
I always love to hear from the users of Reactive Resume with feedback or support. Here are
|
||||
some of the messages I've received. If you have any feedback, feel free to drop me an
|
||||
email at{" "}
|
||||
<a href={email} className="underline">
|
||||
{email}
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3 lg:gap-y-0">
|
||||
{testimonials.map((columnGroup, groupIndex) => (
|
||||
<div key={groupIndex} className="space-y-8">
|
||||
{columnGroup.map((testimonial, index) => (
|
||||
<motion.figure
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: -100 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.25 } }}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg bg-secondary-accent p-5 text-primary shadow-lg",
|
||||
index > 0 && "hidden lg:block",
|
||||
)}
|
||||
>
|
||||
<Quotes size={64} className="absolute -right-3 bottom-0 opacity-20" />
|
||||
<blockquote className="italic leading-relaxed">
|
||||
“{testimonial.quote}”
|
||||
</blockquote>
|
||||
<figcaption className="mt-3 font-medium">{testimonial.name}</figcaption>
|
||||
</motion.figure>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@ -1,34 +1,44 @@
|
||||
import "@/client/libs/dayjs";
|
||||
|
||||
import { i18n } from "@lingui/core";
|
||||
import { detect, fromNavigator, fromUrl } from "@lingui/detect-locale";
|
||||
import { detect, fromNavigator, fromStorage, fromUrl } from "@lingui/detect-locale";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import get from "lodash.get";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { defaultLocale, dynamicActivate } from "../libs/lingui";
|
||||
import { updateUser } from "../services/user";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { useResumeStore } from "../stores/resume";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LocaleProvider = ({ children }: Props) => {
|
||||
const userLocale = useAuthStore((state) => get(state.user, "locale", null));
|
||||
const resumeLocale = useResumeStore((state) => get(state.resume, "data.metadata.locale", null));
|
||||
const userLocale = useAuthStore((state) => state.user?.locale);
|
||||
|
||||
useEffect(() => {
|
||||
const detectedLocale = detect(
|
||||
resumeLocale,
|
||||
userLocale,
|
||||
fromUrl("lang"),
|
||||
fromUrl("locale"),
|
||||
fromStorage("locale"),
|
||||
fromNavigator(),
|
||||
userLocale,
|
||||
defaultLocale,
|
||||
)!;
|
||||
|
||||
dynamicActivate(detectedLocale);
|
||||
}, [userLocale, resumeLocale]);
|
||||
}, [userLocale]);
|
||||
|
||||
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
|
||||
};
|
||||
|
||||
export const changeLanguage = async (locale: string) => {
|
||||
// Update locale in local storage
|
||||
window.localStorage.setItem("locale", locale);
|
||||
|
||||
// Update locale in user profile, if authenticated
|
||||
const state = useAuthStore.getState();
|
||||
if (state.user) await updateUser({ locale }).catch(() => null);
|
||||
|
||||
// Reload the page for language switch to take effect
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
@ -12,7 +12,7 @@ export * from "./email-verification/verify-email";
|
||||
export * from "./password-recovery/forgot-password";
|
||||
export * from "./password-recovery/reset-password";
|
||||
|
||||
// Two Factor Authentication
|
||||
// Two-Factor Authentication
|
||||
export * from "./two-factor-authentication/backup-otp";
|
||||
export * from "./two-factor-authentication/disable";
|
||||
export * from "./two-factor-authentication/enable";
|
||||
|
||||
41
apps/client/src/services/resume/contributors.ts
Normal file
41
apps/client/src/services/resume/contributors.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { ContributorDto } from "@reactive-resume/dto";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { axios } from "@/client/libs/axios";
|
||||
|
||||
export const fetchGitHubContributors = async () => {
|
||||
const response = await axios.get<ContributorDto[]>(`/contributors/github`);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchCrowdinContributors = async () => {
|
||||
const response = await axios.get<ContributorDto[]>(`/contributors/crowdin`);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useContributors = () => {
|
||||
const {
|
||||
error: githubError,
|
||||
isPending: githubLoading,
|
||||
data: github,
|
||||
} = useQuery({
|
||||
queryKey: ["contributors", "github"],
|
||||
queryFn: fetchGitHubContributors,
|
||||
});
|
||||
|
||||
const {
|
||||
error: crowdinError,
|
||||
isPending: crowdinLoading,
|
||||
data: crowdin,
|
||||
} = useQuery({
|
||||
queryKey: ["contributors", "crowdin"],
|
||||
queryFn: fetchCrowdinContributors,
|
||||
});
|
||||
|
||||
const error = githubError || crowdinError;
|
||||
const loading = githubLoading || crowdinLoading;
|
||||
|
||||
return { github, crowdin, loading, error };
|
||||
};
|
||||
23
apps/client/src/services/resume/translation.ts
Normal file
23
apps/client/src/services/resume/translation.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { LanguageDto } from "@reactive-resume/dto";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { axios } from "@/client/libs/axios";
|
||||
|
||||
export const fetchLanguages = async () => {
|
||||
const response = await axios.get<LanguageDto[]>(`/translation/languages`);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useLanguages = () => {
|
||||
const {
|
||||
error,
|
||||
isPending: loading,
|
||||
data: languages,
|
||||
} = useQuery({
|
||||
queryKey: ["translation", "languages"],
|
||||
queryFn: fetchLanguages,
|
||||
});
|
||||
|
||||
return { languages: languages ?? [], loading, error };
|
||||
};
|
||||
Reference in New Issue
Block a user