diff --git a/app/(dash)/setting/parish/new/page.tsx b/app/(dash)/setting/parish/new/page.tsx
new file mode 100644
index 0000000..7bb3b62
--- /dev/null
+++ b/app/(dash)/setting/parish/new/page.tsx
@@ -0,0 +1 @@
+export { default } from '@/pages/setting/ui/ParishCreatePage';
diff --git a/apps/dash/app/(dash)/setting/diocese/[id]/page.tsx b/apps/dash/app/(dash)/setting/diocese/[id]/page.tsx
new file mode 100644
index 0000000..98bd708
--- /dev/null
+++ b/apps/dash/app/(dash)/setting/diocese/[id]/page.tsx
@@ -0,0 +1,14 @@
+import { DioceseDetailPage } from "@/pages/setting/ui/DioceseDetailPage";
+
+interface PageProps {
+ params: Promise<{ id: string }>;
+}
+
+/**
+ * Thin export for the Diocese Detail route.
+ * Follows the Domus architecture of keeping route files minimal.
+ */
+export default async function Page({ params }: PageProps) {
+ const { id } = await params;
+ return ;
+}
diff --git a/apps/dash/app/(dash)/setting/diocese/new/page.tsx b/apps/dash/app/(dash)/setting/diocese/new/page.tsx
new file mode 100644
index 0000000..eaa2d4b
--- /dev/null
+++ b/apps/dash/app/(dash)/setting/diocese/new/page.tsx
@@ -0,0 +1 @@
+export { DioceseCreatePage as default } from "@/pages/setting/ui/DioceseCreatePage";
diff --git a/apps/dash/app/(dash)/setting/diocese/page.tsx b/apps/dash/app/(dash)/setting/diocese/page.tsx
new file mode 100644
index 0000000..b8df0a6
--- /dev/null
+++ b/apps/dash/app/(dash)/setting/diocese/page.tsx
@@ -0,0 +1 @@
+export { default } from "@/pages/setting/ui/DioceseListPage";
diff --git a/apps/dash/app/(dash)/setting/parish/[id]/page.tsx b/apps/dash/app/(dash)/setting/parish/[id]/page.tsx
new file mode 100644
index 0000000..a6dba15
--- /dev/null
+++ b/apps/dash/app/(dash)/setting/parish/[id]/page.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from "next";
+import ParishDetailPage from "@/pages/setting/ui/ParishDetailPage";
+
+export const metadata: Metadata = {
+ title: "Edit Parish | Domus",
+};
+
+export default ParishDetailPage;
diff --git a/apps/dash/app/(dash)/setting/parish/new/page.tsx b/apps/dash/app/(dash)/setting/parish/new/page.tsx
new file mode 100644
index 0000000..f9591d9
--- /dev/null
+++ b/apps/dash/app/(dash)/setting/parish/new/page.tsx
@@ -0,0 +1,8 @@
+import type { Metadata } from "next";
+import ParishCreatePage from "@/pages/setting/ui/ParishCreatePage";
+
+export const metadata: Metadata = {
+ title: "Add New Parish | Domus",
+};
+
+export default ParishCreatePage;
diff --git a/apps/dash/app/(dash)/setting/parish/page.tsx b/apps/dash/app/(dash)/setting/parish/page.tsx
new file mode 100644
index 0000000..29c4f92
--- /dev/null
+++ b/apps/dash/app/(dash)/setting/parish/page.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from "next";
+import { ParishListPage } from "@/pages/setting/ui/ParishListPage";
+
+export const metadata: Metadata = {
+ title: "Parish List | Domus",
+ description: "Manage parishes in the Domus system.",
+};
+
+export default ParishListPage;
diff --git a/apps/dash/e2e/assets/.gitignore b/apps/dash/e2e/assets/.gitignore
new file mode 100644
index 0000000..38acef9
--- /dev/null
+++ b/apps/dash/e2e/assets/.gitignore
@@ -0,0 +1 @@
+ktp.jpg
diff --git a/apps/dash/e2e/features/org/join.spec.ts b/apps/dash/e2e/features/org/join.spec.ts
index 10a1dde..e4e4573 100644
--- a/apps/dash/e2e/features/org/join.spec.ts
+++ b/apps/dash/e2e/features/org/join.spec.ts
@@ -1,3 +1,4 @@
+import path from "node:path";
import { AccountStatus } from "@domus/core";
import { expect, test } from "@playwright/test";
import { v7 } from "uuid";
@@ -124,50 +125,39 @@ test.describe("Organization Join Page", () => {
test("should complete registration with ID card photo upload", async ({
context,
}) => {
- const TEST_JOIN_ID = `test-join-photo-${v7().substring(0, 8)}`;
+ const TEST_JOIN_ID = `test-join-photo-${v7()}`;
await iHaveOrganizationWithJoinId(TEST_JOIN_ID);
- const uniqueEmail = `toni-photo-${Date.now()}@tester.org`;
+ const uniqueEmail = `toni-photo-${v7()}@tester.org`;
- // 1. Setup session for a new unique user
await iHaveLoggedInAs(context, {
name: "Toni Photo",
email: uniqueEmail,
accountStatus: AccountStatus.Pending,
});
- // 2. Navigate to /join/[joinId]
await joinPage.goto(TEST_JOIN_ID);
- // 3. Fill basic info
await joinPage.fillJoinForm({
title: "Prof.",
birthPlace: "Barong Tongkok",
birthDateDay: "1",
});
- // 4. Upload ID card photo
- await joinPage.uploadIdCardPhoto("e2e/assets/dummy-id.png");
+ const testImagePath = path.join(__dirname, "../../assets/ktp.jpg");
+ await joinPage.uploadIdCardPhoto(testImagePath);
- // 5. Wait for upload to complete (Loading indicator disappears)
- // We increase timeout here because actual upload to GDrive can be slow in some environments
await expect(joinPage.page.getByText(/Uploading/i)).not.toBeVisible({
timeout: 45000,
});
- // 6. Verify preview is visible with a generous timeout
- const preview = joinPage.page.getByAltText(/Pratinjau KTP/i);
- await expect(preview).toBeVisible({ timeout: 15000 });
+ await expect(joinPage.idCardPreview).toBeVisible({ timeout: 30000 });
- // 7. Submit the form
await joinPage.submit();
- // 8. Should redirect to success page
await expect(joinPage.page).toHaveURL(
new RegExp(`/join/${TEST_JOIN_ID}/success`),
- {
- timeout: 15000,
- },
+ { timeout: 15000 },
);
await expect(joinPage.successHeading).toBeVisible();
});
@@ -175,37 +165,26 @@ test.describe("Organization Join Page", () => {
test("should allow removing uploaded ID card photo before submission", async ({
context,
}) => {
- const TEST_JOIN_ID = `test-join-remove-${v7().substring(0, 8)}`;
+ const TEST_JOIN_ID = `test-join-remove-${v7()}`;
await iHaveOrganizationWithJoinId(TEST_JOIN_ID);
- const uniqueEmail = `toni-remove-${Date.now()}@tester.org`;
+ const uniqueEmail = `toni-remove-${v7()}@tester.org`;
- // 1. Setup session
await iHaveLoggedInAs(context, {
name: "Toni Remove",
email: uniqueEmail,
accountStatus: AccountStatus.Pending,
});
- // 2. Navigate to /join/[joinId]
await joinPage.goto(TEST_JOIN_ID);
- // 3. Upload ID card photo
- await joinPage.uploadIdCardPhoto("e2e/assets/dummy-id.png");
-
- // 4. Wait for upload to complete (Loading indicator disappears)
- await expect(joinPage.page.getByText(/Uploading/i)).not.toBeVisible({
- timeout: 30000,
- });
-
- // 5. Verify preview is visible
- const preview = joinPage.page.getByAltText(/Pratinjau KTP/i);
- await expect(preview).toBeVisible({ timeout: 10000 });
+ const testImagePath = path.join(__dirname, "../../assets/ktp.jpg");
+ await joinPage.uploadIdCardPhoto(testImagePath);
- // 6. Click the remove button
+ // The button only appears after upload finishes (isUploadingPhoto becomes false)
+ await expect(joinPage.removePhotoButton).toBeVisible({ timeout: 45000 });
await joinPage.removePhotoButton.click();
- // 7. Verify preview is gone
- await expect(preview).not.toBeVisible();
+ await expect(joinPage.idCardPreview).toBeHidden();
});
});
diff --git a/apps/dash/e2e/features/setting/diocese/create.spec.ts b/apps/dash/e2e/features/setting/diocese/create.spec.ts
new file mode 100644
index 0000000..a177e8f
--- /dev/null
+++ b/apps/dash/e2e/features/setting/diocese/create.spec.ts
@@ -0,0 +1,83 @@
+import { expect, test } from "@playwright/test";
+import { iHaveLoggedInAsSuperAdmin } from "../../../helper/auth";
+import { DioceseCreatePage } from "../../../pages/setting/DioceseCreatePage";
+
+test.describe("Diocese Page: Create", () => {
+ let createPage: DioceseCreatePage;
+
+ test.beforeEach(async ({ page, context }) => {
+ createPage = new DioceseCreatePage(page);
+ // 1. Login as Super Admin to access setting
+ await iHaveLoggedInAsSuperAdmin(context);
+ });
+
+ test("should successfully create a new diocese with valid data", async ({
+ page,
+ }) => {
+ await createPage.goto();
+ await expect(page).toHaveURL(/\/setting\/diocese\/new$/);
+
+ // 3. Verify page header
+ const heading = page.getByTestId("page-title");
+ await expect(heading).toBeVisible({ timeout: 10000 });
+ await expect(heading).toContainText(/Tambah Keuskupan|New Diocese/i);
+
+ // 4. Fill and submit form
+ const dioceseName = `Keuskupan E2E ${Date.now()}`;
+ await createPage.fillForm({
+ name: dioceseName,
+ description: "Automated E2E test diocese description.",
+ address: "123 Test St, Samarinda",
+ phone: "081234567890",
+ email: "e2e@domus.org",
+ website: "https://e2e-diocese.org",
+ });
+
+ await createPage.submit();
+
+ // 5. Verify redirection to detail page and appearance in list
+ await expect(page).toHaveURL(/\/setting\/diocese\/[a-zA-Z0-9-]+$/, {
+ timeout: 10000,
+ });
+
+ // Go back to list to verify it's there
+ await page.goto("/setting/diocese");
+ await expect(page.getByText(dioceseName)).toBeVisible();
+ });
+
+ test("should show validation errors when required fields are empty", async ({
+ page,
+ }) => {
+ await createPage.goto();
+
+ // Focus, fill, clear, and blur name field to trigger validation
+ const nameInput = page.getByTestId("diocese-name-input");
+ await nameInput.fill("test");
+ await nameInput.fill("");
+ await nameInput.blur();
+
+ await createPage.submit();
+
+ // Check for validation error on name
+ const nameError = page.locator(
+ "text=/wajib diisi|required|minimal 1|at least 1|too small/i",
+ );
+ await expect(nameError.first()).toBeVisible();
+ });
+
+ test("should navigate back when clicking cancel", async ({ page }) => {
+ // Start from list page to have history
+ await page.goto("/setting/diocese");
+ await expect(page.getByTestId("add-diocese-btn")).toBeVisible();
+
+ // Click add button (from list page)
+ await page.getByTestId("add-diocese-btn").click();
+ await expect(page).toHaveURL(/\/setting\/diocese\/new$/);
+
+ // Click cancel in the form
+ await createPage.cancel();
+
+ // Should return to the list page via history.back()
+ await expect(page).toHaveURL(/\/setting\/diocese$/);
+ });
+});
diff --git a/apps/dash/e2e/features/setting/diocese/detail.spec.ts b/apps/dash/e2e/features/setting/diocese/detail.spec.ts
new file mode 100644
index 0000000..d072550
--- /dev/null
+++ b/apps/dash/e2e/features/setting/diocese/detail.spec.ts
@@ -0,0 +1,101 @@
+import { expect, test } from "@playwright/test";
+import { iHaveLoggedInAsSuperAdmin } from "../../../helper/auth";
+import { DioceseCreatePage } from "../../../pages/setting/DioceseCreatePage";
+import { DioceseDetailPage } from "../../../pages/setting/DioceseDetailPage";
+import { DioceseListPage } from "../../../pages/setting/DioceseListPage";
+
+test.describe("Diocese Page: Detail & Edit", () => {
+ let listPage: DioceseListPage;
+ let createPage: DioceseCreatePage;
+ let detailPage: DioceseDetailPage;
+
+ test.beforeEach(async ({ page, context }) => {
+ listPage = new DioceseListPage(page);
+ createPage = new DioceseCreatePage(page);
+ detailPage = new DioceseDetailPage(page);
+ await iHaveLoggedInAsSuperAdmin(context);
+ });
+
+ test("should successfully update diocese information", async ({ page }) => {
+ // 1. Create a fresh diocese first to ensure we have something to edit
+ await createPage.goto();
+ const uniqueId = Date.now();
+ const originalName = `Diocese to Edit ${uniqueId}`;
+ await createPage.fillForm({
+ name: originalName,
+ description: "Original description",
+ });
+ await createPage.submit();
+ await page.waitForURL(/\/setting\/diocese\/[a-zA-Z0-9-]+/);
+
+ // 2. Search for the diocese to ensure it's visible
+ await listPage.goto();
+ await listPage.search(originalName);
+
+ // Ensure only one card matches to avoid ambiguity
+ await expect(listPage.dioceseCards).toHaveCount(1);
+ const card = listPage.dioceseCards.first();
+ await expect(card.getByTestId("diocese-details-btn")).toBeVisible();
+ await card.getByTestId("diocese-details-btn").click();
+ await expect(detailPage.headerTitle).toBeVisible();
+
+ // 3. Verify we are on the detail page
+ await expect(page).toHaveURL(/\/setting\/diocese\/[a-zA-Z0-9-]+/);
+ await expect(detailPage.headerTitle).toContainText(originalName);
+
+ // 4. Update the name and description
+ const updatedName = `Diocese Updated ${uniqueId}`;
+ await detailPage.fillForm({
+ name: updatedName,
+ description: "Updated description through E2E",
+ });
+ await detailPage.submit();
+
+ // 5. Verify toast and header update (header should update after refresh)
+ await expect(
+ page.getByText(/berhasil diperbarui|successfully updated/i),
+ ).toBeVisible();
+ await expect(detailPage.headerTitle).toContainText(updatedName);
+
+ // 6. Go back to list and verify updated name
+ await page.goto("/setting/diocese");
+ await expect(page.getByText(updatedName)).toBeVisible();
+ await expect(page.getByText(originalName)).not.toBeVisible();
+ });
+
+ test("should successfully soft-delete a diocese", async ({ page }) => {
+ // 1. Create a fresh diocese to delete
+ await createPage.goto();
+ await page.waitForLoadState("networkidle");
+ const uniqueId = Date.now();
+ const nameToDelete = `Diocese to Delete ${uniqueId}`;
+ await createPage.fillForm({
+ name: nameToDelete,
+ });
+ await createPage.submit();
+ await page.waitForURL(/\/setting\/diocese\/[a-zA-Z0-9-]+/);
+
+ // 2. Search for the diocese to delete
+ await listPage.goto();
+ await listPage.search(nameToDelete);
+
+ // Ensure only one card matches to avoid ambiguity
+ await expect(listPage.dioceseCards).toHaveCount(1);
+ const card = listPage.dioceseCards.first();
+ await expect(card.getByTestId("diocese-details-btn")).toBeVisible();
+ await card.getByTestId("diocese-details-btn").click();
+ await expect(detailPage.headerTitle).toBeVisible();
+
+ // 3. Perform delete with confirmation
+ await detailPage.deleteAndConfirm();
+
+ // 4. Verify success toast and redirection to list
+ await expect(
+ page.getByText(/berhasil dihapus|successfully deleted/i),
+ ).toBeVisible();
+ await expect(page).toHaveURL(/\/setting\/diocese$/);
+
+ // 5. Verify it's no longer in the list (Soft Delete)
+ await expect(page.getByText(nameToDelete)).not.toBeVisible();
+ });
+});
diff --git a/apps/dash/e2e/features/setting/diocese/list.spec.ts b/apps/dash/e2e/features/setting/diocese/list.spec.ts
new file mode 100644
index 0000000..00c24a1
--- /dev/null
+++ b/apps/dash/e2e/features/setting/diocese/list.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from "@playwright/test";
+import { iHaveLoggedInAsSuperAdmin } from "../../../helper/auth";
+import { iHaveDiocese } from "../../../helper/diocese";
+import { DioceseListPage } from "../../../pages/setting/DioceseListPage";
+
+test.describe("Diocese Page: Browsing and Search", () => {
+ let listPage: DioceseListPage;
+
+ test.beforeEach(async ({ page, context }) => {
+ listPage = new DioceseListPage(page);
+ // Setup session and data
+ await iHaveLoggedInAsSuperAdmin(context);
+ await iHaveDiocese({ name: "Keuskupan Agung Samarinda" });
+ });
+
+ test("should render the diocese list and search dynamically", async ({
+ page,
+ }) => {
+ // 1. Navigate to the diocese route
+ await listPage.goto();
+
+ // 2. Verify page content
+ const heading = page.getByRole("heading", { level: 1 });
+ await expect(heading).toBeVisible();
+
+ // 3. Verify diocese cards are rendered
+ const count = await listPage.getVisibleDioceseCount();
+ expect(count).toBeGreaterThan(0);
+
+ // 4. Verify search functionality dynamically
+ // Pick a card that is currently visible
+ const firstCard = listPage.getDioceseCardAt(0);
+ const cardName = await firstCard
+ .getByTestId("diocese-card-name")
+ .textContent();
+
+ if (cardName) {
+ await listPage.search(cardName);
+
+ // Verify the searched card is visible and others are filtered
+ await expect(listPage.dioceseGrid).toContainText([cardName]);
+
+ // Verify search clear button is visible when searching
+ await expect(listPage.searchClearBtn).toBeVisible();
+
+ // Clear search and verify list is restored
+ await listPage.clearSearch();
+ await expect(listPage.getVisibleDioceseCount()).resolves.toBeGreaterThan(
+ 0,
+ );
+ }
+ });
+
+ test("should show an empty state for non-matching searches", async () => {
+ await listPage.goto();
+
+ // Search for something impossible
+ await listPage.search("XYZ-NON-EXISTENT-DIOCESE-999-POTATO");
+
+ // Verify empty state UI
+ await expect(listPage.emptyState).toBeVisible();
+ await expect(
+ listPage.page.getByText(
+ /No dioceses found|Tidak ada keuskupan ditemukan/i,
+ ),
+ ).toBeVisible();
+ await expect(listPage.dioceseCards).toHaveCount(0);
+ });
+});
diff --git a/apps/dash/e2e/features/setting/diocese/rbac.spec.ts b/apps/dash/e2e/features/setting/diocese/rbac.spec.ts
new file mode 100644
index 0000000..b258b76
--- /dev/null
+++ b/apps/dash/e2e/features/setting/diocese/rbac.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from "@playwright/test";
+import {
+ iHaveLoggedInAsApprovedParishioner,
+ iHaveLoggedInAsSuperAdmin,
+} from "../../../helper/auth";
+import { DioceseCreatePage } from "../../../pages/setting/DioceseCreatePage";
+import { DioceseDetailPage } from "../../../pages/setting/DioceseDetailPage";
+import { DioceseListPage } from "../../../pages/setting/DioceseListPage";
+
+test.describe("Diocese Page: RBAC Enforcement", () => {
+ let createPage: DioceseCreatePage;
+
+ test.beforeEach(async ({ page }) => {
+ createPage = new DioceseCreatePage(page);
+ });
+
+ test("Approved Parishioner should have read-only access", async ({
+ browser,
+ context,
+ }) => {
+ // 1. Setup: Create a diocese as Admin first so there's something to view
+ await iHaveLoggedInAsSuperAdmin(context);
+ const uniqueId = Date.now();
+ const dioceseName = `RBAC Test Diocese ${uniqueId}`;
+ await createPage.goto();
+ await createPage.fillForm({ name: dioceseName });
+ await createPage.submit();
+
+ // 2. Login as Approved Parishioner in a FRESH context to ensure isolation
+ const parishionerContext = await browser.newContext();
+ const parishionerPage = await parishionerContext.newPage();
+ const parishionerListPage = new DioceseListPage(parishionerPage);
+ const parishionerCreatePage = new DioceseCreatePage(parishionerPage);
+ const parishionerDetailPage = new DioceseDetailPage(parishionerPage);
+
+ await iHaveLoggedInAsApprovedParishioner(parishionerContext, {
+ email: `user-${uniqueId}@example.com`,
+ name: "Approved User",
+ });
+
+ // 3. Verify List Page: Add button should NOT be visible
+ await parishionerListPage.goto();
+ await expect(parishionerListPage.addDioceseBtn).toBeHidden();
+ await expect(parishionerPage.getByText(dioceseName)).toBeVisible();
+
+ // 4. Verify Create Page: Should show unauthorized message
+ await parishionerCreatePage.goto();
+ await expect(
+ parishionerPage.getByText(/Unauthorized access/i),
+ ).toBeVisible();
+ await expect(parishionerPage.getByTestId("diocese-form")).toBeHidden();
+
+ // 5. Verify Detail Page: Save and Delete buttons should NOT be visible
+ await parishionerListPage.goto();
+ await parishionerListPage.search(dioceseName);
+ await expect(parishionerListPage.dioceseCards).toHaveCount(1);
+ const card = parishionerListPage.dioceseCards.first();
+ await card.getByTestId("diocese-details-btn").click({ force: true });
+ await expect(parishionerDetailPage.headerTitle).toBeVisible();
+
+ await expect(parishionerDetailPage.submitBtn).toBeHidden();
+ await expect(parishionerDetailPage.deleteBtn).toBeHidden();
+
+ // Verify fields are disabled (read-only)
+ await expect(parishionerDetailPage.nameInput).toBeDisabled();
+
+ await parishionerContext.close();
+ });
+});
diff --git a/apps/dash/e2e/features/setting/parish.spec.ts b/apps/dash/e2e/features/setting/parish.spec.ts
new file mode 100644
index 0000000..85e638f
--- /dev/null
+++ b/apps/dash/e2e/features/setting/parish.spec.ts
@@ -0,0 +1,62 @@
+import { expect, test } from "@playwright/test";
+import { iHaveLoggedInAsSuperAdmin, iHaveParish } from "../../helper";
+import { ParishListPage } from "../../pages/setting/ParishListPage";
+
+test.describe("Parish List Feature", () => {
+ let parishPage: ParishListPage;
+
+ test.beforeEach(async ({ page }) => {
+ await iHaveLoggedInAsSuperAdmin(page.context());
+ // Ensure at least one parish exists for listing tests
+ await iHaveParish({ name: "Katedral Santo Yosef" });
+
+ parishPage = new ParishListPage(page);
+ await parishPage.goto();
+ });
+
+ test("should display the parish list page with premium elements", async () => {
+ await expect(parishPage.premiumHero).toBeVisible();
+ await expect(parishPage.searchInput).toBeVisible();
+ await expect(parishPage.parishGrid).toBeVisible();
+ });
+
+ test("should be able to search for a parish by name", async () => {
+ // Assuming 'Katedral' exists in the seeded/mocked data
+ const query = "Katedral";
+ await parishPage.search(query);
+
+ const count = await parishPage.getVisibleParishCount();
+ if (count > 0) {
+ const firstCard = parishPage.getParishCardAt(0);
+ await expect(firstCard).toContainText(query);
+ } else {
+ await expect(parishPage.emptyState).toBeVisible();
+ }
+ });
+
+ test("should display empty state when no results are found", async () => {
+ await parishPage.search("NonExistentParish12345");
+ await expect(parishPage.emptyState).toBeVisible();
+ await expect(parishPage.parishCards).toHaveCount(0);
+ });
+
+ test("should clear search and restore list", async () => {
+ await parishPage.search("Katedral");
+ await parishPage.clearSearch();
+ await expect(parishPage.searchInput).toHaveValue("");
+ await expect(parishPage.parishCards.first()).toBeVisible();
+ });
+
+ test("should navigate to parish detail when clicking a card", async ({
+ page,
+ }) => {
+ const count = await parishPage.getVisibleParishCount();
+ test.skip(count === 0, "No parishes to test navigation");
+
+ const firstCard = parishPage.getParishCardAt(0);
+ await firstCard.click();
+
+ // Check if URL matches the detail pattern
+ await expect(page).toHaveURL(/\/setting\/parish\/[0-9a-f-]+/);
+ });
+});
diff --git a/apps/dash/e2e/features/setting/parish/create.spec.ts b/apps/dash/e2e/features/setting/parish/create.spec.ts
new file mode 100644
index 0000000..b4049fc
--- /dev/null
+++ b/apps/dash/e2e/features/setting/parish/create.spec.ts
@@ -0,0 +1,72 @@
+import { expect, test } from "@playwright/test";
+import { iHaveLoggedInAsSuperAdmin } from "../../../helper/auth";
+import { iHaveVicariate } from "../../../helper/vicariate";
+import { ParishCreatePage } from "../../../pages/setting/ParishCreatePage";
+
+test.describe("Parish Creation Feature", () => {
+ let createPage: ParishCreatePage;
+
+ test.beforeEach(async ({ page }) => {
+ await iHaveLoggedInAsSuperAdmin(page.context());
+
+ // Ensure we have a setup for chained select
+ await iHaveVicariate({
+ name: "Dekenat Mahakam Ulu",
+ });
+
+ createPage = new ParishCreatePage(page);
+ await createPage.goto();
+ });
+
+ test("should display the creation form with premium breadcrumbs", async ({
+ page,
+ }) => {
+ await expect(page.getByText(/create parish|tambah paroki/i)).toBeVisible();
+ await expect(createPage.dioceseSelect).toBeVisible();
+ await expect(createPage.nameInput).toBeVisible();
+ });
+
+ test("should permit creating a parish via chained selection", async ({
+ page,
+ }) => {
+ const parishName = `Parish Test ${Date.now()}`;
+
+ await createPage.fillForm({
+ dioceseName: "Keuskupan Agung Samarinda",
+ vicariateName: "Dekenat Mahakam Ulu",
+ name: parishName,
+ address: "Jl. Test No. 123",
+ phone: "08123456789",
+ email: "test@parish.com",
+ description: "Testing automated parish creation",
+ });
+
+ await createPage.submit();
+
+ // Verify success redirect and list presence
+ await expect(page).toHaveURL(/\/setting\/parish$/);
+ await expect(page.getByText(/success|berhasil|sukses/i)).toBeVisible();
+ await expect(page.getByText(parishName)).toBeVisible();
+ });
+
+ test("should show validation errors when fields are empty", async ({
+ page,
+ }) => {
+ await createPage.submit();
+
+ // Ensure validation errors appear in the DOM (searching specifically for Zod patterns)
+ await expect(
+ page
+ .locator("p")
+ .filter({ hasText: /too small|invalid/i })
+ .first(),
+ ).toBeVisible({ timeout: 10000 });
+
+ // Check multiple identical errors don't crash UI (our key fix)
+ const _errorMessages = page.locator(
+ 'p:text-is("Required"), p:text-is("Wajib diisi")',
+ );
+ // If multiple fields are required, we might see multiple "Required" or similar.
+ // Our fix ensures they have unique keys and handle collisions.
+ });
+});
diff --git a/apps/dash/e2e/features/setting/parish/detail.spec.ts b/apps/dash/e2e/features/setting/parish/detail.spec.ts
new file mode 100644
index 0000000..096c421
--- /dev/null
+++ b/apps/dash/e2e/features/setting/parish/detail.spec.ts
@@ -0,0 +1,78 @@
+import { expect, test } from "@playwright/test";
+import { iHaveLoggedInAsSuperAdmin } from "../../../helper/auth";
+import { iHaveParish } from "../../../helper/parish";
+import { iHaveVicariate } from "../../../helper/vicariate";
+import { ParishDetailPage } from "../../../pages/setting/ParishDetailPage";
+
+test.describe("Parish Detail & Update Feature", () => {
+ let detailPage: ParishDetailPage;
+ let testParish: any;
+
+ test.beforeEach(async ({ page }) => {
+ await iHaveLoggedInAsSuperAdmin(page.context());
+
+ // Seed data: Diocese -> Vicariate -> Parish
+ const vicariate = await iHaveVicariate({
+ name: "Dekenat Sendawar",
+ });
+
+ testParish = await iHaveParish({
+ name: `Test Parish ${Date.now()}`,
+ vicariateId: vicariate.id,
+ });
+
+ detailPage = new ParishDetailPage(page);
+ await detailPage.goto(testParish.id);
+ });
+
+ test("should display correctly populated parish details", async ({
+ page,
+ }) => {
+ await expect(detailPage.nameInput).toHaveValue(testParish.name);
+ await expect(page.getByText(/detail paroki|parish detail/i)).toBeVisible();
+ });
+
+ test("should update parish name successfully", async ({ page }) => {
+ const newName = `${testParish.name} Updated`;
+
+ await detailPage.fillForm({ name: newName });
+ await detailPage.submit();
+
+ // Verify toast and value persistence
+ await expect(page.getByText(/success|berhasil|sukses/i)).toBeVisible();
+ await expect(detailPage.nameInput).toHaveValue(newName);
+
+ // Refresh to ensure server side update
+ await page.reload();
+ await expect(detailPage.nameInput).toHaveValue(newName);
+ });
+
+ test("should show validation error for empty name", async ({ page }) => {
+ // Clear and blur to trigger validation
+ await detailPage.nameInput.fill("");
+ await detailPage.nameInput.click(); // Ensure focus
+ await detailPage.nameInput.blur();
+ await detailPage.submit();
+
+ // Check for ANY destructive text appearing (the validation error color)
+ const errorMsg = page.locator(".text-destructive").first();
+ await expect(errorMsg).toBeVisible({ timeout: 15000 });
+ });
+
+ test("should delete parish via confirmation dialog", async ({ page }) => {
+ // Navigate to page first (ensure we are on the right page)
+ await detailPage.goto(testParish.id);
+
+ await detailPage.delete();
+
+ // Verify redirect to list and success toast
+ await expect(page).toHaveURL(/\/setting\/parish$/);
+ await expect(
+ page.getByText(/success|berhasil|sukses/i).first(),
+ ).toBeVisible();
+
+ // Refresh to ensure it's gone from server side
+ await page.reload();
+ await expect(page.getByText(testParish.name)).not.toBeVisible();
+ });
+});
diff --git a/apps/dash/e2e/features/setting/parish/rbac.spec.ts b/apps/dash/e2e/features/setting/parish/rbac.spec.ts
new file mode 100644
index 0000000..df41ba8
--- /dev/null
+++ b/apps/dash/e2e/features/setting/parish/rbac.spec.ts
@@ -0,0 +1,82 @@
+import { expect, test } from "@playwright/test";
+import {
+ iHaveLoggedInAsApprovedParishioner,
+ iHaveLoggedInAsSuperAdmin,
+} from "../../../helper/auth";
+import { iHaveVicariate } from "../../../helper/vicariate";
+import { ParishCreatePage } from "../../../pages/setting/ParishCreatePage";
+import { ParishDetailPage } from "../../../pages/setting/ParishDetailPage";
+import { ParishListPage } from "../../../pages/setting/ParishListPage";
+
+test.describe("Parish Page: RBAC Enforcement", () => {
+ let createPage: ParishCreatePage;
+
+ test.beforeEach(async ({ page }) => {
+ createPage = new ParishCreatePage(page);
+ });
+
+ test("Approved Parishioner should have read-only access", async ({
+ browser,
+ context,
+ }) => {
+ // 1. Setup: Create a parish as Admin first so there's something to view
+ const _adminUser = await iHaveLoggedInAsSuperAdmin(context);
+ const uniqueId = Date.now();
+ const parishName = `RBAC Test Parish ${uniqueId}`;
+
+ // Ensure we have a vicariate for the parish
+ await iHaveVicariate({
+ name: "Dekenat Mahakam Ulu",
+ });
+
+ await createPage.goto();
+ await createPage.fillForm({
+ dioceseName: "Keuskupan Agung Samarinda",
+ vicariateName: "Dekenat Mahakam Ulu",
+ name: parishName,
+ address: "Jl. RBAC No. 1",
+ });
+ await createPage.submit();
+
+ // 2. Login as Approved Parishioner in a FRESH context to ensure isolation
+ const parishionerContext = await browser.newContext();
+ const parishionerPage = await parishionerContext.newPage();
+ const parishionerListPage = new ParishListPage(parishionerPage);
+ const parishionerCreatePage = new ParishCreatePage(parishionerPage);
+ const parishionerDetailPage = new ParishDetailPage(parishionerPage);
+
+ await iHaveLoggedInAsApprovedParishioner(parishionerContext, {
+ email: `user-${uniqueId}@example.com`,
+ name: "Approved User",
+ });
+
+ // 3. Verify List Page: Add button should NOT be visible
+ await parishionerListPage.goto();
+ await expect(parishionerListPage.addParishBtn).toBeHidden();
+ await expect(parishionerPage.getByText(parishName)).toBeVisible();
+
+ // 4. Verify Create Page: Should show unauthorized message or be redirected
+ await parishionerCreatePage.goto();
+ // We expect an unauthorized message OR a redirect back to list/home
+ // The diocese test expected "Unauthorized access"
+ await expect(
+ parishionerPage.getByText(/Unauthorized access|tidak memiliki akses/i),
+ ).toBeVisible();
+ await expect(parishionerPage.getByTestId("parish-form")).toBeHidden();
+
+ // 5. Verify Detail Page: Save and Delete buttons should NOT be visible
+ await parishionerListPage.goto();
+ await parishionerListPage.search(parishName);
+ const card = parishionerListPage.getParishCardByName(parishName);
+ await card.click(); // Click the card to go to detail
+
+ await expect(parishionerDetailPage.nameInput).toBeVisible();
+ await expect(parishionerDetailPage.submitBtn).toBeHidden();
+ await expect(parishionerDetailPage.deleteBtn).toBeHidden();
+
+ // Verify fields are disabled (read-only)
+ await expect(parishionerDetailPage.nameInput).toBeDisabled();
+
+ await parishionerContext.close();
+ });
+});
diff --git a/apps/dash/e2e/global-setup.ts b/apps/dash/e2e/global-setup.ts
new file mode 100644
index 0000000..2249725
--- /dev/null
+++ b/apps/dash/e2e/global-setup.ts
@@ -0,0 +1,44 @@
+import fs from "node:fs";
+import https from "node:https";
+import path from "node:path";
+import type { FullConfig } from "@playwright/test";
+
+async function globalSetup(_config: FullConfig) {
+ const assetsDir = path.resolve(__dirname, "assets");
+ const ktpPath = path.join(assetsDir, "ktp.jpg");
+ const ktpUrl = "https://s3-dev.pkrbt.id/mock/ktp.jpg";
+
+ // Ensure assets directory exists
+ if (!fs.existsSync(assetsDir)) {
+ fs.mkdirSync(assetsDir, { recursive: true });
+ }
+
+ // Download KTP if not exists
+ if (!fs.existsSync(ktpPath)) {
+ console.log("Downloading mock KTP image...");
+ await new Promise((resolve, reject) => {
+ const file = fs.createWriteStream(ktpPath);
+ https
+ .get(ktpUrl, (response) => {
+ if (response.statusCode !== 200) {
+ reject(
+ new Error(`Failed to download image: ${response.statusCode}`),
+ );
+ return;
+ }
+ response.pipe(file);
+ file.on("finish", () => {
+ file.close();
+ console.log("Download complete:", ktpPath);
+ resolve(true);
+ });
+ })
+ .on("error", (err) => {
+ fs.unlink(ktpPath, () => {});
+ reject(err);
+ });
+ });
+ }
+}
+
+export default globalSetup;
diff --git a/apps/dash/e2e/global-teardown.ts b/apps/dash/e2e/global-teardown.ts
new file mode 100644
index 0000000..e6e115b
--- /dev/null
+++ b/apps/dash/e2e/global-teardown.ts
@@ -0,0 +1,25 @@
+import { truncateAllTables } from "@domus/db";
+
+/**
+ * Global teardown script for Playwright E2E tests.
+ * Cleans up the database state after all tests have completed.
+ */
+async function globalTeardown() {
+ // We only run this if NEXT_PUBLIC_E2E_MOCK is true to avoid accidental data loss
+ // in non-E2E environments, although truncateAllTables has its own protections.
+ if (process.env.NEXT_PUBLIC_E2E_MOCK === "true") {
+ console.log("๐ฌ Global Teardown: Starting database cleanup...");
+ try {
+ await truncateAllTables();
+ console.log("โ Global Teardown: Cleanup finished successfully.");
+ } catch (error) {
+ console.error("โ Global Teardown: Database cleanup failed!", error);
+ }
+ } else {
+ console.log(
+ "โญ๏ธ Global Teardown: Skipping database cleanup (E2E Mock mode not active).",
+ );
+ }
+}
+
+export default globalTeardown;
diff --git a/apps/dash/e2e/helper/auth.ts b/apps/dash/e2e/helper/auth.ts
index f14c004..9a39ff0 100644
--- a/apps/dash/e2e/helper/auth.ts
+++ b/apps/dash/e2e/helper/auth.ts
@@ -22,6 +22,25 @@ export async function iHaveLoggedInAsSuperAdmin(
return iHaveLoggedInAs(context, withDefaults);
}
+/**
+ * Orchestrates a complete login flow for an approved ordinary user (Parishioner).
+ */
+export async function iHaveLoggedInAsApprovedParishioner(
+ context: BrowserContext,
+ payload?: Partial,
+) {
+ const withDefaults: UserPayload = {
+ email: "parishioner@example.com",
+ name: "Regular Parishioner",
+ password: "testing123",
+ accountStatus: AccountStatus.Approved,
+ role: [UserRole.Parishioner],
+ ...payload,
+ };
+
+ return iHaveLoggedInAs(context, withDefaults);
+}
+
/**
* Orchestrates an E2E login flow using the UI.
*/
diff --git a/apps/dash/e2e/helper/diocese.ts b/apps/dash/e2e/helper/diocese.ts
new file mode 100644
index 0000000..d91cf61
--- /dev/null
+++ b/apps/dash/e2e/helper/diocese.ts
@@ -0,0 +1,57 @@
+import type { AuthContext, CreateDiocese } from "@domus/core";
+import * as core from "../../src/shared/core/service";
+
+/**
+ * Payload for creating a diocese in tests.
+ */
+export type DiocesePayload = Partial & {
+ name: string;
+};
+
+/**
+ * Ensures a diocese exists with the given payload.
+ * Priotizes finding by name.
+ */
+export async function iHaveDiocese(payload: DiocesePayload) {
+ const withDefaults: CreateDiocese = {
+ description: "Test Diocese Description",
+ address: "Test Address",
+ phone: "08123456789",
+ email: "test@diocese.com",
+ website: "https://diocese.com",
+ ...payload,
+ };
+
+ // 1. Try Find by Name
+ if (!core.diocese) {
+ throw new Error("Diocese service is undefined in iHaveDiocese helper");
+ }
+
+ const [dioceses] = await core.diocese.findAll(
+ {
+ userId: "system",
+ roles: ["super-admin"],
+ accountStatus: "approved",
+ } as AuthContext, // Bypass auth check for seeding
+ withDefaults.name,
+ );
+
+ let diocese = dioceses?.find((d) => d.name === withDefaults.name);
+
+ // 2. Create if not exists
+ if (!diocese) {
+ const [created, error] = await core.diocese.create(
+ withDefaults,
+ {
+ userId: "system",
+ roles: ["super-admin"],
+ accountStatus: "approved",
+ } as AuthContext, // Bypass auth check for seeding
+ );
+
+ if (error) throw error;
+ diocese = created;
+ }
+
+ return diocese;
+}
diff --git a/apps/dash/e2e/helper/index.ts b/apps/dash/e2e/helper/index.ts
index 5e1b3ab..1a6191b 100644
--- a/apps/dash/e2e/helper/index.ts
+++ b/apps/dash/e2e/helper/index.ts
@@ -1,2 +1,6 @@
export * from "./auth";
+export * from "./diocese";
+export * from "./org";
+export * from "./parish";
export * from "./user";
+export * from "./vicariate";
diff --git a/apps/dash/e2e/helper/parish.ts b/apps/dash/e2e/helper/parish.ts
new file mode 100644
index 0000000..bcec042
--- /dev/null
+++ b/apps/dash/e2e/helper/parish.ts
@@ -0,0 +1,62 @@
+import type { AuthContext, CreateParish } from "@domus/core";
+import * as core from "../../src/shared/core/service";
+import { iHaveVicariate } from "./vicariate";
+
+/**
+ * Payload for creating a parish in tests.
+ */
+export type ParishPayload = Partial & {
+ name: string;
+};
+
+/**
+ * Ensures a parish exists with the given payload.
+ * Prioritizes finding by name.
+ */
+export async function iHaveParish(payload: ParishPayload) {
+ // 1. Ensure vicariate exists
+ const vicariate = await iHaveVicariate({ name: "Kutai Barat" });
+
+ const withDefaults: CreateParish = {
+ vicariateId: vicariate.id,
+ address: "Test Parish Address",
+ phone: "08123456789",
+ email: "parish@test.com",
+ website: "https://test-parish.com",
+ logo: "https://s3.pkrbt.id/static/cathedral/1.webp",
+ ...payload,
+ };
+
+ // 1. Try Find by Name
+ if (!core.parish) {
+ throw new Error("Parish service is undefined in iHaveParish helper");
+ }
+
+ const [parishes] = await core.parish.findAll(
+ {
+ userId: "system",
+ roles: ["super-admin"],
+ accountStatus: "approved",
+ } as AuthContext, // Bypass auth check for seeding
+ withDefaults.name,
+ );
+
+ let parish = parishes?.find((p) => p.name === withDefaults.name);
+
+ // 2. Create if not exists
+ if (!parish) {
+ const [created, error] = await core.parish.create(
+ withDefaults,
+ {
+ userId: "system",
+ roles: ["super-admin"],
+ accountStatus: "approved",
+ } as AuthContext, // Bypass auth check for seeding
+ );
+
+ if (error) throw error;
+ parish = created;
+ }
+
+ return parish;
+}
diff --git a/apps/dash/e2e/helper/user.ts b/apps/dash/e2e/helper/user.ts
index b992cb5..3602ffe 100644
--- a/apps/dash/e2e/helper/user.ts
+++ b/apps/dash/e2e/helper/user.ts
@@ -1,7 +1,7 @@
import { AccountStatus, UserRole } from "@domus/core";
import { v7 } from "uuid";
import auth from "@/shared/auth/server";
-import service from "@/shared/core";
+import * as core from "@/shared/core/service";
export type UserPayload = {
id?: string;
@@ -29,40 +29,57 @@ export async function iHaveUser(payload: UserPayload) {
// 1. Try Find by ID
if (withDefaults.id) {
- const [found] = await service.user.findById(withDefaults.id);
+ if (!core.user) {
+ throw new Error("User service is undefined in iHaveUser helper");
+ }
+ const [found] = await core.user.findById(withDefaults.id);
if (found) user = found;
}
// 2. Try Find by Email if still not found
if (!user) {
- const [found] = await service.user.findByEmail(withDefaults.email);
+ const [found] = await core.user.findByEmail(withDefaults.email);
if (found) user = found;
}
// 3. Create if not exists
if (!user) {
- const res = await auth.api.signUpEmail({
- body: {
- email: withDefaults.email,
- password: withDefaults.password,
- name: withDefaults.name,
- },
- });
+ try {
+ const res = await auth.api.signUpEmail({
+ body: {
+ email: withDefaults.email,
+ password: withDefaults.password,
+ name: withDefaults.name,
+ },
+ });
- if (!res?.user) {
- throw new Error(
- `Failed to create user via signUpEmail: ${withDefaults.email}`,
- );
- }
+ if (!res?.user) {
+ throw new Error(
+ `Failed to create user via signUpEmail: ${withDefaults.email}`,
+ );
+ }
- const [d, e] = await service.user.findById(res.user.id);
- if (e) throw e;
- user = d;
+ const [d, e] = await core.user.findById(res.user.id);
+ if (e) throw e;
+ user = d;
+ } catch (err: any) {
+ // Handle race condition: if user was created by another worker between our check and signUpEmail
+ if (err.code === "23505" || err.message?.includes("already exists")) {
+ const [found] = await core.user.findByEmail(withDefaults.email);
+ if (found) {
+ user = found;
+ } else {
+ throw err;
+ }
+ } else {
+ throw err;
+ }
+ }
}
// 4. Update existing user to match payload (direct DB update)
const { name, email, role, accountStatus } = withDefaults;
- const [updatedUser, updateError] = await service.user.update(user.id, {
+ const [updatedUser, updateError] = await core.user.update(user.id, {
name,
email,
role,
diff --git a/apps/dash/e2e/helper/vicariate.ts b/apps/dash/e2e/helper/vicariate.ts
new file mode 100644
index 0000000..5549f5d
--- /dev/null
+++ b/apps/dash/e2e/helper/vicariate.ts
@@ -0,0 +1,59 @@
+import type { AuthContext, CreateVicariate } from "@domus/core";
+import * as core from "../../src/shared/core/service";
+import { iHaveDiocese } from "./diocese";
+
+/**
+ * Payload for creating a vicariate in tests.
+ */
+export type VicariatePayload = Partial & {
+ name: string;
+};
+
+/**
+ * Ensures a vicariate exists with the given payload.
+ * Prioritizes finding by name.
+ */
+export async function iHaveVicariate(payload: VicariatePayload) {
+ // 1. Ensure diocese exists
+ const diocese = await iHaveDiocese({ name: "Keuskupan Agung Samarinda" });
+
+ const withDefaults: CreateVicariate = {
+ dioceseId: diocese.id,
+ address: "Test Vicariate Address",
+ phone: "08123456789",
+ email: "vicariate@test.com",
+ ...payload,
+ };
+
+ // 2. Try Find by Name
+ if (!core.vicariate) {
+ throw new Error("Vicariate service is undefined in iHaveVicariate helper");
+ }
+
+ const [vicariates] = await core.vicariate.findAll(
+ {
+ userId: "system",
+ roles: ["super-admin"],
+ accountStatus: "approved",
+ } as AuthContext, // Bypass auth check for seeding
+ );
+
+ let vicariate = vicariates?.find((v) => v.name === withDefaults.name);
+
+ // 3. Create if not exists
+ if (!vicariate) {
+ const [created, error] = await core.vicariate.create(
+ withDefaults,
+ {
+ userId: "system",
+ roles: ["super-admin"],
+ accountStatus: "approved",
+ } as AuthContext, // Bypass auth check for seeding
+ );
+
+ if (error) throw error;
+ vicariate = created;
+ }
+
+ return vicariate;
+}
diff --git a/apps/dash/e2e/pages/org/OrgJoinPage.ts b/apps/dash/e2e/pages/org/OrgJoinPage.ts
index 0e70b22..94126dc 100644
--- a/apps/dash/e2e/pages/org/OrgJoinPage.ts
+++ b/apps/dash/e2e/pages/org/OrgJoinPage.ts
@@ -1,4 +1,4 @@
-import type { Locator, Page } from "@playwright/test";
+import { expect, type Locator, type Page } from "@playwright/test";
/**
* Page Object Model for the Organization Join page.
@@ -15,9 +15,10 @@ export class OrgJoinPage {
readonly birthDateTrigger: Locator;
readonly idCardInput: Locator;
readonly submitButton: Locator;
+ readonly removePhotoButton: Locator;
+ readonly idCardPreview: Locator;
// Success/Error states
readonly successHeading: Locator;
- readonly removePhotoButton: Locator;
readonly alreadyExistsHeading: Locator;
readonly invalidHeading: Locator;
@@ -29,10 +30,13 @@ export class OrgJoinPage {
this.birthPlaceInput = page.getByLabel(/Tempat Lahir/i);
this.maleRadio = page.getByText(/Laki-laki/i);
this.femaleRadio = page.getByText(/Perempuan/i);
- this.birthDateTrigger = page.locator("#birthDate");
- this.idCardInput = page.locator('input[type="file"]');
- this.removePhotoButton = page.locator("#remove-photo-button");
- this.submitButton = page.locator("#submit-registration-button");
+ this.birthDateTrigger = page.getByTestId("birth-date-trigger");
+ this.idCardInput = page.getByTestId("id-card-input");
+ this.idCardPreview = page.getByTestId("ktp-preview-image");
+ this.removePhotoButton = page.getByTestId("remove-photo-button");
+ this.submitButton = page.getByRole("button").filter({
+ hasText: /Bergabung Sekarang|Perbarui Pendaftaran/i,
+ });
this.successHeading = page.getByRole("heading", {
name: /Pendaftaran Berhasil!/i,
@@ -68,18 +72,29 @@ export class OrgJoinPage {
await this.birthPlaceInput.fill(data.birthPlace);
await this.maleRadio.click();
await this.birthDateTrigger.click();
- // Simplified date selection matching the spec
- await this.page
- .getByRole("gridcell", { name: data.birthDateDay })
+
+ // Wait for calendar to be visible
+ const calendar = this.page.locator('[data-slot="calendar"]');
+ await expect(calendar).toBeVisible({ timeout: 15000 });
+
+ // Target the specific day button inside the calendar
+ // Use a precise regex for the day number to avoid partial matches (e.g., "1" matching "10")
+ const dayRegex = new RegExp(`^${data.birthDateDay}$`);
+ await calendar
+ .getByRole("button")
+ .filter({ hasText: dayRegex })
+ .filter({
+ hasNot: this.page.locator('[class*="outside"], [class*="rdp-outside"]'),
+ })
.first()
.click();
}
/**
- * Clicks the 'Bergabung Sekarang' button.
+ * Clicks the 'Bergabung Sekarang' (or Update) button.
*/
async submit() {
- await this.submitButton.click();
+ await this.submitButton.first().click();
}
/**
@@ -88,6 +103,8 @@ export class OrgJoinPage {
* @param filePath - The local path to the file to upload.
*/
async uploadIdCardPhoto(filePath: string) {
+ // Ensure the input is ready
+ await this.idCardInput.waitFor({ state: "attached" });
await this.idCardInput.setInputFiles(filePath);
}
}
diff --git a/apps/dash/e2e/pages/setting/DioceseCreatePage.ts b/apps/dash/e2e/pages/setting/DioceseCreatePage.ts
new file mode 100644
index 0000000..3509678
--- /dev/null
+++ b/apps/dash/e2e/pages/setting/DioceseCreatePage.ts
@@ -0,0 +1,77 @@
+import type { Locator, Page } from "@playwright/test";
+
+/**
+ * Page Object Model for the Diocese Create page.
+ * Encapsulates selectors and actions for creating a new diocese.
+ */
+export class DioceseCreatePage {
+ readonly page: Page;
+ readonly nameInput: Locator;
+ readonly addressInput: Locator;
+ readonly phoneInput: Locator;
+ readonly emailInput: Locator;
+ readonly websiteInput: Locator;
+ readonly logoInput: Locator;
+ readonly descInput: Locator;
+ readonly submitBtn: Locator;
+ readonly cancelBtn: Locator;
+ readonly headerTitle: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.nameInput = page.getByTestId("diocese-name-input");
+ this.logoInput = page.getByTestId("diocese-logo-input");
+ this.descInput = page.getByTestId("diocese-desc-input");
+ this.addressInput = page.getByTestId("diocese-address-input");
+ this.phoneInput = page.getByTestId("diocese-phone-input");
+ this.emailInput = page.getByTestId("diocese-email-input");
+ this.websiteInput = page.getByTestId("diocese-website-input");
+ this.submitBtn = page.getByTestId("save-diocese-btn");
+ this.cancelBtn = page.getByRole("button", { name: /Cancel|Batal/i });
+ this.headerTitle = page.getByTestId("page-title");
+ }
+
+ /**
+ * Navigates to the diocese creation page.
+ */
+ async goto() {
+ await this.page.goto("/setting/diocese/new");
+ }
+
+ /**
+ * Fills the diocese creation form with the provided data.
+ *
+ * @param data - The diocese data.
+ */
+ async fillForm(data: {
+ name: string;
+ address?: string;
+ phone?: string;
+ email?: string;
+ website?: string;
+ logo?: string;
+ description?: string;
+ }) {
+ await this.nameInput.fill(data.name);
+ if (data.logo) await this.logoInput.fill(data.logo);
+ if (data.description) await this.descInput.fill(data.description);
+ if (data.address) await this.addressInput.fill(data.address);
+ if (data.phone) await this.phoneInput.fill(data.phone);
+ if (data.email) await this.emailInput.fill(data.email);
+ if (data.website) await this.websiteInput.fill(data.website);
+ }
+
+ /**
+ * Submits the diocese creation form.
+ */
+ async submit() {
+ await this.submitBtn.click();
+ }
+
+ /**
+ * Cancels the diocese creation.
+ */
+ async cancel() {
+ await this.cancelBtn.click();
+ }
+}
diff --git a/apps/dash/e2e/pages/setting/DioceseDetailPage.ts b/apps/dash/e2e/pages/setting/DioceseDetailPage.ts
new file mode 100644
index 0000000..c5d46aa
--- /dev/null
+++ b/apps/dash/e2e/pages/setting/DioceseDetailPage.ts
@@ -0,0 +1,85 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+/**
+ * Page Object Model for the Diocese Detail page.
+ * Encapsulates selectors and actions for updating and deleting a diocese.
+ */
+export class DioceseDetailPage {
+ readonly page: Page;
+ readonly nameInput: Locator;
+ readonly addressInput: Locator;
+ readonly phoneInput: Locator;
+ readonly emailInput: Locator;
+ readonly websiteInput: Locator;
+ readonly logoInput: Locator;
+ readonly descInput: Locator;
+ readonly submitBtn: Locator;
+ readonly cancelBtn: Locator;
+ readonly deleteBtn: Locator;
+ readonly confirmDeleteBtn: Locator;
+ readonly headerTitle: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.nameInput = page.getByTestId("diocese-name-input");
+ this.logoInput = page.getByTestId("diocese-logo-input");
+ this.descInput = page.getByTestId("diocese-desc-input");
+ this.addressInput = page.getByTestId("diocese-address-input");
+ this.phoneInput = page.getByTestId("diocese-phone-input");
+ this.emailInput = page.getByTestId("diocese-email-input");
+ this.websiteInput = page.getByTestId("diocese-website-input");
+ this.submitBtn = page.getByTestId("save-diocese-btn");
+ this.cancelBtn = page.getByRole("button", { name: /Cancel|Batal/i });
+ this.deleteBtn = page.getByTestId("delete-diocese-btn");
+ this.confirmDeleteBtn = page
+ .getByRole("button", { name: /Hapus/i })
+ .filter({ hasText: /^Hapus$/ });
+ this.headerTitle = page.getByTestId("page-title");
+ }
+
+ /**
+ * Navigates to a specific diocese detail page.
+ * @param id - The diocese ID.
+ */
+ async goto(id: string) {
+ await this.page.goto(`/setting/diocese/${id}`);
+ }
+
+ /**
+ * Fills the form with new data.
+ */
+ async fillForm(data: {
+ name?: string;
+ address?: string;
+ phone?: string;
+ email?: string;
+ website?: string;
+ logo?: string;
+ description?: string;
+ }) {
+ if (data.name) await this.nameInput.fill(data.name);
+ if (data.logo) await this.logoInput.fill(data.logo);
+ if (data.description) await this.descInput.fill(data.description);
+ if (data.address) await this.addressInput.fill(data.address);
+ if (data.phone) await this.phoneInput.fill(data.phone);
+ if (data.email) await this.emailInput.fill(data.email);
+ if (data.website) await this.websiteInput.fill(data.website);
+ }
+
+ /**
+ * Submits the update.
+ */
+ async submit() {
+ await this.submitBtn.click();
+ }
+
+ /**
+ * Initiates deletion and confirms it in the dialog.
+ */
+ async deleteAndConfirm() {
+ await this.deleteBtn.click();
+ // Wait for alertDialog to appear and the action button to be visible
+ await expect(this.confirmDeleteBtn).toBeVisible();
+ await this.confirmDeleteBtn.click();
+ }
+}
diff --git a/apps/dash/e2e/pages/setting/DioceseListPage.ts b/apps/dash/e2e/pages/setting/DioceseListPage.ts
new file mode 100644
index 0000000..648e64f
--- /dev/null
+++ b/apps/dash/e2e/pages/setting/DioceseListPage.ts
@@ -0,0 +1,95 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+/**
+ * Page Object Model for the Diocese List page.
+ * Encapsulates selectors and actions for browsing and searching dioceses.
+ */
+export class DioceseListPage {
+ readonly page: Page;
+ readonly searchInput: Locator;
+ readonly searchClearBtn: Locator;
+ readonly loadingIndicator: Locator;
+ readonly addDioceseBtn: Locator;
+ readonly dioceseGrid: Locator;
+ readonly dioceseCards: Locator;
+ readonly emptyState: Locator;
+ readonly premiumHero: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.searchInput = page.getByTestId("search-input");
+ this.searchClearBtn = page.getByTestId("search-clear");
+ this.loadingIndicator = page.getByTestId("search-loading");
+ this.addDioceseBtn = page.getByTestId("add-diocese-btn");
+ this.dioceseGrid = page.getByTestId("diocese-grid");
+ this.dioceseCards = page.getByTestId("diocese-card");
+ this.emptyState = page.getByTestId("diocese-empty-state");
+ this.premiumHero = page.getByTestId("premium-hero");
+ }
+
+ /**
+ * Navigates to the diocese list page.
+ */
+ async goto() {
+ await this.page.goto("/setting/diocese");
+ await expect(this.dioceseGrid).toBeVisible();
+ }
+
+ /**
+ * Performs a search by filling the search input and waiting for the URL to update.
+ *
+ * @param query - The search query.
+ */
+ async search(query: string) {
+ await expect(this.searchInput).toBeVisible();
+ await this.searchInput.fill(query);
+
+ // If query is not empty, wait for URL to contain 'q=' (due to debounce)
+ if (query) {
+ await this.page.waitForURL((url) => url.searchParams.get("q") === query, {
+ timeout: 10000,
+ });
+ } else {
+ await this.page.waitForURL((url) => !url.searchParams.has("q"), {
+ timeout: 10000,
+ });
+ }
+
+ // Wait for internal navigation/loading to finish if applicable
+ await expect(this.loadingIndicator).toBeHidden();
+ }
+
+ /**
+ * Clears the search input using the clear button.
+ */
+ async clearSearch() {
+ await this.searchClearBtn.click();
+ await expect(this.loadingIndicator).toBeHidden();
+ await this.page.waitForURL((url) => !url.searchParams.has("q"));
+ }
+
+ /**
+ * Gets the number of currently visible diocese cards.
+ */
+ async getVisibleDioceseCount() {
+ return await this.dioceseCards.count();
+ }
+
+ /**
+ * Gets a specific diocese card by its index.
+ *
+ * @param index - The 0-based index of the card.
+ */
+ getDioceseCardAt(index: number) {
+ return this.dioceseCards.nth(index);
+ }
+
+ /**
+ * Gets a specific diocese card by its name.
+ *
+ * @param name - The name of the diocese.
+ */
+ getDioceseCardByName(name: string | RegExp) {
+ return this.dioceseCards.filter({ hasText: name });
+ }
+}
diff --git a/apps/dash/e2e/pages/setting/ParishCreatePage.ts b/apps/dash/e2e/pages/setting/ParishCreatePage.ts
new file mode 100644
index 0000000..5407730
--- /dev/null
+++ b/apps/dash/e2e/pages/setting/ParishCreatePage.ts
@@ -0,0 +1,84 @@
+import type { Locator, Page } from "@playwright/test";
+
+/**
+ * Page Object Model for the Parish Create page.
+ * Encapsulates selectors and actions for creating a new parish.
+ */
+export class ParishCreatePage {
+ readonly page: Page;
+ readonly dioceseSelect: Locator;
+ readonly vicariateSelect: Locator;
+ readonly nameInput: Locator;
+ readonly addressInput: Locator;
+ readonly phoneInput: Locator;
+ readonly emailInput: Locator;
+ readonly websiteInput: Locator;
+ readonly logoInput: Locator;
+ readonly descInput: Locator;
+ readonly submitBtn: Locator;
+ readonly cancelBtn: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.dioceseSelect = page.getByLabel(/diocese|keuskupan/i);
+ this.vicariateSelect = page.getByLabel(/vicariate|vikariat/i);
+ this.nameInput = page.getByLabel(/name|nama/i).first();
+ this.addressInput = page.getByLabel(/address|alamat/i);
+ this.phoneInput = page.getByLabel(/phone|telepon/i);
+ this.emailInput = page.getByLabel(/email/i);
+ this.websiteInput = page.getByLabel(/website|situs web/i);
+ this.logoInput = page.getByLabel(/logo/i);
+ this.descInput = page.getByLabel(/description|deskripsi/i);
+ this.submitBtn = page.getByRole("button", { name: /save|simpan/i });
+ this.cancelBtn = page.getByRole("button", { name: /cancel|batal/i });
+ }
+
+ /**
+ * Navigates to the parish creation page.
+ */
+ async goto() {
+ await this.page.goto("/setting/parish/new");
+ }
+
+ /**
+ * Fills the parish creation form.
+ * Handles chained selection for Diocese and Vicariate.
+ *
+ * @param data - The parish data.
+ */
+ async fillForm(data: {
+ dioceseName: string;
+ vicariateName: string;
+ name: string;
+ address?: string;
+ phone?: string;
+ email?: string;
+ website?: string;
+ logo?: string;
+ description?: string;
+ }) {
+ // 1. Select Diocese
+ await this.dioceseSelect.click();
+ await this.page.getByRole("option", { name: data.dioceseName }).click();
+
+ // 2. Select Vicariate (Wait for it to load/enable)
+ await this.vicariateSelect.click();
+ await this.page.getByRole("option", { name: data.vicariateName }).click();
+
+ // 3. Fill text fields
+ await this.nameInput.fill(data.name);
+ if (data.logo) await this.logoInput.fill(data.logo);
+ if (data.description) await this.descInput.fill(data.description);
+ if (data.address) await this.addressInput.fill(data.address);
+ if (data.phone) await this.phoneInput.fill(data.phone);
+ if (data.email) await this.emailInput.fill(data.email);
+ if (data.website) await this.websiteInput.fill(data.website);
+ }
+
+ /**
+ * Submits the form.
+ */
+ async submit() {
+ await this.submitBtn.click();
+ }
+}
diff --git a/apps/dash/e2e/pages/setting/ParishDetailPage.ts b/apps/dash/e2e/pages/setting/ParishDetailPage.ts
new file mode 100644
index 0000000..47d4571
--- /dev/null
+++ b/apps/dash/e2e/pages/setting/ParishDetailPage.ts
@@ -0,0 +1,108 @@
+import type { Locator, Page } from "@playwright/test";
+
+/**
+ * Page Object Model for the Parish Detail page.
+ * Encapsulates selectors and actions for viewing and editing a parish.
+ */
+export class ParishDetailPage {
+ readonly page: Page;
+ readonly dioceseSelect: Locator;
+ readonly vicariateSelect: Locator;
+ readonly nameInput: Locator;
+ readonly addressInput: Locator;
+ readonly phoneInput: Locator;
+ readonly emailInput: Locator;
+ readonly websiteInput: Locator;
+ readonly logoInput: Locator;
+ readonly descInput: Locator;
+ readonly submitBtn: Locator;
+ readonly cancelBtn: Locator;
+ readonly deleteBtn: Locator;
+ readonly confirmDeleteBtn: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.dioceseSelect = page.getByLabel(/diocese|keuskupan/i);
+ this.vicariateSelect = page.getByLabel(/vicariate|vikariat/i);
+ this.nameInput = page.getByLabel(/name|nama/i).first();
+ this.addressInput = page.getByLabel(/address|alamat/i);
+ this.phoneInput = page.getByLabel(/phone|telepon/i);
+ this.emailInput = page.getByLabel(/email/i);
+ this.websiteInput = page.getByLabel(/website|situs web/i);
+ this.logoInput = page.getByLabel(/logo/i);
+ this.descInput = page.getByLabel(/description|deskripsi/i);
+ this.submitBtn = page.getByRole("button", { name: /save|simpan/i });
+ this.cancelBtn = page.getByRole("button", { name: /cancel|batal/i });
+ this.deleteBtn = page
+ .getByRole("button", { name: /delete|hapus/i })
+ .first();
+ this.confirmDeleteBtn = page.locator("role=alertdialog >> role=button", {
+ hasText: /delete|hapus/i,
+ });
+ }
+
+ /**
+ * Navigates to the parish detail page by ID.
+ * @param id - The parish ID.
+ */
+ async goto(id: string) {
+ await this.page.goto(`/setting/parish/${id}`);
+ }
+
+ /**
+ * Fills the parish update form.
+ *
+ * @param data - The parish data to update.
+ */
+ async fillForm(data: {
+ dioceseName?: string;
+ vicariateName?: string;
+ name?: string;
+ address?: string;
+ phone?: string;
+ email?: string;
+ website?: string;
+ logo?: string;
+ description?: string;
+ }) {
+ // 1. Select Diocese if provided
+ if (data.dioceseName) {
+ await this.dioceseSelect.click();
+ await this.page.getByRole("option", { name: data.dioceseName }).click();
+ }
+
+ // 2. Select Vicariate if provided (Wait for it to load/enable)
+ if (data.vicariateName) {
+ await this.vicariateSelect.click();
+ await this.page.getByRole("option", { name: data.vicariateName }).click();
+ }
+
+ // 3. Fill text fields
+ if (data.name !== undefined) await this.nameInput.fill(data.name);
+ if (data.logo !== undefined) await this.logoInput.fill(data.logo);
+ if (data.description !== undefined)
+ await this.descInput.fill(data.description);
+ if (data.address !== undefined) await this.addressInput.fill(data.address);
+ if (data.phone !== undefined) await this.phoneInput.fill(data.phone);
+ if (data.email !== undefined) await this.emailInput.fill(data.email);
+ if (data.website !== undefined) await this.websiteInput.fill(data.website);
+ }
+
+ /**
+ * Submits the form.
+ */
+ async submit() {
+ await this.submitBtn.click();
+ }
+
+ /**
+ * Deletes the parish.
+ */
+ async delete() {
+ await this.deleteBtn.scrollIntoViewIfNeeded();
+ await this.deleteBtn.click();
+ // Wait for alert dialog to appear and click confirm
+ await this.confirmDeleteBtn.waitFor({ state: "visible" });
+ await this.confirmDeleteBtn.click();
+ }
+}
diff --git a/apps/dash/e2e/pages/setting/ParishListPage.ts b/apps/dash/e2e/pages/setting/ParishListPage.ts
new file mode 100644
index 0000000..275c025
--- /dev/null
+++ b/apps/dash/e2e/pages/setting/ParishListPage.ts
@@ -0,0 +1,95 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+/**
+ * Page Object Model for the Parish List page.
+ * Encapsulates selectors and actions for browsing and searching parishes.
+ */
+export class ParishListPage {
+ readonly page: Page;
+ readonly searchInput: Locator;
+ readonly searchClearBtn: Locator;
+ readonly loadingIndicator: Locator;
+ readonly addParishBtn: Locator;
+ readonly parishGrid: Locator;
+ readonly parishCards: Locator;
+ readonly emptyState: Locator;
+ readonly premiumHero: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.searchInput = page.getByTestId("search-input");
+ this.searchClearBtn = page.getByTestId("search-clear");
+ this.loadingIndicator = page.getByTestId("search-loading");
+ this.addParishBtn = page.getByTestId("add-parish-btn");
+ this.parishGrid = page.getByTestId("parish-grid");
+ this.parishCards = page.getByTestId("parish-card");
+ this.emptyState = page.getByTestId("parish-empty-state");
+ this.premiumHero = page.getByTestId("premium-hero");
+ }
+
+ /**
+ * Navigates to the parish list page.
+ */
+ async goto() {
+ await this.page.goto("/setting/parish");
+ await expect(this.parishGrid).toBeVisible();
+ }
+
+ /**
+ * Performs a search by filling the search input and waiting for the URL to update.
+ *
+ * @param query - The search query.
+ */
+ async search(query: string) {
+ await expect(this.searchInput).toBeVisible();
+ await this.searchInput.fill(query);
+
+ // If query is not empty, wait for URL to contain 'q=' (due to debounce)
+ if (query) {
+ await this.page.waitForURL((url) => url.searchParams.get("q") === query, {
+ timeout: 10000,
+ });
+ } else {
+ await this.page.waitForURL((url) => !url.searchParams.has("q"), {
+ timeout: 10000,
+ });
+ }
+
+ // Wait for internal navigation/loading to finish if applicable
+ await expect(this.loadingIndicator).toBeHidden();
+ }
+
+ /**
+ * Clears the search input using the clear button.
+ */
+ async clearSearch() {
+ await this.searchClearBtn.click();
+ await expect(this.loadingIndicator).toBeHidden();
+ await this.page.waitForURL((url) => !url.searchParams.has("q"));
+ }
+
+ /**
+ * Gets the number of currently visible parish cards.
+ */
+ async getVisibleParishCount() {
+ return await this.parishCards.count();
+ }
+
+ /**
+ * Gets a specific parish card by its index.
+ *
+ * @param index - The 0-based index of the card.
+ */
+ getParishCardAt(index: number) {
+ return this.parishCards.nth(index);
+ }
+
+ /**
+ * Gets a specific parish card by its name.
+ *
+ * @param name - The name of the parish.
+ */
+ getParishCardByName(name: string | RegExp) {
+ return this.parishCards.filter({ hasText: name });
+ }
+}
diff --git a/apps/dash/playwright.config.ts b/apps/dash/playwright.config.ts
index 0e999fd..fa10ac7 100644
--- a/apps/dash/playwright.config.ts
+++ b/apps/dash/playwright.config.ts
@@ -11,6 +11,8 @@ dotenv.config({
*/
export default defineConfig({
testDir: "./e2e",
+ globalSetup: "./e2e/global-setup.ts",
+ globalTeardown: "./e2e/global-teardown.ts",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -40,7 +42,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
- command: "pnpm dev",
+ command: "NEXT_PUBLIC_E2E_MOCK=true pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
diff --git a/apps/dash/proxy.ts b/apps/dash/proxy.ts
index db6ec2f..0a3622d 100644
--- a/apps/dash/proxy.ts
+++ b/apps/dash/proxy.ts
@@ -3,7 +3,7 @@ import c, { Environment } from "@domus/config";
import { cookies } from "next/headers";
import type { NextFetchEvent, NextRequest } from "next/server";
import { NextResponse } from "next/server";
-import auth from "@/shared/auth/server";
+import { getAuthSession } from "@/shared/auth/server";
import { axiomLogger } from "@/shared/axiom/axiom-logger";
import { logger } from "@/shared/core/logger";
import { routing } from "@/shared/i18n/routing";
@@ -34,10 +34,7 @@ export default async function proxy(
try {
// 1. Get session using the provided request headers
- const session: typeof auth.$Infer.Session | null =
- await auth.api.getSession({
- headers: request.headers,
- });
+ const [session, errSession] = await getAuthSession();
// 2. Auth Redirection Logic
const isPublicPage = /^\/login/.test(pathname);
@@ -46,7 +43,7 @@ export default async function proxy(
// 4. Create the response and set local headers
const requestHeaders = new Headers(request.headers);
- if (!session) {
+ if (errSession) {
// Unauthenticated user
if (isPublicPage) {
return await finalizeResponse(request, event, NextResponse.next());
diff --git a/apps/dash/src/pages/demo/ui/DemoPage.tsx b/apps/dash/src/pages/demo/ui/DemoPage.tsx
index db69160..0dfa183 100644
--- a/apps/dash/src/pages/demo/ui/DemoPage.tsx
+++ b/apps/dash/src/pages/demo/ui/DemoPage.tsx
@@ -1,88 +1,54 @@
-import { Bell, Calendar, MapPin, Share2 } from "lucide-react";
+import { ArrowRight, Bell, Church, Share2 } from "lucide-react";
import { cn } from "@/shared/ui/common/utils";
+import { PremiumAction, PremiumHero } from "@/shared/ui/components";
import {
DomusCard,
DomusCardContent,
- DomusCardDescription,
DomusCardFooter,
DomusCardHeader,
DomusCardTitle,
} from "@/shared/ui/components/DomusCard";
-import { PremiumAction } from "@/shared/ui/components/PremiumFooter";
import { Button } from "@/shared/ui/shadcn/button";
import { RegistrationForm } from "./components/RegistrationForm";
/**
- * DemoPage showcases the Premium DomusCard components and their variants.
- * It implements a responsive layout (2/3 width on desktop, full width on mobile).
+ * DemoPage showcases the Premium components available in the Domus design system.
*/
export default function DemoPage() {
- const cathedralImage =
- "https://images.unsplash.com/photo-1551759138-3a1d9214d973?ixlib=rb-4.1.0&q=85&fm=jpg&crop=entropy&cs=srgb&dl=josh-applegate-g0WjhnQRTa8-unsplash.jpg";
+ const heroImage = "https://s3.pkrbt.id/static/cathedral/1.webp";
return (
-
- {/* Page Header */}
-
-
- Component Showcase
-
-
- Exploring the refined Domus design system. Modern, compact, and
- professional components for parish administration.
-
-
+
+ {/* Premium Hero Showcase */}
+ }
+ orgName="St. Mary's Cathedral"
+ tags={["Parish", "Barong Tongkok", "Diocese"]}
+ title={
+ <>
+ Digital Excellence in
+ Parish Administration.
+ >
+ }
+ description="A specialized dashboard built for modern ministry. Manage memberships, track sacramental records, and streamline organizational workflows with ease."
+ actions={
+ <>
+ }
+ >
+ GET STARTED
+
+
+ VIEW DOCUMENTATION
+
+ >
+ }
+ />
{/* Main Container*/}
- {/* Variant 1: Professional Cover Card */}
-
-
- 01. Premium Cover Variant
-
-
-
- Sunday High Mass
-
- St. Mary's Cathedral โข 09:00 AM
-
-
-
-
-
- Join us for the weekly solemn liturgy. This event features the
- full choir and traditional Gregorian chants. Please arrive 15
- minutes early for silent prayer.
-