diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01871ee..dc630d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ env: CF_API_TOKEN: ${{secrets.CF_API_TOKEN}} CF_ACCOUNT_ID: ${{secrets.CF_ACCOUNT_ID}} WHATSAPP_TOKEN: ${{secrets.WHATSAPP_TOKEN}} + SENTRY_AUTH_TOKEN: ${{secrets.SENTRY_AUTH_TOKEN}} jobs: check: diff --git a/apps/dash/.gitignore b/apps/dash/.gitignore index 5ef6a52..dd146b5 100644 --- a/apps/dash/.gitignore +++ b/apps/dash/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/apps/dash/app/(auth)/login/page.tsx b/apps/dash/app/(auth)/login/page.tsx index 48a5246..03e1ebd 100644 --- a/apps/dash/app/(auth)/login/page.tsx +++ b/apps/dash/app/(auth)/login/page.tsx @@ -1,4 +1,4 @@ export { - LoginMetadata as metadata, + generateMetadata, LoginPage as default, } from "@/pages/auth/ui"; diff --git a/apps/dash/app/api/sentry-example-api/route.ts b/apps/dash/app/api/sentry-example-api/route.ts new file mode 100644 index 0000000..aa23929 --- /dev/null +++ b/apps/dash/app/api/sentry-example-api/route.ts @@ -0,0 +1,17 @@ +import * as Sentry from "@sentry/nextjs"; +export const dynamic = "force-dynamic"; + +class SentryExampleAPIError extends Error { + constructor(message: string | undefined) { + super(message); + this.name = "SentryExampleAPIError"; + } +} + +// A faulty API route to test Sentry's error monitoring +export function GET() { + Sentry.logger.info("Sentry example API called"); + throw new SentryExampleAPIError( + "This error is raised on the backend called by the example page.", + ); +} diff --git a/apps/dash/app/global-error.tsx b/apps/dash/app/global-error.tsx new file mode 100644 index 0000000..4f9c8a9 --- /dev/null +++ b/apps/dash/app/global-error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import NextError from "next/error"; +import { useEffect } from "react"; + +export default function GlobalError({ + error, +}: { + error: Error & { digest?: string }; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/apps/dash/app/sentry-example-page/page.tsx b/apps/dash/app/sentry-example-page/page.tsx new file mode 100644 index 0000000..ef2efc3 --- /dev/null +++ b/apps/dash/app/sentry-example-page/page.tsx @@ -0,0 +1,239 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import Head from "next/head"; +import { useEffect, useState } from "react"; + +class SentryExampleFrontendError extends Error { + constructor(message: string | undefined) { + super(message); + this.name = "SentryExampleFrontendError"; + } +} + +export default function Page() { + const [hasSentError, setHasSentError] = useState(false); + const [isConnected, setIsConnected] = useState(true); + + useEffect(() => { + Sentry.logger.info("Sentry example page loaded"); + async function checkConnectivity() { + const result = await Sentry.diagnoseSdkConnectivity(); + setIsConnected(result !== "sentry-unreachable"); + } + checkConnectivity(); + }, []); + + return ( +
+ + sentry-example-page + + + +
+
+ + + +

sentry-example-page

+ +

+ Click the button below, and view the sample error on the Sentry{" "} + + Issues Page + + . For more details about setting up Sentry,{" "} + + read our docs + + . +

+ + + + {hasSentError ? ( +

Error sent to Sentry.

+ ) : !isConnected ? ( +
+

+ It looks like network requests to Sentry are being blocked, which + will prevent errors from being captured. Try disabling your + ad-blocker to complete the test. +

+
+ ) : ( +
+ )} + +
+
+ + +
+ ); +} diff --git a/apps/dash/e2e/features/org/hierarchy.spec.ts b/apps/dash/e2e/features/org/hierarchy.spec.ts index 00f08f6..678f9b7 100644 --- a/apps/dash/e2e/features/org/hierarchy.spec.ts +++ b/apps/dash/e2e/features/org/hierarchy.spec.ts @@ -67,7 +67,9 @@ test.describe("Organization Hierarchy Rules (E2E)", () => { // Verify it allows opening await parentSelect.click(); - await expect(page.getByRole("option", { name: /None/i })).toBeVisible(); + await expect( + page.getByRole("option", { name: /Tanpa Induk/i }), + ).toBeVisible(); await page.keyboard.press("Escape"); // Case 3: BEC should be enabled diff --git a/apps/dash/e2e/features/org/manage.spec.ts b/apps/dash/e2e/features/org/manage.spec.ts index d0369ee..8c6d411 100644 --- a/apps/dash/e2e/features/org/manage.spec.ts +++ b/apps/dash/e2e/features/org/manage.spec.ts @@ -67,6 +67,9 @@ test.describe("Organization Update & Remove Page", () => { }); await expect(removeButton).toBeVisible(); await removeButton.click(); + const confirmButton = page.getByRole("button", { name: /^Hapus$/i }); + await expect(confirmButton).toBeVisible(); + await confirmButton.click(); // 4. Verify redirected to list await expect(page).toHaveURL(/.*\/org$/); @@ -135,8 +138,8 @@ test.describe("Organization Update & Remove Page", () => { }); // We expect the progress overlay to appear and then disappear (upload completes) - await expect(page.getByText(/Uploading/i)).toBeVisible(); - await expect(page.getByText(/Uploading/i)).toBeHidden(); + await expect(page.getByText(/Unggah/i)).toBeVisible(); + await expect(page.getByText(/Unggah/i)).toBeHidden(); // 5. Edit Logo const logoGroup = page @@ -156,7 +159,7 @@ test.describe("Organization Update & Remove Page", () => { }); // Wait for upload progress to finish - await expect(page.getByText(/Uploading/i)).toBeVisible(); - await expect(page.getByText(/Uploading/i)).toBeHidden(); + await expect(page.getByText(/Unggah/i)).toBeVisible(); + await expect(page.getByText(/Unggah/i)).toBeHidden(); }); }); diff --git a/apps/dash/instrumentation-client.ts b/apps/dash/instrumentation-client.ts new file mode 100644 index 0000000..0fb54cd --- /dev/null +++ b/apps/dash/instrumentation-client.ts @@ -0,0 +1,31 @@ +// This file configures the initialization of Sentry on the client. +// The added config here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: "https://ca8edde747854b2c90b1ee88ddb8f618@o4511147968430080.ingest.us.sentry.io/4511147970199552", + + // Add optional integrations for additional features + integrations: [Sentry.replayIntegration()], + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + // Enable logs to be sent to Sentry + enableLogs: true, + + // Define how likely Replay events are sampled. + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, + + // Define how likely Replay events are sampled when an error occurs. + replaysOnErrorSampleRate: 1.0, + + // Enable sending user PII (Personally Identifiable Information) + // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii + sendDefaultPii: true, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/apps/dash/instrumentation.ts b/apps/dash/instrumentation.ts index b4ce3d2..6fa9d4b 100644 --- a/apps/dash/instrumentation.ts +++ b/apps/dash/instrumentation.ts @@ -1,3 +1,4 @@ +import * as Sentry from "@sentry/nextjs"; import { registerListeners } from "@/shared/core/listeners"; /** @@ -8,4 +9,13 @@ import { registerListeners } from "@/shared/core/listeners"; */ export async function register(): Promise { registerListeners(); + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config"); + } + + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } } + +export const onRequestError = Sentry.captureRequestError; diff --git a/apps/dash/next.config.ts b/apps/dash/next.config.ts index 05f4de9..b7ef855 100644 --- a/apps/dash/next.config.ts +++ b/apps/dash/next.config.ts @@ -1,3 +1,4 @@ +import { withSentryConfig } from "@sentry/nextjs"; import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; @@ -33,4 +34,44 @@ const nextConfig: NextConfig = { ], }; -export default withNextIntl(nextConfig); +export default withSentryConfig(withNextIntl(nextConfig), { + // For all available options, see: + // https://www.npmjs.com/package/@sentry/webpack-plugin#options + + org: "pkrbt-developer-bn", + + project: "domus", + + // Only print logs for uploading source maps in CI + silent: !process.env.CI, + + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + tunnelRoute: "/monitoring", + + webpack: { + // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, + + // Tree-shaking options for reducing bundle size + treeshake: { + // Automatically tree-shake Sentry logger statements to reduce bundle size + removeDebugLogging: true, + }, + }, + + sourcemaps: { + disable: true, + }, +}); diff --git a/apps/dash/package.json b/apps/dash/package.json index f0c5447..089fe97 100644 --- a/apps/dash/package.json +++ b/apps/dash/package.json @@ -31,6 +31,7 @@ "@fontsource/plus-jakarta-sans": "^5.2.8", "@hello-pangea/dnd": "^18.0.1", "@nextwrappers/async-local-storage": "^1.0.1", + "@sentry/nextjs": "^10.47.0", "@tanstack/react-form": "^1.28.6", "better-auth": "1.5.6", "browser-image-compression": "^2.0.2", diff --git a/apps/dash/pages/_error.tsx b/apps/dash/pages/_error.tsx new file mode 100644 index 0000000..6f4c580 --- /dev/null +++ b/apps/dash/pages/_error.tsx @@ -0,0 +1,18 @@ +import * as Sentry from "@sentry/nextjs"; +import type { NextPageContext } from "next"; +import NextError, { type ErrorProps } from "next/error"; + +const CustomErrorComponent = (props: ErrorProps) => { + return ; +}; + +CustomErrorComponent.getInitialProps = async (contextData: NextPageContext) => { + // In case this is running in a serverless function, await this in order to give Sentry + // time to send the error before the lambda exits + await Sentry.captureUnderscoreErrorException(contextData); + + // This will contain the status code of the response + return NextError.getInitialProps(contextData); +}; + +export default CustomErrorComponent; diff --git a/apps/dash/proxy.ts b/apps/dash/proxy.ts index 709f3c3..09efefd 100644 --- a/apps/dash/proxy.ts +++ b/apps/dash/proxy.ts @@ -104,5 +104,5 @@ export default async function proxy(request: NextRequest) { } export const config = { - matcher: ["/((?!api|_next|_static|_vercel|.*\\.\\w+).*)"], + matcher: ["/((?!api|sentry-example-page|_next|_static|_vercel|.*\\.\\w+).*)"], }; diff --git a/apps/dash/seed/helper/org.ts b/apps/dash/seed/helper/org.ts index e04f8cf..d02e63b 100644 --- a/apps/dash/seed/helper/org.ts +++ b/apps/dash/seed/helper/org.ts @@ -49,6 +49,7 @@ export async function ensureOrg( slug: slug, userId: user.id, type: payload.type, + joinId: payload.joinId || nanoid(8), }, }); @@ -65,7 +66,7 @@ export async function ensureOrg( .update(orgTable) .set({ type: payload.type, - joinId: payload.joinId || nanoid(), + joinId: payload.joinId || nanoid(8), parentId: payload.parentId ?? null, cover: payload.cover ?? null, logo: payload.logo ?? null, @@ -94,7 +95,7 @@ export async function ensureOrg( parentId: payload.parentId, cover: payload.cover, description: payload.description || existingOrg.description || null, - joinId: payload.joinId || existingOrg.joinId || nanoid(), + joinId: payload.joinId || existingOrg.joinId || nanoid(8), updatedAt: new Date(), }) .where(eq(orgTable.id, existingOrg.id)); diff --git a/apps/dash/sentry.edge.config.ts b/apps/dash/sentry.edge.config.ts new file mode 100644 index 0000000..c61c0ff --- /dev/null +++ b/apps/dash/sentry.edge.config.ts @@ -0,0 +1,21 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + enabled: process.env.NODE_ENV === "production", + dsn: "https://ca8edde747854b2c90b1ee88ddb8f618@o4511147968430080.ingest.us.sentry.io/4511147970199552", + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Enable logs to be sent to Sentry + enableLogs: true, + + // Enable sending user PII (Personally Identifiable Information) + // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii + sendDefaultPii: true, +}); diff --git a/apps/dash/sentry.server.config.ts b/apps/dash/sentry.server.config.ts new file mode 100644 index 0000000..9ecc775 --- /dev/null +++ b/apps/dash/sentry.server.config.ts @@ -0,0 +1,20 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: "https://ca8edde747854b2c90b1ee88ddb8f618@o4511147968430080.ingest.us.sentry.io/4511147970199552", + enabled: process.env.NODE_ENV === "production", + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Enable logs to be sent to Sentry + enableLogs: true, + + // Enable sending user PII (Personally Identifiable Information) + // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii + sendDefaultPii: true, +}); diff --git a/apps/dash/src/pages/auth/ui/LoginPage.tsx b/apps/dash/src/pages/auth/ui/LoginPage.tsx index 8bece98..374c628 100644 --- a/apps/dash/src/pages/auth/ui/LoginPage.tsx +++ b/apps/dash/src/pages/auth/ui/LoginPage.tsx @@ -1,13 +1,19 @@ import c, { Environment } from "@domus/config"; -import type { Metadata } from "next"; import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; import { Card, CardContent } from "@/shared/ui/shadcn/card"; import { Brand } from "@/widgets/layout/ui/components/Brand"; import { LoginForm } from "./components/LoginForm"; -export const LoginMetadata: Metadata = { - title: "Login to PKRBT", -}; +/** + * Generates metadata for the login page. + */ +export async function generateMetadata() { + const t = await getTranslations("LoginPage"); + return { + title: t("metadataTitle"), + }; +} /** * Login page UI component. diff --git a/apps/dash/src/pages/event/ui/EventPage.tsx b/apps/dash/src/pages/event/ui/EventPage.tsx index 4c27c00..4e2bc4b 100644 --- a/apps/dash/src/pages/event/ui/EventPage.tsx +++ b/apps/dash/src/pages/event/ui/EventPage.tsx @@ -1,3 +1,4 @@ +import { useTranslations } from "next-intl"; import { UnderConstruction } from "@/shared/ui/components/UnderConstruction"; /** @@ -7,10 +8,9 @@ import { UnderConstruction } from "@/shared/ui/components/UnderConstruction"; * @returns The EventPage component. */ export function EventPage() { + const t = useTranslations("EventPage"); + return ( - + ); } diff --git a/apps/dash/src/pages/finance/ui/FinancePage.tsx b/apps/dash/src/pages/finance/ui/FinancePage.tsx index a8603f0..d767c70 100644 --- a/apps/dash/src/pages/finance/ui/FinancePage.tsx +++ b/apps/dash/src/pages/finance/ui/FinancePage.tsx @@ -1,3 +1,4 @@ +import { useTranslations } from "next-intl"; import { UnderConstruction } from "@/shared/ui/components/UnderConstruction"; /** @@ -7,10 +8,9 @@ import { UnderConstruction } from "@/shared/ui/components/UnderConstruction"; * @returns The FinancePage component. */ export function FinancePage() { + const t = useTranslations("FinancePage"); + return ( - + ); } diff --git a/apps/dash/src/pages/home/actions/home.ts b/apps/dash/src/pages/home/actions/home.ts index 4b037fd..96443ce 100644 --- a/apps/dash/src/pages/home/actions/home.ts +++ b/apps/dash/src/pages/home/actions/home.ts @@ -41,12 +41,9 @@ import type { LucideIcon } from "lucide-react"; /** A parish service shortcut card. */ export interface LayananItem { id: string; - title: string; - description: string; icon: LucideIcon; color: string; hoverTextColor: string; - actionLabel: string; href: string; } diff --git a/apps/dash/src/pages/home/ui/components/AgendaSection.tsx b/apps/dash/src/pages/home/ui/components/AgendaSection.tsx index 6bc1977..6c6d6cb 100644 --- a/apps/dash/src/pages/home/ui/components/AgendaSection.tsx +++ b/apps/dash/src/pages/home/ui/components/AgendaSection.tsx @@ -3,7 +3,7 @@ import { EventStatus, RsvpStatus } from "@domus/core"; import { Calendar, Check, HelpCircle, MapPin, X } from "lucide-react"; import Image from "next/image"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useState } from "react"; import type { AgendaItem } from "../../actions/home"; @@ -16,6 +16,7 @@ interface AgendaSectionProps { */ export function AgendaSection({ items }: AgendaSectionProps) { const t = useTranslations("HomePage"); + const locale = useLocale(); const [rsvpMap, setRsvpMap] = useState>( () => { return Object.fromEntries(items.map((i) => [i.id, i.rsvpStatus])); @@ -93,11 +94,14 @@ export function AgendaSection({ items }: AgendaSectionProps) {
- {new Intl.DateTimeFormat("id-ID", { - day: "2-digit", - month: "short", - year: "numeric", - }).format(item.startDateTime)} + {new Intl.DateTimeFormat( + locale === "id" ? "id-ID" : "en-US", + { + day: "2-digit", + month: "short", + year: "numeric", + }, + ).format(item.startDateTime)}
diff --git a/apps/dash/src/pages/home/ui/components/LayananSection.tsx b/apps/dash/src/pages/home/ui/components/LayananSection.tsx index d97b065..d97d945 100644 --- a/apps/dash/src/pages/home/ui/components/LayananSection.tsx +++ b/apps/dash/src/pages/home/ui/components/LayananSection.tsx @@ -10,15 +10,16 @@ interface LayananSectionProps { * Displays a grid of service shortcut cards. Static/Server Component. */ export function LayananSection({ items }: LayananSectionProps) { - const t = useTranslations("HomePage"); + const tHome = useTranslations("HomePage"); + const tActions = useTranslations("DashboardActions"); return (

- {t("servicesTitle")} + {tHome("servicesTitle")}

- {t("servicesSubtitle")} + {tHome("servicesSubtitle")}

@@ -37,12 +38,12 @@ export function LayananSection({ items }: LayananSectionProps) {

- {item.title} + {tActions(`${item.id}.title`)}

- {item.description} + {tActions(`${item.id}.description`)}

@@ -50,7 +51,7 @@ export function LayananSection({ items }: LayananSectionProps) { type="button" className={`mt-6 w-full rounded-lg bg-white/10 px-4 py-2 text-[10px] font-bold uppercase tracking-wider text-white border border-white/20 transition-all hover:bg-white ${item.hoverTextColor}`} > - {item.actionLabel} + {tActions(`${item.id}.actionLabel`)} ); diff --git a/apps/dash/src/pages/home/ui/components/TugasSection.tsx b/apps/dash/src/pages/home/ui/components/TugasSection.tsx index 0bf0eaf..1997ae0 100644 --- a/apps/dash/src/pages/home/ui/components/TugasSection.tsx +++ b/apps/dash/src/pages/home/ui/components/TugasSection.tsx @@ -2,7 +2,7 @@ import { Calendar, CheckCircle2 } from "lucide-react"; import Image from "next/image"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useState } from "react"; import type { ParishionerRequest } from "../../actions/home"; @@ -15,6 +15,7 @@ interface TugasSectionProps { */ export function TugasSection({ items }: TugasSectionProps) { const t = useTranslations("HomePage"); + const locale = useLocale(); const [confirmed, setConfirmed] = useState>(new Set()); const handleConfirm = (id: string) => { @@ -85,14 +86,17 @@ export function TugasSection({ items }: TugasSectionProps) {
- {new Intl.DateTimeFormat("id-ID", { - weekday: "long", - day: "2-digit", - month: "short", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }).format(item.createdAt)} + {new Intl.DateTimeFormat( + locale === "id" ? "id-ID" : "en-US", + { + weekday: "long", + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }, + ).format(item.createdAt)}
diff --git a/apps/dash/src/pages/org/actions/create.ts b/apps/dash/src/pages/org/actions/create.ts index 86c8f3e..a21817d 100644 --- a/apps/dash/src/pages/org/actions/create.ts +++ b/apps/dash/src/pages/org/actions/create.ts @@ -1,6 +1,7 @@ "use server"; import type { CreateOrganization, Organization } from "@domus/core"; import { OrgType, UserRole } from "@domus/core"; +import { nanoid } from "nanoid"; import { headers } from "next/headers"; import auth, { getSession } from "@/shared/auth/server"; import { organization as organizationService } from "@/shared/core"; @@ -96,6 +97,7 @@ export async function createAction( parentId: data.parentId ?? undefined, cover: data.cover ?? undefined, description: data.description ?? undefined, + joinId: nanoid(8), }, }); if (!org) { diff --git a/apps/dash/src/pages/org/ui/JoinPage.tsx b/apps/dash/src/pages/org/ui/JoinPage.tsx index 333e8e0..e6f4e2a 100644 --- a/apps/dash/src/pages/org/ui/JoinPage.tsx +++ b/apps/dash/src/pages/org/ui/JoinPage.tsx @@ -1,6 +1,7 @@ import { AlertCircle, ArrowLeft } from "lucide-react"; import { headers } from "next/headers"; import Link from "next/link"; +import { getTranslations } from "next-intl/server"; import { Button } from "@/shared/ui/shadcn/button"; import { Card } from "@/shared/ui/shadcn/card"; import { getSession } from "../../../shared/auth/server"; @@ -27,14 +28,16 @@ interface JoinPageProps { */ export async function JoinPage({ params }: JoinPageProps) { const { joinId } = await params; + const t = await getTranslations("JoinPage"); // 1. Get session (Auth is required, proxy should have redirected if not logged in) const session = await getSession(await headers()); if (!session) { return ( ); } @@ -42,7 +45,13 @@ export async function JoinPage({ params }: JoinPageProps) { // 2. Fetch Organization and Unit data const [data, error] = await getJoinDataAction(joinId); if (error) { - return ; + return ( + + ); } const { organization, units } = data; @@ -56,7 +65,7 @@ export async function JoinPage({ params }: JoinPageProps) { className="group mb-6 flex w-fit items-center gap-2 text-xs font-semibold text-[#44474c] transition-colors hover:text-[#a23c29]" > - Kembali ke Beranda + {t("backHome")} {/* Form Container */} @@ -87,9 +96,11 @@ export async function JoinPage({ params }: JoinPageProps) { function JoinErrorScreen({ title, message, + backHome, }: { title: string; message: string; + backHome: string; }) { return (
@@ -106,7 +117,7 @@ function JoinErrorScreen({ variant="default" className="mt-8 bg-[#233345] hover:bg-[#324559] h-10 px-8 rounded-lg" > - Kembali ke Beranda + {backHome}
); diff --git a/apps/dash/src/pages/org/ui/JoinSuccessPage.tsx b/apps/dash/src/pages/org/ui/JoinSuccessPage.tsx index 4864e67..5a65421 100644 --- a/apps/dash/src/pages/org/ui/JoinSuccessPage.tsx +++ b/apps/dash/src/pages/org/ui/JoinSuccessPage.tsx @@ -1,5 +1,6 @@ import { CheckCircle, Home } from "lucide-react"; import Link from "next/link"; +import { getTranslations } from "next-intl/server"; import { Button } from "@/shared/ui/shadcn/button"; import { Card } from "@/shared/ui/shadcn/card"; @@ -33,6 +34,7 @@ export async function JoinSuccessPage({ await params; const { exists } = await searchParams; const isExisting = exists === "true"; + const t = await getTranslations("JoinSuccessPage"); return (
@@ -42,12 +44,10 @@ export async function JoinSuccessPage({

- {isExisting ? "Pendaftaran Sudah Ada" : "Pendaftaran Berhasil!"} + {isExisting ? t("titleExists") : t("titleSuccess")}

- {isExisting - ? "Anda sudah terdaftar atau memiliki pendaftaran yang sedang diproses untuk organisasi ini. Silakan hubungi pengurus organisasi untuk informasi lebih lanjut." - : "Data pendaftaran Anda telah kami terima. Pengurus organisasi akan segera meninjau dan memproses profil Anda. Silakan hubungi sekretariat paroki untuk informasi lebih lanjut."} + {isExisting ? t("descExists") : t("descSuccess")}

diff --git a/apps/dash/src/pages/org/ui/OrgDetailPage.tsx b/apps/dash/src/pages/org/ui/OrgDetailPage.tsx index 89d02af..07cae6c 100644 --- a/apps/dash/src/pages/org/ui/OrgDetailPage.tsx +++ b/apps/dash/src/pages/org/ui/OrgDetailPage.tsx @@ -1,14 +1,16 @@ import { UserRole } from "@domus/core"; import { headers } from "next/headers"; import Link from "next/link"; -import { notFound, redirect } from "next/navigation"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { getSession } from "@/shared/auth/server"; import { organization as organizationService, unit as unitService, } from "@/shared/core"; -import { Button } from "@/shared/ui/shadcn/button"; +import { Badge } from "@/shared/ui/shadcn/badge"; import { Card, CardContent } from "@/shared/ui/shadcn/card"; +import { DeleteOrgButton } from "./components/DeleteOrgButton"; import { OrgHeader } from "./components/OrgHeader"; import { UnitList } from "./components/UnitList"; @@ -23,6 +25,8 @@ interface OrgDetailPageProps { * @param props - Component properties. */ export async function OrgDetailPage({ params }: OrgDetailPageProps) { + const t = await getTranslations("OrgDetailPage"); + const tEnums = await getTranslations("Enums"); const { id } = await params; // Parallel fetch org and units @@ -40,25 +44,13 @@ export async function OrgDetailPage({ params }: OrgDetailPageProps) { session?.context?.roles.includes(UserRole.SuperAdmin) || session?.context?.roles.includes(UserRole.ParishAdmin); - // Server action to remove the organization - const handleRemove = async () => { - "use server"; - const { removeAction } = await import("../actions/delete"); - const res = await removeAction(id); - if (res.success) { - redirect("/org"); - } else { - console.error(res.message); - } - }; - return (
{/* Navigation Breadcrumb-like */}
- Organisasi + {t("breadcrumb")} / {org.name} @@ -82,30 +74,33 @@ export async function OrgDetailPage({ params }: OrgDetailPageProps) {

- Detail Organisasi + {t("detailsTitle")}

- - Jenis - - - {org.type} + + {t("labelType")} + + {tEnums(`OrgType.${org.type}`)} +
{org.parentId && (
- Induk + {t("labelParent")} - Organisasi Terlampir + {t("labelAttachedOrg")}
)}
- Dibuat Pada + {t("labelCreatedAt")} {new Date(org.createdAt).toLocaleDateString("id-ID", { @@ -121,19 +116,9 @@ export async function OrgDetailPage({ params }: OrgDetailPageProps) { {canRemove && (

- Tindakan Berbahaya + {t("dangerZoneTitle")}

-
- -
+
)} @@ -144,28 +129,3 @@ export async function OrgDetailPage({ params }: OrgDetailPageProps) {
); } - -// Helper icons -function Trash2(props: React.SVGProps) { - return ( - - Trash Icon - - - - - - - ); -} diff --git a/apps/dash/src/pages/org/ui/OrgNewPage.tsx b/apps/dash/src/pages/org/ui/OrgNewPage.tsx index bafc6bc..9a25d36 100644 --- a/apps/dash/src/pages/org/ui/OrgNewPage.tsx +++ b/apps/dash/src/pages/org/ui/OrgNewPage.tsx @@ -1,3 +1,4 @@ +import { getTranslations } from "next-intl/server"; import { Card } from "@/shared/ui/shadcn/card"; import { createAction } from "../actions/create"; import { OrgForm } from "./components/OrgForm"; @@ -7,6 +8,7 @@ import { OrgForm } from "./components/OrgForm"; * Server component that renders the organization creation flow. */ export async function OrgNewPage() { + const t = await getTranslations("OrgNewPage"); return (
@@ -16,12 +18,9 @@ export async function OrgNewPage() { data-testid="org-new-title" className="font-headline text-3xl font-extrabold text-[#233345] tracking-tight mb-2 sm:text-4xl" > - Tambah Organisasi Baru + {t("title")} -

- Lengkapi detail informasi untuk mendaftarkan struktur organisasi - baru dalam sistem. -

+

{t("description")}

{/* Shadcn-style Card Container with Stitch styling */} diff --git a/apps/dash/src/pages/org/ui/OrgPage.tsx b/apps/dash/src/pages/org/ui/OrgPage.tsx index 7a852c3..162c325 100644 --- a/apps/dash/src/pages/org/ui/OrgPage.tsx +++ b/apps/dash/src/pages/org/ui/OrgPage.tsx @@ -1,4 +1,5 @@ import { headers } from "next/headers"; +import { getTranslations } from "next-intl/server"; import { getSession } from "@/shared/auth/server"; import { listAction } from "../actions/list"; import { OrgPageContent } from "./components/OrgPageContent"; @@ -9,15 +10,14 @@ import { OrgPageContent } from "./components/OrgPageContent"; */ export async function OrgPage() { const session = await getSession(await headers()); + const t = await getTranslations("OrgPage"); if (!session?.context) { console.error("[OrgPage] No session context found. Redirecting..."); // Ideally this should be handled by proxy, but as a safety measure: return (
-

- Sesi tidak valid. Silakan login kembali. -

+

{t("sessionError")}

); } diff --git a/apps/dash/src/pages/org/ui/components/DeleteOrgButton.tsx b/apps/dash/src/pages/org/ui/components/DeleteOrgButton.tsx new file mode 100644 index 0000000..22aaefb --- /dev/null +++ b/apps/dash/src/pages/org/ui/components/DeleteOrgButton.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/shared/ui/shadcn/alert-dialog"; +import { Button } from "@/shared/ui/shadcn/button"; +import { removeAction } from "../../actions/delete"; + +interface DeleteOrgButtonProps { + id: string; + orgName: string; +} + +/** + * Delete organization button with confirmation dialog. + * + * @param props - Component properties. + */ +export function DeleteOrgButton({ id }: DeleteOrgButtonProps) { + const t = useTranslations("OrgDetailPage"); + const router = useRouter(); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + setIsDeleting(true); + try { + const res = await removeAction(id); + if (res.success) { + toast.success(t("deleteSuccess")); + router.push("/org"); + router.refresh(); + } else { + toast.error(res.message || t("deleteError")); + } + } catch (error) { + console.error("Delete error:", error); + toast.error(t("deleteError")); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + + {t("deleteBtn")} + + } + /> + + + {t("confirmDeleteTitle")} + + {t("confirmDeleteDesc")} + + + + + {t("confirmDeleteCancel")} + + { + e.preventDefault(); + handleDelete(); + }} + disabled={isDeleting} + className="bg-[#a23c29] hover:bg-[#a23c29]/90 text-white" + > + {isDeleting ? "..." : t("confirmDeleteAction")} + + + + + ); +} diff --git a/apps/dash/src/pages/org/ui/components/JoinForm.tsx b/apps/dash/src/pages/org/ui/components/JoinForm.tsx index 76ba09f..84f87f5 100644 --- a/apps/dash/src/pages/org/ui/components/JoinForm.tsx +++ b/apps/dash/src/pages/org/ui/components/JoinForm.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import Image from "next/image"; import { useRouter } from "next/navigation"; +import { useLocale, useTranslations } from "next-intl"; import { useRef, useState } from "react"; import { toast } from "sonner"; import type { ActionResult } from "@/shared/types"; @@ -45,24 +46,6 @@ import { SelectValue, } from "@/shared/ui/shadcn/select"; -/** - * Labels for Indonesian UI translation. - */ -const EducationLevelLabels: Record = { - primary: "SD / Sederajat", - junior: "SMP / Sederajat", - senior: "SMA / SMK / Sederajat", - diploma: "Diploma (D3)", - bachelor: "Sarjana (S1)", - master: "Magister (S2)", - doctorate: "Doktor (S3)", -}; - -const GenderLabels: Record = { - male: "Laki-laki", - female: "Perempuan", -}; - /** * Honorific suggestions for registration. */ @@ -108,6 +91,10 @@ export function OrganizationJoinForm({ action, }: JoinFormProps) { const router = useRouter(); + const t = useTranslations("JoinForm"); + const tEnums = useTranslations("Enums"); + const locale = useLocale(); + const [isSubmitting, setIsSubmitting] = useState(false); const [actionError, setActionError] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); @@ -136,19 +123,19 @@ export function OrganizationJoinForm({ const result = await action(joinId, value); if (result.success) { if (result.data?.isNew) { - toast.success("Berhasil mendaftar!"); + toast.success(t("toastSuccess")); router.push(`/join/${joinId}/success`); } else { - toast.info("Pendaftaran Anda sudah terdaftar sebelumnya."); + toast.info(t("toastInfoExists")); router.push(`/join/${joinId}/success?exists=true`); } } else { - setActionError(result.message || "Gagal mendaftar."); - toast.error(result.message || "Gagal mendaftar."); + setActionError(result.message || t("toastError")); + toast.error(result.message || t("toastError")); } } catch (_err) { - setActionError("Terjadi kesalahan yang tidak terduga."); - toast.error("Terjadi kesalahan yang tidak terduga."); + setActionError(t("toastError")); + toast.error(t("toastError")); } finally { setIsSubmitting(false); } @@ -224,7 +211,7 @@ export function OrganizationJoinForm({

- {organization.description || "Organisasi Katolik Barong Tongkok."} + {organization.description || t("defaultOrgDescription")}

@@ -260,10 +247,10 @@ export function OrganizationJoinForm({

- Identitas Diri + {t("identityHeader")}

- Informasi dasar identitas Anda sebagai anggota. + {t("identitySub")}

@@ -275,7 +262,7 @@ export function OrganizationJoinForm({ htmlFor={field.name} className="text-sm font-semibold text-[#233345] font-body" > - Gelar Depan + {t("labelHonorific")} field.handleChange(e.target.value)} - placeholder="RD, Sr, Dr..." + placeholder={t("placeholderHonorific")} list="honorifics" className="w-full px-4 py-2.5 bg-[#f3f4f6] border-none rounded-lg focus-visible:ring-2 focus-visible:ring-[#a23c29]/20 border-b-2 border-[#74777d] focus-visible:border-[#a23c29] transition-all text-sm placeholder:text-[#c4c6cc]" /> @@ -303,7 +290,8 @@ export function OrganizationJoinForm({ htmlFor={field.name} className="text-sm font-semibold text-[#233345] font-body" > - Nama Lengkap * + {t("labelFullName")}{" "} + * field.handleChange(e.target.value)} - placeholder="Masukkan nama lengkap Anda" + placeholder={t("placeholderFullName")} className="w-full px-4 py-2.5 bg-[#f3f4f6] border-none rounded-lg focus-visible:ring-2 focus-visible:ring-[#a23c29]/20 border-b-2 border-[#74777d] focus-visible:border-[#a23c29] transition-all text-sm placeholder:text-[#c4c6cc]" /> {field.state.meta.errors.length > 0 && ( @@ -328,7 +316,7 @@ export function OrganizationJoinForm({ htmlFor={field.name} className="text-sm font-semibold text-[#233345] font-body" > - Gelar Belakang + {t("labelSuffix")} field.handleChange(e.target.value)} - placeholder="S.Pd, M.T" + placeholder={t("placeholderSuffix")} className="w-full px-4 py-2.5 bg-[#f3f4f6] border-none rounded-lg focus-visible:ring-2 focus-visible:ring-[#a23c29]/20 border-b-2 border-[#74777d] focus-visible:border-[#a23c29] transition-all text-sm placeholder:text-[#c4c6cc]" /> {field.state.meta.errors.length > 0 && ( @@ -352,10 +340,10 @@ export function OrganizationJoinForm({

- Data Pribadi + {t("personalHeader")}

- Informasi tambahan untuk profil kependudukan Anda. + {t("personalSub")}

@@ -367,7 +355,7 @@ export function OrganizationJoinForm({ htmlFor={field.name} className="text-sm font-semibold text-[#233345] font-body" > - Tempat Lahir + {t("labelBirthPlace")} field.handleChange(e.target.value)} - placeholder="Contoh: Barong Tongkok" + placeholder={t("placeholderBirthPlace")} className="w-full px-4 py-2.5 bg-[#f3f4f6] border-none rounded-lg focus-visible:ring-2 focus-visible:ring-[#a23c29]/20 border-b-2 border-[#74777d] focus-visible:border-[#a23c29] transition-all text-sm placeholder:text-[#c4c6cc]" /> {field.state.meta.errors.length > 0 && ( @@ -392,7 +380,7 @@ export function OrganizationJoinForm({ htmlFor={field.name} className="text-sm font-semibold text-[#233345] font-body" > - Tanggal Lahir + {t("labelBirthDate")} {field.state.value ? ( - format(field.state.value, "PPP", { locale: id }) + format(field.state.value, "PPP", { + locale: locale === "id" ? id : undefined, + }) ) : ( - Pilih tanggal + {t("placeholderBirthDate")} )} @@ -433,7 +423,7 @@ export function OrganizationJoinForm({ {(field) => (
- {GenderLabels[value]} + {tEnums(`Gender.${value}`)}
))} @@ -467,7 +457,7 @@ export function OrganizationJoinForm({ htmlFor={field.name} className="text-sm font-semibold text-[#233345] font-body" > - Pendidikan Terakhir + {t("labelEducation")} field.handleChange(e.target.value)} - placeholder="Contoh: Anggota, Pengurus..." + placeholder={t("placeholderPosition")} className="w-full px-4 py-2.5 bg-[#f3f4f6] border-none rounded-lg focus-visible:ring-2 focus-visible:ring-[#a23c29]/20 border-b-2 border-[#74777d] focus-visible:border-[#a23c29] transition-all text-sm placeholder:text-[#c4c6cc]" />

- Default: anggota + {t("positionHelp")}

{field.state.meta.errors.length > 0 && ( @@ -720,15 +707,12 @@ export function OrganizationJoinForm({ ) : ( <> - Bergabung Sekarang + {t("btnSubmit")} )}

- Dengan menekan tombol di atas, saya bersedia bergabung dengan - organisasi{" "} - {organization.name}{" "} - dan mematuhi segala ketentuan yang berlaku. + {t("consentText", { orgName: organization.name })}

diff --git a/apps/dash/src/pages/org/ui/components/OrgFilters.tsx b/apps/dash/src/pages/org/ui/components/OrgFilters.tsx index fcfdae4..6b3652a 100644 --- a/apps/dash/src/pages/org/ui/components/OrgFilters.tsx +++ b/apps/dash/src/pages/org/ui/components/OrgFilters.tsx @@ -40,22 +40,13 @@ export function OrgFilters({ onClear, }: OrgFiltersProps) { const t = useTranslations("OrgPage"); + const tEnums = useTranslations("Enums"); const isFiltered = search !== "" || type !== "all"; // Explicit label mapping to ensure translations render correctly in SelectValue const getTypeLabel = (val: string) => { - switch (val) { - case OrgType.Region: - return t("typeRegion"); - case OrgType.BEC: - return t("typeBec"); - case OrgType.Station: - return t("typeStation"); - case OrgType.Categorical: - return t("typeCategorical"); - default: - return t("filterAll"); - } + if (val === "all") return t("filterAll"); + return tEnums(`OrgType.${val}`); }; return ( @@ -88,11 +79,17 @@ export function OrgFilters({ {t("filterAll")} - {t("typeRegion")} - {t("typeBec")} - {t("typeStation")} + + {tEnums(`OrgType.${OrgType.Region}`)} + + + {tEnums(`OrgType.${OrgType.BEC}`)} + + + {tEnums(`OrgType.${OrgType.Station}`)} + - {t("typeCategorical")} + {tEnums(`OrgType.${OrgType.Categorical}`)} diff --git a/apps/dash/src/pages/org/ui/components/OrgForm.tsx b/apps/dash/src/pages/org/ui/components/OrgForm.tsx index 90fb8a7..589d4cb 100644 --- a/apps/dash/src/pages/org/ui/components/OrgForm.tsx +++ b/apps/dash/src/pages/org/ui/components/OrgForm.tsx @@ -11,6 +11,7 @@ import { useForm } from "@tanstack/react-form"; import { Camera, Check, Image as ImageIcon, X } from "lucide-react"; import Image from "next/image"; import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import type { ActionResult } from "@/shared/types"; import ValidationErrors from "@/shared/ui/components/ValidationErrors"; @@ -53,6 +54,8 @@ type OrgFormProps = { */ export function OrgForm({ action, defaultValues, isUpdate }: OrgFormProps) { const router = useRouter(); + const t = useTranslations("OrgForm"); + const tEnum = useTranslations("Enums.OrgType"); const [isSubmitting, setIsSubmitting] = useState(false); const [actionError, setActionError] = useState(null); @@ -109,10 +112,10 @@ export function OrgForm({ action, defaultValues, isUpdate }: OrgFormProps) {

- Informasi Dasar + {t("basicInfoTitle")}

- Detail struktural dan penamaan organisasi. + {t("basicInfoDesc")}

@@ -125,7 +128,7 @@ export function OrgForm({ action, defaultValues, isUpdate }: OrgFormProps) { htmlFor={field.name} className="text-sm font-semibold text-[#233345] font-body" > - Nama Organisasi + {t("nameLabel")} field.handleChange(e.target.value)} className="w-full px-4 py-2.5 bg-[#f3f4f6] border-none rounded-lg focus-visible:ring-2 focus-visible:ring-[#a23c29]/20 border-b-2 border-[#74777d] focus-visible:border-[#a23c29] transition-all text-sm placeholder:text-[#c4c6cc]" - placeholder="Masukkan nama organisasi..." + placeholder={t("namePlaceholder")} /> {field.state.meta.errors.length > 0 && ( @@ -153,7 +156,7 @@ export function OrgForm({ action, defaultValues, isUpdate }: OrgFormProps) { htmlFor={field.name} className="text-sm font-semibold text-[#233345] font-body" > - Jenis Organisasi + {t("typeLabel")} @@ -198,7 +205,7 @@ export function OrgForm({ action, defaultValues, isUpdate }: OrgFormProps) { htmlFor={field.name} className="text-sm font-semibold text-[#233345] font-body" > - Organisasi Induk + {t("parentLabel")} - Batal + {t("btnCancel")}
@@ -250,10 +257,10 @@ export function OrgForm({ action, defaultValues, isUpdate }: OrgFormProps) {
diff --git a/apps/dash/src/pages/org/ui/components/OrganizationList.tsx b/apps/dash/src/pages/org/ui/components/OrganizationList.tsx index bf8cbfb..c201b4c 100644 --- a/apps/dash/src/pages/org/ui/components/OrganizationList.tsx +++ b/apps/dash/src/pages/org/ui/components/OrganizationList.tsx @@ -62,7 +62,7 @@ export function OrganizationList({ onClick={() => window.location.reload()} className="rounded-xl px-8 h-11 hover:bg-destructive/5 hover:text-destructive hover:border-destructive/30 transition-all font-medium" > - Coba Lagi + {t("retry")} @@ -82,7 +82,7 @@ export function OrganizationList({ {t("emptyState")}

- Coba sesuaikan kata kunci atau filter Anda. + {t("emptyStateDesc")}

{(searchQuery || selectedType !== "all") && ( diff --git a/apps/dash/src/pages/org/ui/components/UnitForm.tsx b/apps/dash/src/pages/org/ui/components/UnitForm.tsx index 3fbfb37..c342a70 100644 --- a/apps/dash/src/pages/org/ui/components/UnitForm.tsx +++ b/apps/dash/src/pages/org/ui/components/UnitForm.tsx @@ -8,6 +8,7 @@ import { } from "@domus/core"; import { useForm } from "@tanstack/react-form"; import { Check, X } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import type { ActionResult } from "@/shared/types"; import ValidationErrors from "@/shared/ui/components/ValidationErrors"; @@ -60,6 +61,7 @@ export function UnitForm({ onSuccess, onCancel, }: UnitFormProps) { + const t = useTranslations("UnitForm"); const [isSubmitting, setIsSubmitting] = useState(false); const [actionError, setActionError] = useState(null); @@ -115,7 +117,7 @@ export function UnitForm({ htmlFor={field.name} className="text-sm font-semibold text-[#233345]" > - Nama Unit + {t("nameLabel")} field.handleChange(e.target.value)} - placeholder="Masukkan nama unit (misal: Seksi Kerohanian)" + placeholder={t("namePlaceholder")} className="w-full" /> {field.state.meta.errors.length > 0 && ( @@ -149,7 +151,7 @@ export function UnitForm({ htmlFor={field.name} className="text-sm font-semibold text-[#233345]" > - Deskripsi (Opsional) + {t("descLabel")}