feat(homepage): add new sections to homepage

This commit is contained in:
Amruth Pillai
2023-11-13 17:03:41 +01:00
parent 4b1e33db80
commit d18b258761
79 changed files with 3096 additions and 313 deletions

View File

@ -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)}
/>
);

View 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>
);
};

View File

@ -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)}
/>
);

View File

@ -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 });
}

File diff suppressed because it is too large Load Diff

View File

@ -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"
/>

View File

@ -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);

View File

@ -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 />

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
);

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@ -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>

View File

@ -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:`}

View 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>
);

View File

@ -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 }}
/>
);
};

View File

@ -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) => (

View 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>
);

View 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">
&ldquo;{testimonial.quote}&rdquo;
</blockquote>
<figcaption className="mt-3 font-medium">{testimonial.name}</figcaption>
</motion.figure>
))}
</div>
))}
</div>
</section>
);

View File

@ -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();
};

View File

@ -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";

View 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 };
};

View 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 };
};