Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@openstatus/db": "workspace:*",
"@openstatus/emails": "workspace:*",
"@openstatus/error": "workspace:*",
"@openstatus/locales": "workspace:*",
"@openstatus/header-analysis": "workspace:*",
"@openstatus/icons": "workspace:*",
"@openstatus/importers": "workspace:*",
Expand Down
230 changes: 230 additions & 0 deletions apps/dashboard/src/components/forms/status-page/form-locale.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { useTransition } from "react";
import { z } from "zod";

import {
FormCard,
FormCardContent,
FormCardDescription,
FormCardFooter,
FormCardFooterInfo,
FormCardHeader,
FormCardSeparator,
FormCardTitle,
} from "@/components/forms/form-card";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@openstatus/ui/components/ui/button";
import { Checkbox } from "@openstatus/ui/components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@openstatus/ui/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@openstatus/ui/components/ui/select";
import { isTRPCClientError } from "@trpc/client";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

import { type Locale, localeDetails, locales } from "@openstatus/locales";

const AVAILABLE_LOCALES = locales.map((code) => ({
value: code,
label: localeDetails[code].name,
}));

const schema = z
.object({
defaultLocale: z.enum(locales),
locales: z.array(z.enum(locales)).nullable(),
})
.refine(
(data) => {
if (data.locales) {
return data.locales.includes(data.defaultLocale);
}
return true;
},
{
message: "Default locale must be included in the enabled locales",
path: ["defaultLocale"],
},
);

type FormValues = z.infer<typeof schema>;

export function FormLocale({
defaultValues,
onSubmit,
}: {
defaultValues?: FormValues;
onSubmit: (values: FormValues) => Promise<void>;
}) {
const [isPending, startTransition] = useTransition();
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: defaultValues ?? {
defaultLocale: "en",
locales: null,
},
});

const selectedLocales = form.watch("locales");
const isMultiLocaleEnabled = selectedLocales !== null;

function submitAction(values: FormValues) {
if (isPending) return;

startTransition(async () => {
try {
const promise = onSubmit(values);
toast.promise(promise, {
loading: "Saving...",
success: "Saved",
error: (error) => {
if (isTRPCClientError(error)) {
return error.message;
}
return "Failed to save";
},
});
await promise;
} catch (error) {
console.error(error);
}
});
}

function toggleMultiLocale(enabled: boolean) {
if (enabled) {
const currentDefault = form.getValues("defaultLocale");
form.setValue("locales", [currentDefault], { shouldValidate: true });
} else {
form.setValue("locales", null, { shouldValidate: true });
}
}

function toggleLocale(locale: Locale, checked: boolean) {
const current = form.getValues("locales") ?? [];
const updated = checked
? [...current, locale]
: current.filter((l) => l !== locale);

// Don't allow removing all locales
if (updated.length === 0) return;

form.setValue("locales", updated, { shouldValidate: true });

// If the default locale was removed, switch to the first remaining locale
const currentDefault = form.getValues("defaultLocale");
if (!updated.includes(currentDefault)) {
form.setValue("defaultLocale", updated[0], { shouldValidate: true });
}
}

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(submitAction)}>
<FormCard>
<FormCardHeader>
<FormCardTitle>Locales</FormCardTitle>
<FormCardDescription>
Configure which languages are available on your status page.
</FormCardDescription>
</FormCardHeader>
<FormCardSeparator />
<FormCardContent className="grid gap-4">
<FormField
control={form.control}
name="defaultLocale"
render={({ field }) => (
<FormItem>
<FormLabel>Default Locale</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select default locale" />
</SelectTrigger>
</FormControl>
<SelectContent>
{AVAILABLE_LOCALES.map((locale) => (
<SelectItem key={locale.value} value={locale.value}>
{locale.label} ({locale.value})
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
The fallback language for your status page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="multi-locale"
checked={isMultiLocaleEnabled}
onCheckedChange={(checked) =>
toggleMultiLocale(checked === true)
}
/>
<label
htmlFor="multi-locale"
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Enable locale switcher
</label>
</div>
{isMultiLocaleEnabled ? (
<div className="ml-6 space-y-2">
{AVAILABLE_LOCALES.map((locale) => (
<div
key={locale.value}
className="flex items-center space-x-2"
>
<Checkbox
id={`locale-${locale.value}`}
checked={selectedLocales?.includes(locale.value)}
onCheckedChange={(checked) =>
toggleLocale(locale.value, checked === true)
}
/>
<label
htmlFor={`locale-${locale.value}`}
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{locale.label} ({locale.value})
</label>
</div>
))}
</div>
) : null}
</div>
</FormCardContent>
<FormCardFooter>
<FormCardFooterInfo>
When the locale switcher is enabled, visitors can choose their
preferred language.
</FormCardFooterInfo>
<Button type="submit" disabled={isPending}>
{isPending ? "Submitting..." : "Submit"}
</Button>
</FormCardFooter>
</FormCard>
</form>
</Form>
);
}
20 changes: 20 additions & 0 deletions apps/dashboard/src/components/forms/status-page/update.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { FormCustomDomain } from "./form-custom-domain";
import { FormDangerZone } from "./form-danger-zone";
import { FormGeneral } from "./form-general";
import { FormLinks } from "./form-links";
import { FormLocale } from "./form-locale";
import { FormPageAccess } from "./form-page-access";

export function FormStatusPageUpdate() {
Expand Down Expand Up @@ -74,6 +75,12 @@ export function FormStatusPageUpdate() {
}),
);

const updateLocalesMutation = useMutation(
trpc.page.updateLocales.mutationOptions({
onSuccess: () => refetch(),
}),
);

if (!statusPage || !monitors || !workspace) return null;

return (
Expand Down Expand Up @@ -148,6 +155,19 @@ export function FormStatusPageUpdate() {
});
}}
/>
<FormLocale
defaultValues={{
defaultLocale: statusPage.defaultLocale ?? "en",
locales: statusPage.locales,
}}
onSubmit={async (values) => {
await updateLocalesMutation.mutateAsync({
id: Number.parseInt(id),
defaultLocale: values.defaultLocale,
locales: values.locales,
});
}}
/>
<FormPageAccess
lockedMap={
new Map([
Expand Down
2 changes: 1 addition & 1 deletion apps/status-page/messages/de.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"Y9HHck": "Authentifizieren",
"1QcGkA": "Geben Sie Ihre E-Mail-Adresse ein, um einen Magic Link für den Zugriff auf die Statusseite zu erhalten. Hinweis: Es werden nur E-Mails von genehmigten Domains akzeptiert.",
"wSZR47": "Absenden",
"txkW56": "Wird gesendet...",
"wSZR47": "Absenden",
"OrFVks": "Überprüfen Sie Ihren Posteingang!",
"n36zhX": "Greifen Sie auf die Statusseite zu, indem Sie auf den Link in der E-Mail klicken.",
"qIAQSi": "Geschützte Seite",
Expand Down
2 changes: 1 addition & 1 deletion apps/status-page/messages/en.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"Y9HHck": "Authenticate",
"1QcGkA": "Enter your email to receive a magic link for accessing the status page. Note: Only emails from approved domains are accepted.",
"wSZR47": "Submit",
"txkW56": "Submitting...",
"wSZR47": "Submit",
"OrFVks": "Check your inbox!",
"n36zhX": "Access the status page by clicking the link in the email.",
"qIAQSi": "Protected Page",
Expand Down
4 changes: 2 additions & 2 deletions apps/status-page/messages/fr.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"Y9HHck": "S'authentifier",
"1QcGkA": "Entrez votre email pour recevoir un lien magique d'accès à la page de statut. Remarque : seuls les emails provenant de domaines approuvés sont acceptés.",
"wSZR47": "Envoyer",
"txkW56": "Envoi en cours...",
"wSZR47": "Envoyer",
"OrFVks": "Vérifiez votre boîte de réception !",
"n36zhX": "Accédez à la page de statut en cliquant sur le lien dans l'email.",
"qIAQSi": "Page protégée",
Expand Down Expand Up @@ -115,7 +115,7 @@
"D3rOMr": "Aucune donnée",
"2wsjxR": "en cours",
"jC7BY1": "sur {duration}",
"uPb/gh": "Recevoir les mises à jour",
"uPb/gh": "Mises à jour",
"sjzDbu": "Slack",
"q0qMyV": "RSS",
"9y9QQh": "JSON",
Expand Down
1 change: 1 addition & 0 deletions apps/status-page/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@openstatus/db": "workspace:*",
"@openstatus/emails": "workspace:*",
"@openstatus/error": "workspace:*",
"@openstatus/locales": "workspace:*",
"@openstatus/react": "workspace:*",
"@openstatus/theme-store": "workspace:*",
"@openstatus/tinybird": "workspace:*",
Expand Down
Loading
Loading