Load locale ondemand + spanish locale (#249)

This commit is contained in:
Luke Vella 2022-07-28 10:39:58 +01:00 committed by GitHub
parent 0f35bd0518
commit c2aea134ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 700 additions and 455 deletions

View file

@ -2,6 +2,7 @@ import "react-i18next";
import app from "~/public/locales/en/app.json";
import common from "~/public/locales/en/common.json";
import errors from "~/public/locales/en/errors.json";
import homepage from "~/public/locales/en/homepage.json";
declare module "next-i18next" {
@ -9,5 +10,6 @@ declare module "next-i18next" {
homepage: typeof homepage;
app: typeof app;
common: typeof common;
errors: typeof errors;
}
}

View file

@ -3,7 +3,7 @@ const path = require("path");
module.exports = {
i18n: {
defaultLocale: "en",
locales: ["en", "de", "fr", "sv"],
locales: ["en", "es", "de", "fr", "sv"],
localePath: path.resolve("./public/locales"),
reloadOnPrerender: process.env.NODE_ENV === "development",
},

View file

@ -17,5 +17,9 @@
"starOnGithub": "Star us on Github",
"support": "Support",
"swedish": "Swedish",
"volunteerTranslator": "Help translate this site"
"volunteerTranslator": "Help translate this site",
"notFoundTitle": "404 not found",
"notFoundDescription": "We couldn't find the page you're looking for.",
"goToHome": "Go to home",
"startChat": "Start chat"
}

View file

@ -0,0 +1,6 @@
{
"notFoundTitle": "404 not found",
"notFoundDescription": "We couldn't find the page you're looking for.",
"goToHome": "Go to home",
"startChat": "Start chat"
}

128
public/locales/es/app.json Normal file
View file

@ -0,0 +1,128 @@
{
"12h": "12h",
"24h": "24h",
"addParticipant": "Añadir participante",
"addTimeOption": "Añadir hora",
"alreadyVoted": "Ya has votado",
"applyToAllDates": "Aplicar a todas las fechas",
"areYouSure": "¿Estás seguro?",
"back": "Volver",
"calendarHelp": "No puedes crear una encuesta sin ninguna opción. Añada al menos una opción para continuar.",
"calendarHelpTitle": "¿Olvidaste algo?",
"cancel": "Cancelar",
"comment": "Comentario",
"commentPlaceholder": "Dejar un comentario en esta encuesta (visible para todos)",
"comments": "Comentarios",
"continue": "Continuar",
"copied": "Copiado",
"copyLink": "Copiar enlace",
"createdBy": "de <b>{{name}}</b>",
"createPoll": "Crear encuesta",
"creatingDemo": "Creando una encuesta de demostración…",
"delete": "Borrar",
"deleteComment": "Borrar comentario",
"deleteDate": "Borrar fecha",
"deletedPoll": "Encuesta borrada",
"deletedPollInfo": "Esta encuesta ya no existe.",
"deletePoll": "Borrar encuesta",
"deletePollDescription": "Todos los datos relacionados con esta encuesta se eliminarán. Para confirmar, por favor escribe <s>“{{confirmText}}”</s> en el campo siguiente:",
"deletingOptionsWarning": "Estás eliminando opciones por las que algunos participantes han votado. También se eliminarán sus votos.",
"demoPollNotice": "Las encuestas de demostración se eliminan automáticamente después de 1 día",
"description": "Descripción",
"descriptionPlaceholder": "¡Hola a todos, por favor elijan las fechas que les convengan!",
"donate": "Donar",
"editDetails": "Editar detalles",
"editOptions": "Editar opciones",
"email": "Correo electrónico",
"emailPlaceholder": "jessie.smith@email.com",
"endingGuestSessionNotice": "Una vez que finalices la sesión de visitante, no se puede reanudar. No podrás editar ningún voto o comentario que hayas hecho con esta sesión.",
"endSession": "Cerrar Sesión",
"errorCreate": "¡Oh! Hubo un problema al crear tu encuesta. El error ha sido registrado y vamos a intentar arreglarlo.",
"exportToCsv": "Exportar a CSV",
"finish": "Finalizar",
"forgetMe": "Olvídame",
"goToAdmin": "Ir al panel de administración",
"guest": "Visitante",
"guestSessionNotice": "Estás usando una sesión de visitante. Esto nos permite reconocerte si vuelves más tarde para que puedas editar tus votos.",
"guestSessionReadMore": "Lee más sobre sesiones de visitantes.",
"hide": "Ocultar",
"ifNeedBe": "Si es necesario",
"linkHasExpired": "Tu enlace ha expirado o ya no es válido",
"loading": "Cargando…",
"loadingParticipants": "Cargando participantes…",
"location": "Ubicación",
"locationPlaceholder": "Café de Carlos",
"lockPoll": "Bloquear encuesta",
"login": "Iniciar sesión",
"loginCheckInbox": "Por favor, revisa tus correos electrónicos.",
"loginMagicLinkSent": "Se ha enviado un enlace mágico a:",
"loginSendMagicLink": "Enviarme un enlace mágico",
"loginViaMagicLink": "Iniciar sesión a través de un enlace mágico",
"loginViaMagicLinkDescription": "Te enviaremos un correo electrónico con un enlace mágico que puedes usar para iniciar sesión.",
"loginWithValidEmail": "Por favor ingresa un correo electrónico válido",
"logout": "Cerrar sesión",
"manage": "Gestionar",
"menu": "Menú",
"mixedOptionsDescription": "No puedes tener opciones de fecha y opciones de hora en la misma encuesta. ¿Cuáles quieres mantener?",
"mixedOptionsKeepDates": "Mantener las opciones de fecha",
"mixedOptionsKeepTimes": "Mantener las opciones de hora",
"mixedOptionsTitle": "Aguanta un minuto…🤔",
"monday": "Lunes",
"monthView": "Vista mensual",
"name": "Nombre",
"namePlaceholder": "Jessie Smith",
"newPoll": "Nueva encuesta",
"next": "Siguiente",
"nextMonth": "Siguiente mes",
"no": "No",
"noDatesSelected": "Ninguna fecha seleccionada",
"notificationsDisabled": "Las notificaciones han sido desactivadas",
"notificationsOff": "Las notificaciones están desactivadas",
"notificationsOn": "Las notificaciones están activadas",
"notificationsOnDescription": "Vamos a mandar un correo electrónico a <b>{{email}}</b> cuando haya actividad en esta encuesta.",
"notificationsVerifyEmail": "Tienes que verificar tu correo electrónico para activar las notificaciones",
"ok": "Aceptar",
"options": "Opciones",
"participant": "Participante",
"participantCount_other": "{{count}} participantes",
"participantCount": "{{count}} participante",
"pollHasBeenLocked": "Esta encuesta ha sido bloqueada",
"pollHasBeenVerified": "Esta encuesta ha sido verificada",
"pollOwnerNotice": "Hola {{name}}, parece que eres el dueño de esta encuesta.",
"pollsEmpty": "Ninguna encuesta creada",
"possibleAnswers": "Respuestas posibles",
"preferences": "Ajustes",
"previousMonth": "Mes anterior",
"profileLogin": "Perfil - Iniciar sesión",
"profileUser": "Perfil - {{username}}",
"requiredNameError": "El nombre es obligatorio",
"save": "Guardar",
"saveInstruction": "Selecciona tu disponibilidad y haz clic en <b>{{save}}</b>",
"share": "Compartir",
"shareDescription": "Dale este enlace a tus <b>participantes</b> para permitirles votar en tu encuesta.",
"shareLink": "Compartir con un enlace",
"specifyTimes": "Especificar tiempos",
"specifyTimesDescription": "Incluir horas de inicio y fin para cada opción",
"stepSummary": "Paso {{current}} de {{total}}",
"sunday": "Domingo",
"timeAndDate": "Fecha y hora",
"timeFormat": "Formato de hora:",
"timeZone": "Zona horaria:",
"title": "Título",
"titlePlaceholder": "Reunión mensual",
"today": "Hoy",
"unlockPoll": "Desbloquear encuesta",
"unverifiedMessage": "Se ha enviado un correo electrónico a <b>{{email}}</b> con un enlace para verificar la dirección de correo electrónico.",
"user": "Usuario",
"vote": "Votar",
"voteCount_other": "{{count}} votos",
"voteCount": "{{count}} voto",
"weekStartsOn": "La semana comienza el",
"weekView": "Vista semanal",
"whatsThis": "¿Qué es esto?",
"yes": "Sí",
"you": "Tú",
"yourDetails": "Tus datos",
"yourName": "Tu nombre…",
"yourPolls": "Tus encuestas"
}

View file

@ -0,0 +1,21 @@
{
"blog": "Blog",
"discussions": "Discusiones",
"donate": "Donar",
"english": "Inglés",
"footerCredit": "Creado por <a>@imlukevella</a>",
"footerSponsor": "Este proyecto está financiado por los usuarios. Por favor, considera apoyarlo <a>donando</a>.",
"french": "Francés",
"german": "Alemán",
"home": "Inicio",
"italian": "Italiano",
"language": "Idioma",
"links": "Enlaces",
"poweredBy": "Con tecnología de",
"privacyPolicy": "Política de privacidad",
"spanish": "Español",
"starOnGithub": "Seguir el proyecto en GitHub",
"support": "Soporte",
"swedish": "Sueco",
"volunteerTranslator": "Ayuda a traducir esta página"
}

View file

@ -0,0 +1,36 @@
{
"3Ls": "Sí—con 3 <e>L</e>s",
"adFree": "Sin anuncios",
"adFreeDescription": "Puedes darle un descanso a tu bloqueador de anuncios — No lo necesitarás aquí.",
"comments": "Comentarios",
"commentsDescription": "Los participantes pueden comentar en tu encuesta y los comentarios serán visibles para todos.",
"features": "Características",
"featuresSubheading": "Programar reuniones, de una manera inteligente",
"follow": "Seguir",
"getStarted": "Empezar",
"heroSubText": "Encuentra la fecha correcta sin dar vueltas",
"heroText": "Programar<br/><s>reuniones</s><br />con facilidad",
"links": "Enlaces",
"liveDemo": "Demostración en vivo",
"metaDescription": "Crea encuestas y vota para encontrar el mejor día o la mejor hora. Una alternativa gratuita a Doodle.",
"metaTitle": "Rallly - Programar reuniones",
"mobileFriendly": "Optimizado para dispositivos móviles",
"mobileFriendlyDescription": "Funciona muy bien en dispositivos móviles para que los participantes puedan responder a las encuestas dondequiera que estén.",
"new": "Nuevo",
"noLoginRequired": "No es necesario iniciar sesión",
"noLoginRequiredDescription": "No es necesario iniciar sesión para crear o participar en una encuesta",
"notifications": "Notificaciones",
"notificationsDescription": "Sabe quién ha respondido. Recibe notificaciones cuando los participantes voten o comenten en tu encuesta.",
"openSource": "Código abierto",
"openSourceDescription": "Ese proyecto es completamente de código abierto y <a>disponible en GitHub</a>.",
"participant": "Participante",
"participantCount_other": "{{count}} participantes",
"participantCount": "{{count}} participante",
"perfect": "¡Perfecto!",
"principles": "Principios",
"principlesSubheading": "No somos como los otros",
"selfHostable": "Autoalojable",
"selfHostableDescription": "Ejecútalo en tu propio servidor para tener el control total de tus datos",
"timeSlots": "Intervalos de tiempo",
"timeSlotsDescription": "Establece la hora de inicio y fin individual para cada opción de tu encuesta. Los tiempos se pueden ajustar automáticamente a la zona horaria de cada participante o pueden ser ajustados para ignorar completamente las zonas horarias."
}

View file

@ -1,11 +1,11 @@
import clsx from "clsx";
import dayjs from "dayjs";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslation } from "next-i18next";
import { usePlausible } from "next-plausible";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useDayjs } from "../../utils/dayjs";
import { requiredString } from "../../utils/form-validation";
import { trpc } from "../../utils/trpc";
import { Button } from "../button";
@ -25,6 +25,7 @@ interface CommentForm {
}
const Discussion: React.VoidFunctionComponent = () => {
const { dayjs } = useDayjs();
const queryClient = trpc.useContext();
const { t } = useTranslation("app");
const { poll } = usePoll();

View file

@ -1,5 +1,6 @@
import Head from "next/head";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { Button } from "@/components/button";
@ -19,6 +20,7 @@ const ErrorPage: React.VoidFunctionComponent<ComponentProps> = ({
title,
description,
}) => {
const { t } = useTranslation("errors");
return (
<div className="mx-auto flex h-full max-w-full items-center justify-center bg-gray-50 px-4 py-8 lg:w-[1024px]">
<Head>
@ -28,16 +30,16 @@ const ErrorPage: React.VoidFunctionComponent<ComponentProps> = ({
<div className="flex items-start">
<div className="text-center">
<Icon className="mb-4 inline-block w-24 text-slate-400" />
<div className="text-primary-500 mb-2 text-3xl font-bold ">
<div className="mb-2 text-3xl font-bold text-primary-500 ">
{title}
</div>
<p>{description}</p>
<div className="flex justify-center space-x-3">
<Link href="/" passHref={true}>
<a className="btn-default">Go to home</a>
<a className="btn-default">{t("goToHome")}</a>
</Link>
<Button icon={<Chat />} onClick={showCrispChat}>
Start chat
{t("startChat")}
</Button>
</div>
</div>

View file

@ -1,16 +1,14 @@
import clsx from "clsx";
import dayjs from "dayjs";
import { useTranslation } from "next-i18next";
import { usePlausible } from "next-plausible";
import * as React from "react";
import { usePreferences } from "@/components/preferences/use-preferences";
import {
expectTimeOption,
getDateProps,
removeAllOptionsForDay,
} from "../../../../utils/date-time-utils";
import { useDayjs } from "../../../../utils/dayjs";
import { Button } from "../../../button";
import CompactButton from "../../../compact-button";
import DateCard from "../../../date-card";
@ -38,6 +36,7 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
duration,
onChangeDuration,
}) => {
const { dayjs, weekStartsOn } = useDayjs();
const { t } = useTranslation("app");
const isTimedEvent = options.some((option) => option.type === "timeSlot");
@ -76,8 +75,6 @@ const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
);
}, [optionsByDay]);
const { weekStartsOn } = usePreferences();
const datepicker = useHeadlessDatePicker({
selection: datepickerSelection,
onNavigationChange: onNavigate,

View file

@ -7,11 +7,11 @@ import {
} from "@floating-ui/react-dom-interactions";
import { Listbox } from "@headlessui/react";
import clsx from "clsx";
import dayjs from "dayjs";
import * as React from "react";
import { stopPropagation } from "@/utils/stop-propagation";
import { useDayjs } from "../../../../utils/dayjs";
import ChevronDown from "../../../icons/chevron-down.svg";
import { styleMenuItem } from "../../../menu-styles";
@ -28,6 +28,7 @@ const TimePicker: React.VoidFunctionComponent<TimePickerProps> = ({
className,
startFrom,
}) => {
const { dayjs } = useDayjs();
const { reference, floating, x, y, strategy, refs } = useFloating({
strategy: "fixed",
middleware: [

View file

@ -1,18 +1,15 @@
import clsx from "clsx";
import dayjs from "dayjs";
import React from "react";
import { Calendar } from "react-big-calendar";
import { useMount } from "react-use";
import { getDuration } from "../../../utils/date-time-utils";
import { usePreferences } from "../../preferences/use-preferences";
import { useDayjs } from "../../../utils/dayjs";
import DateNavigationToolbar from "./date-navigation-toolbar";
import dayjsLocalizer from "./dayjs-localizer";
import { DateTimeOption, DateTimePickerProps } from "./types";
import { formatDateWithoutTime, formatDateWithoutTz } from "./utils";
const localizer = dayjsLocalizer(dayjs);
const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
title,
options,
@ -23,8 +20,9 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
onChangeDuration,
}) => {
const [scrollToTime, setScrollToTime] = React.useState<Date>();
const { dayjs, timeFormat } = useDayjs();
const localizer = React.useMemo(() => dayjsLocalizer(dayjs), [dayjs]);
const { timeFormat } = usePreferences();
useMount(() => {
// Bit of a hack to force rbc to scroll to the right time when we close/open a modal
setScrollToTime(dayjs(date).add(-60, "minutes").toDate());

View file

@ -1,6 +1,7 @@
import dayjs from "dayjs";
import React from "react";
import { useDayjs } from "../utils/dayjs";
interface DayProps {
date: Date;
day: string;
@ -33,6 +34,7 @@ export const useHeadlessDatePicker = (
selection: Date[];
toggle: (date: Date) => void;
} => {
const { dayjs } = useDayjs();
const [localSelection, setSelection] = React.useState<Date[]>([]);
const selection = options?.selection ?? localSelection;
const [localNavigationDate, setNavigationDate] = React.useState(today);

View file

@ -3,6 +3,7 @@ import Link from "next/link";
import { Trans, useTranslation } from "next-i18next";
import * as React from "react";
import { DayjsProvider } from "../../utils/dayjs";
import { UserAvatarProvider } from "../poll/user-avatar";
import PollDemo from "./poll-demo";
import ScribbleArrow from "./scribble-arrow.svg";
@ -43,6 +44,7 @@ const Hero: React.VoidFunctionComponent = () => {
</div>
<div className="pointer-events-none mt-24 hidden h-[380px] select-none items-end justify-center md:flex lg:mt-8 lg:ml-24">
<UserAvatarProvider seed="mock" names={names}>
<DayjsProvider>
<div className="relative inline-block">
<motion.div
className="absolute z-20 h-full rounded-2xl border-4 border-primary-500 bg-primary-200/10 shadow-md"
@ -73,6 +75,7 @@ const Hero: React.VoidFunctionComponent = () => {
<PollDemo />
</motion.div>
</div>
</DayjsProvider>
</UserAvatarProvider>
</div>
</div>

View file

@ -1,7 +1,7 @@
import dayjs from "dayjs";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { useDayjs } from "../../utils/dayjs";
import { ParticipantRowView } from "../poll/desktop-poll/participant-row";
import { ScoreSummary } from "../poll/score-summary";
@ -35,6 +35,7 @@ const options = ["2022-12-14", "2022-12-15", "2022-12-16", "2022-12-17"];
const PollDemo: React.VoidFunctionComponent = () => {
const { t } = useTranslation("homepage");
const { dayjs } = useDayjs();
return (
<div
className="rounded-lg bg-white py-1 shadow-huge"

View file

@ -12,9 +12,9 @@ import {
} from "@/utils/date-time-utils";
import { GetPollApiResponse } from "@/utils/trpc/types";
import { useDayjs } from "../utils/dayjs";
import ErrorPage from "./error-page";
import { useParticipants } from "./participants-provider";
import { usePreferences } from "./preferences/use-preferences";
import { useSession } from "./session";
import { useRequiredContext } from "./use-required-context";
@ -88,7 +88,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
[participants],
);
const { timeFormat } = usePreferences();
const { timeFormat } = useDayjs();
const contextValue = React.useMemo<PollContextValue>(() => {
const highScore = poll.options.reduce((acc, curr) => {

View file

@ -27,7 +27,6 @@ import VoteIcon from "./poll/vote-icon";
import { usePoll } from "./poll-context";
import { useSession } from "./session";
import Sharing from "./sharing";
import StandardLayout from "./standard-layout";
const Discussion = React.lazy(() => import("@/components/discussion"));
@ -120,7 +119,6 @@ const PollPage: NextPage = () => {
);
return (
<UserAvatarProvider seed={poll.id} names={names}>
<StandardLayout>
<div className="relative max-w-full py-4 md:px-4">
<Head>
<title>{poll.title}</title>
@ -240,9 +238,7 @@ const PollPage: NextPage = () => {
<div className="flex items-center space-x-3">
<span className="inline-flex items-center space-x-1">
<VoteIcon type="yes" />
<span className="text-xs text-slate-500">
{t("yes")}
</span>
<span className="text-xs text-slate-500">{t("yes")}</span>
</span>
<span className="inline-flex items-center space-x-1">
<VoteIcon type="ifNeedBe" />
@ -252,9 +248,7 @@ const PollPage: NextPage = () => {
</span>
<span className="inline-flex items-center space-x-1">
<VoteIcon type="no" />
<span className="text-xs text-slate-500">
{t("no")}
</span>
<span className="text-xs text-slate-500">{t("no")}</span>
</span>
</div>
</div>
@ -265,14 +259,11 @@ const PollPage: NextPage = () => {
</React.Suspense>
</div>
<React.Suspense
fallback={<div className="p-4">{t("loading")}</div>}
>
<React.Suspense fallback={<div className="p-4">{t("loading")}</div>}>
<Discussion />
</React.Suspense>
</div>
</div>
</StandardLayout>
</UserAvatarProvider>
);
};

View file

@ -1,18 +1,16 @@
import clsx from "clsx";
import Cookies from "js-cookie";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
export const LanguageSelect: React.VoidFunctionComponent<{
className?: string;
onChange?: (language: string) => void;
}> = ({ className, onChange }) => {
const { t } = useTranslation("common");
const router = useRouter();
const { t, i18n } = useTranslation("common");
return (
<select
className={clsx("input", className)}
defaultValue={router.locale}
defaultValue={i18n.language}
onChange={(e) => {
Cookies.set("NEXT_LOCALE", e.target.value, {
expires: 365,
@ -21,6 +19,7 @@ export const LanguageSelect: React.VoidFunctionComponent<{
}}
>
<option value="en">{t("english")}</option>
<option value="es">{t("spanish")}</option>
<option value="fr">{t("french")}</option>
<option value="de">{t("german")}</option>
<option value="sv">{t("swedish")}</option>

View file

@ -1,11 +1,12 @@
import dayjs from "dayjs";
import { useTranslation } from "next-i18next";
import { usePoll } from "@/components/poll-context";
import { useDayjs } from "../../../utils/dayjs";
import { useParticipants } from "../../participants-provider";
export const useCsvExporter = () => {
const { dayjs } = useDayjs();
const { poll, options } = usePoll();
const { t } = useTranslation("app");
const { participants } = useParticipants();

View file

@ -1,7 +1,7 @@
import dayjs from "dayjs";
import { Trans, useTranslation } from "next-i18next";
import * as React from "react";
import { useDayjs } from "../../utils/dayjs";
import Badge from "../badge";
import { usePoll } from "../poll-context";
import Tooltip from "../tooltip";
@ -9,7 +9,7 @@ import Tooltip from "../tooltip";
const PollSubheader: React.VoidFunctionComponent = () => {
const { poll } = usePoll();
const { t } = useTranslation("app");
const { dayjs } = useDayjs();
return (
<div className="text-slate-500/75 lg:text-lg">
<div className="md:inline">

View file

@ -4,15 +4,14 @@ import { useTranslation } from "next-i18next";
import { usePlausible } from "next-plausible";
import React from "react";
import { useDayjs } from "../utils/dayjs";
import { LanguageSelect } from "./poll/language-selector";
import { usePreferences } from "./preferences/use-preferences";
const Preferences: React.VoidFunctionComponent = () => {
const { t } = useTranslation(["app", "common"]);
const { weekStartsOn, setWeekStartsOn, timeFormat, setTimeFormat } =
usePreferences();
useDayjs();
const router = useRouter();
const plausible = usePlausible();

View file

@ -1,85 +0,0 @@
import dayjs from "dayjs";
import de from "dayjs/locale/de";
import en from "dayjs/locale/en";
import fr from "dayjs/locale/fr";
import sv from "dayjs/locale/sv";
import duration from "dayjs/plugin/duration";
import isBetween from "dayjs/plugin/isBetween";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import localeData from "dayjs/plugin/localeData";
import localizedFormat from "dayjs/plugin/localizedFormat";
import minMax from "dayjs/plugin/minMax";
import relativeTime from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { useRouter } from "next/router";
import * as React from "react";
import { useLocalStorage } from "react-use";
type TimeFormat = "12h" | "24h";
type StartOfWeek = "monday" | "sunday";
const dayJsLocales = {
de,
en,
fr,
sv,
};
dayjs.extend(localizedFormat);
dayjs.extend(relativeTime);
dayjs.extend(localeData);
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
dayjs.extend(minMax);
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(duration);
export const PreferencesContext =
React.createContext<{
weekStartsOn: StartOfWeek;
timeFormat: TimeFormat;
setWeekStartsOn: React.Dispatch<
React.SetStateAction<StartOfWeek | undefined>
>;
setTimeFormat: React.Dispatch<React.SetStateAction<TimeFormat | undefined>>;
} | null>(null);
PreferencesContext.displayName = "PreferencesContext";
const PreferencesProvider: React.VoidFunctionComponent<{
children?: React.ReactNode;
}> = ({ children }) => {
const [weekStartsOn = "monday", setWeekStartsOn] =
useLocalStorage<StartOfWeek>("rallly-week-starts-on");
const router = useRouter();
const userLocale = dayJsLocales[router.locale ?? "en"];
const [timeFormat = "12h", setTimeFormat] =
useLocalStorage<TimeFormat>("rallly-time-format");
dayjs.locale({
...userLocale,
weekStart: weekStartsOn === "monday" ? 1 : 0,
formats: { LT: timeFormat === "12h" ? "h:mm A" : "HH:mm" },
});
const contextValue = React.useMemo(
() => ({
weekStartsOn,
timeFormat,
setWeekStartsOn,
setTimeFormat,
}),
[setTimeFormat, setWeekStartsOn, timeFormat, weekStartsOn],
);
return (
<PreferencesContext.Provider value={contextValue}>
{children}
</PreferencesContext.Provider>
);
};
export default PreferencesProvider;

View file

@ -1,6 +0,0 @@
import { useRequiredContext } from "../use-required-context";
import { PreferencesContext } from "./preferences-provider";
export const usePreferences = () => {
return useRequiredContext(PreferencesContext);
};

View file

@ -1,4 +1,3 @@
import dayjs from "dayjs";
import Head from "next/head";
import Link from "next/link";
import { useTranslation } from "next-i18next";
@ -8,6 +7,7 @@ import Calendar from "@/components/icons/calendar.svg";
import Pencil from "@/components/icons/pencil.svg";
import User from "@/components/icons/user.svg";
import { useDayjs } from "../utils/dayjs";
import { trpc } from "../utils/trpc";
import { EmptyState } from "./empty-state";
import LoginForm from "./login-form";
@ -16,6 +16,7 @@ import { useSession } from "./session";
export const Profile: React.VoidFunctionComponent = () => {
const { user } = useSession();
const { dayjs } = useDayjs();
const { t } = useTranslation("app");
const { data: userPolls } = trpc.useQuery(["user.getPolls"]);

View file

@ -9,6 +9,7 @@ import User from "@/components/icons/user.svg";
import UserCircle from "@/components/icons/user-circle.svg";
import Logo from "~/public/logo.svg";
import { DayjsProvider } from "../utils/dayjs";
import Dropdown, { DropdownItem, DropdownProps } from "./dropdown";
import Adjustments from "./icons/adjustments.svg";
import Cash from "./icons/cash.svg";
@ -23,7 +24,7 @@ import Support from "./icons/support.svg";
import Twitter from "./icons/twitter.svg";
import LoginForm from "./login-form";
import { useModal } from "./modal";
import { useModalContext } from "./modal/modal-provider";
import ModalProvider, { useModalContext } from "./modal/modal-provider";
import Popover from "./popover";
import Preferences from "./preferences";
import { useSession } from "./session";
@ -244,6 +245,8 @@ const StandardLayout: React.VoidFunctionComponent<{
});
return (
<ModalProvider>
<DayjsProvider>
<div
className="relative flex min-h-full flex-col bg-gray-50 lg:flex-row"
{...rest}
@ -276,7 +279,9 @@ const StandardLayout: React.VoidFunctionComponent<{
trigger={
<button className="group flex w-full items-center space-x-3 whitespace-nowrap rounded-md px-3 py-1 font-medium text-slate-600 transition-colors hover:bg-slate-500/10 hover:text-slate-600 hover:no-underline active:bg-slate-500/20">
<Adjustments className="h-5 opacity-75 group-hover:text-primary-500 group-hover:opacity-100" />
<span className="grow text-left">{t("app:preferences")}</span>
<span className="grow text-left">
{t("app:preferences")}
</span>
<DotsVertical className="h-4 text-slate-500 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
}
@ -303,7 +308,11 @@ const StandardLayout: React.VoidFunctionComponent<{
<motion.button
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -20, opacity: 0, transition: { duration: 0.2 } }}
exit={{
x: -20,
opacity: 0,
transition: { duration: 0.2 },
}}
className="group w-full rounded-lg p-2 px-3 text-left text-inherit transition-colors hover:bg-slate-500/10 active:bg-slate-500/20"
>
<div className="flex w-full items-center space-x-3">
@ -392,6 +401,8 @@ const StandardLayout: React.VoidFunctionComponent<{
</div>
</div>
</div>
</DayjsProvider>
</ModalProvider>
);
};

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
const supportedLocales = ["en", "de", "fr", "sv"];
const supportedLocales = ["en", "es", "de", "fr", "sv"];
export function middleware({ headers, cookies, nextUrl }: NextRequest) {
const locale =

View file

@ -1,16 +1,28 @@
import { GetStaticProps, NextPage } from "next";
import { useTranslation } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import React from "react";
import ErrorPage from "@/components/error-page";
import DocumentSearch from "@/components/icons/document-search.svg";
const Custom404: React.VoidFunctionComponent = () => {
const Custom404: NextPage = () => {
const { t } = useTranslation("errors");
return (
<ErrorPage
icon={DocumentSearch}
title="404 not found"
description="We couldn't find the page you're looking for."
title={t("notFoundTitle")}
description={t("notFoundDescription")}
/>
);
};
export const getStaticProps: GetStaticProps = async ({ locale = "en" }) => {
return {
props: {
...(await serverSideTranslations(locale, ["errors"])),
},
};
};
export default Custom404;

View file

@ -14,8 +14,6 @@ import { MutationCache } from "react-query";
import superjson from "superjson";
import Maintenance from "@/components/maintenance";
import ModalProvider from "@/components/modal/modal-provider";
import PreferencesProvider from "@/components/preferences/preferences-provider";
import { AppRouter } from "./api/trpc/[trpc]";
@ -35,16 +33,12 @@ const MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
selfHosted={true}
enabled={!!process.env.PLAUSIBLE_DOMAIN}
>
<PreferencesProvider>
<Head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<CrispChat />
<Toaster />
<ModalProvider>
<Component {...pageProps} />
</ModalProvider>
</PreferencesProvider>
</PlausibleProvider>
);
};

View file

@ -9,6 +9,7 @@ import { PollContextProvider } from "@/components/poll-context";
import { withSession } from "@/components/session";
import { ParticipantsProvider } from "../components/participants-provider";
import StandardLayout from "../components/standard-layout";
import { withSessionSsr } from "../utils/auth";
import { trpc } from "../utils/trpc";
import { withPageTranslations } from "../utils/with-page-translations";
@ -34,11 +35,13 @@ const PollPageLoader: NextPage = () => {
if (poll) {
return (
<StandardLayout>
<ParticipantsProvider pollId={poll.id}>
<PollContextProvider poll={poll} urlId={urlId} admin={admin}>
<PollPage />
</PollContextProvider>
</ParticipantsProvider>
</StandardLayout>
);
}
@ -50,7 +53,7 @@ const PollPageLoader: NextPage = () => {
};
export const getServerSideProps: GetServerSideProps = withSessionSsr(
withPageTranslations(["common", "app"]),
withPageTranslations(["common", "app", "errors"]),
);
export default withSession(PollPageLoader);

123
src/utils/dayjs.tsx Normal file
View file

@ -0,0 +1,123 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import isBetween from "dayjs/plugin/isBetween";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import localeData from "dayjs/plugin/localeData";
import localizedFormat from "dayjs/plugin/localizedFormat";
import minMax from "dayjs/plugin/minMax";
import relativeTime from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { useAsync, useLocalStorage } from "react-use";
import { useRequiredContext } from "../components/use-required-context";
export type TimeFormat = "12h" | "24h";
export type StartOfWeek = "monday" | "sunday";
const dayjsLocales: Record<
string,
{
weekStartsOn: StartOfWeek;
timeFormat: TimeFormat;
import: () => Promise<ILocale>;
}
> = {
en: {
weekStartsOn: "monday",
timeFormat: "12h",
import: () => import("dayjs/locale/en"),
},
es: {
weekStartsOn: "monday",
timeFormat: "24h",
import: () => import("dayjs/locale/es"),
},
de: {
weekStartsOn: "monday",
timeFormat: "24h",
import: () => import("dayjs/locale/de"),
},
fr: {
weekStartsOn: "monday",
timeFormat: "24h",
import: () => import("dayjs/locale/fr"),
},
sv: {
weekStartsOn: "monday",
timeFormat: "24h",
import: () => import("dayjs/locale/sv"),
},
};
dayjs.extend(localizedFormat);
dayjs.extend(relativeTime);
dayjs.extend(localeData);
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
dayjs.extend(minMax);
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(duration);
const DayjsContext =
React.createContext<{
dayjs: (date?: dayjs.ConfigType) => dayjs.Dayjs;
weekStartsOn: StartOfWeek;
timeFormat: TimeFormat;
setWeekStartsOn: React.Dispatch<
React.SetStateAction<StartOfWeek | undefined>
>;
setTimeFormat: React.Dispatch<React.SetStateAction<TimeFormat | undefined>>;
} | null>(null);
export const useDayjs = () => {
return useRequiredContext(DayjsContext);
};
export const DayjsProvider: React.VoidFunctionComponent<{
children?: React.ReactNode;
}> = ({ children }) => {
const { i18n } = useTranslation();
// Using language instead of router.locale because when transitioning from homepage to
// the app via <Link locale={false}> it will be set to "en" instead of the current locale.
const locale = i18n.language;
const [weekStartsOn = dayjsLocales[locale].weekStartsOn, , setWeekStartsOn] =
useLocalStorage<StartOfWeek>("rallly-week-starts-on");
const [timeFormat = dayjsLocales[locale].timeFormat, setTimeFormat] =
useLocalStorage<TimeFormat>("rallly-time-format");
const { value: dayjsLocale } = useAsync(async () => {
return await dayjsLocales[locale ?? "en"].import();
}, [locale]);
if (dayjsLocale) {
dayjs.locale({
...dayjsLocale,
weekStart: weekStartsOn ? (weekStartsOn === "monday" ? 1 : 0) : undefined,
formats: {
...dayjsLocale.formats,
LT: timeFormat === "12h" ? "h:mm A" : "H:mm",
},
});
}
return (
<DayjsContext.Provider
value={{
dayjs,
weekStartsOn: weekStartsOn ?? dayjsLocales[locale].weekStartsOn,
timeFormat: timeFormat ?? dayjsLocales[locale].timeFormat,
setWeekStartsOn,
setTimeFormat,
}}
>
{children}
</DayjsContext.Provider>
);
};