feat: fixing role id usage, fixing content website, etc
continuous-integration/drone/push Build is passing Details

This commit is contained in:
hanif salafi 2026-04-14 10:27:00 +07:00
parent 7515f55e88
commit 71c65f9a60
13 changed files with 384 additions and 206 deletions

4
.env
View File

@ -1,3 +1,3 @@
MEDOLS_CLIENT_KEY=bb65b1ad-e954-4a1a-b4d0-74df5bb0b640
NEXT_PUBLIC_API_URL=http://localhost:8800
# NEXT_PUBLIC_API_URL=https://qudo.id/api
# NEXT_PUBLIC_API_URL=http://localhost:8800
NEXT_PUBLIC_API_URL=https://qudo.id/api

View File

@ -2,6 +2,10 @@
import Cookies from "js-cookie";
import ContentWebsite from "@/components/main/content-website";
import {
isApproverOrAdmin,
isContributorRole,
} from "@/constants/user-roles";
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
@ -13,8 +17,7 @@ export default function ContentWebsitePage() {
useEffect(() => {
setMounted(true);
const ulne = Cookies.get("ulne");
setLevelId(ulne);
setLevelId(Cookies.get("urie"));
}, []);
if (!mounted) {
@ -33,10 +36,10 @@ export default function ContentWebsitePage() {
transition={{ duration: 0.3 }}
>
<div className="p-6">
{levelId === "3" ? (
{isApproverOrAdmin(levelId) ? (
<ApproverContentWebsite />
) : (
<ContentWebsite contributorMode={levelId === "2"} />
<ContentWebsite contributorMode={isContributorRole(levelId)} />
)}
</div>
</motion.div>

View File

@ -12,9 +12,35 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
const siteDescription =
"Qudoco — portal konten dan layanan untuk mengelola website, berita, serta aset media dengan alur kerja yang terstruktur.";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
metadataBase: new URL("https://qudo.id"),
title: {
default: "Qudoco",
template: "%s | Qudoco",
},
description: siteDescription,
applicationName: "Qudoco",
keywords: ["Qudoco", "portal konten", "CMS", "berita", "layanan"],
authors: [{ name: "Qudoco" }],
openGraph: {
type: "website",
locale: "id_ID",
siteName: "Qudoco",
title: "Qudoco",
description: siteDescription,
},
twitter: {
card: "summary_large_image",
title: "Qudoco",
description: siteDescription,
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({
@ -23,7 +49,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="id">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>

View File

@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Header from "@/components/landing-page/headers";
import AboutSection from "@/components/landing-page/about";
import ProductSection from "@/components/landing-page/product";
@ -15,6 +16,22 @@ import type {
CmsServiceContent,
} from "@/types/cms-landing";
const landingDescription =
"Jelajahi layanan, produk, dan informasi terbaru dari Qudoco — satu portal untuk konten profesional dan komunikasi yang jelas.";
export const metadata: Metadata = {
title: "Beranda",
description: landingDescription,
openGraph: {
title: "Beranda | Qudoco",
description: landingDescription,
url: "/",
},
alternates: {
canonical: "/",
},
};
export default async function Home() {
const [hero, aboutList, productList, serviceList, partners, popupList] =
await Promise.all([

View File

@ -18,6 +18,10 @@ import { useParams, useRouter } from "next/navigation";
import GetSeoScore from "./get-seo-score-form";
import Link from "next/link";
import Cookies from "js-cookie";
import {
isApproverOrAdmin,
isContributorRole,
} from "@/constants/user-roles";
import {
createArticleSchedule,
deleteArticleFiles,
@ -162,8 +166,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
const [levelId, setLevelId] = useState<string | undefined>();
useEffect(() => {
const ulne = Cookies.get("ulne");
setLevelId(ulne);
setLevelId(Cookies.get("urie"));
}, []);
const { getRootProps, getInputProps } = useDropzone({
@ -812,7 +815,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
{/* ================= ACTION BUTTON ================= */}
<div className="space-y-3">
{levelId === "2" && !detailData?.isPublish && (
{isApproverOrAdmin(levelId) && !detailData?.isPublish && (
<>
<Button
onClick={doPublish}
@ -844,7 +847,7 @@ export default function EditArticleForm(props: { isDetail: boolean }) {
)}
{/* 🔥 Jika levelId 3 → hanya tampilkan Cancel */}
{levelId === "3" && (
{isContributorRole(levelId) && (
<Link href="/admin/news-article/image">
<Button variant="outline" className="w-full py-3 rounded-xl">
Cancel

View File

@ -29,8 +29,8 @@ interface SidebarSection {
children?: SidebarItem[];
}
const getSidebarByLevel = (levelId: string | null) => {
if (levelId === "1") {
const getSidebarByRole = (roleId: string | null) => {
if (roleId === "1") {
return [
{
title: "Dashboard",
@ -64,7 +64,7 @@ const getSidebarByLevel = (levelId: string | null) => {
];
}
if (levelId === "3") {
if (roleId === "2" || roleId === "3") {
return [
{
title: "Dashboard",
@ -141,66 +141,7 @@ const getSidebarByLevel = (levelId: string | null) => {
];
}
if (levelId === "2") {
return [
{
title: "Dashboard",
items: [
{
title: "Dashboard",
icon: () => (
<Icon icon="material-symbols:dashboard" className="text-lg" />
),
link: "/admin/dashboard",
},
],
},
{
items: [
{
title: "Content Website",
icon: () => <Icon icon="ri:article-line" className="text-lg" />,
link: "/admin/content-website",
},
],
},
{
title: "News & Article",
items: [
{
title: "News & Article",
icon: () => (
<Icon icon="grommet-icons:article" className="text-lg" />
),
children: [
{
title: "Text",
icon: () => <Icon icon="mdi:file-document-outline" />,
link: "/admin/news-article/text",
},
{
title: "Image",
icon: () => <Icon icon="mdi:image-outline" />,
link: "/admin/news-article/image",
},
{
title: "Video",
icon: () => <Icon icon="mdi:video-outline" />,
link: "/admin/news-article/video",
},
{
title: "Audio",
icon: () => <Icon icon="mdi:music-note-outline" />,
link: "/admin/news-article/audio",
},
],
},
],
},
];
}
// fallback kalau Level tidak dikenal
// fallback jika role tidak dikenal
return [];
};
@ -334,10 +275,10 @@ const SidebarContent = ({
};
const cookieUsername = getCookie("username");
const cookieLevelId = getCookie("ulne");
const cookieRoleId = getCookie("urie");
if (cookieUsername) setUsername(cookieUsername);
if (cookieLevelId) setLevelId(cookieLevelId);
if (cookieRoleId) setLevelId(cookieRoleId);
}, []);
// ===============================
@ -357,7 +298,7 @@ const SidebarContent = ({
);
};
const sidebarSections = getSidebarByLevel(LevelId);
const sidebarSections = getSidebarByRole(LevelId);
return (
<div className="flex flex-col h-full">

View File

@ -13,7 +13,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Filter, Loader2 } from "lucide-react";
import { ExternalLink, Filter, Loader2 } from "lucide-react";
import {
approveCmsContentSubmission,
listCmsContentSubmissions,
@ -22,6 +22,7 @@ import {
import { apiPayload } from "@/service/cms-landing";
import { formatDate } from "@/utils/global";
import Swal from "sweetalert2";
import ContentWebsite from "@/components/main/content-website";
const DOMAIN_LABEL: Record<string, string> = {
hero: "Hero",
@ -32,6 +33,16 @@ const DOMAIN_LABEL: Record<string, string> = {
popup: "Pop Up",
};
/** Maps submission `domain` to ContentWebsite tab `value`. */
const DOMAIN_TO_TAB: Record<string, string> = {
hero: "hero",
about: "about",
product: "products",
service: "services",
partner: "partners",
popup: "popup",
};
type Row = {
id: string;
domain: string;
@ -48,6 +59,9 @@ export default function ApproverContentWebsite() {
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [actingId, setActingId] = useState<string | null>(null);
const [tabFocusSignal, setTabFocusSignal] = useState(0);
const [tabFocusTarget, setTabFocusTarget] = useState("hero");
const [liveDataReloadSignal, setLiveDataReloadSignal] = useState(0);
const load = useCallback(async () => {
setLoading(true);
@ -78,6 +92,18 @@ export default function ApproverContentWebsite() {
);
});
function jumpToLiveTab(domain: string) {
const tab = DOMAIN_TO_TAB[domain];
if (!tab) return;
setTabFocusTarget(tab);
setTabFocusSignal((n) => n + 1);
requestAnimationFrame(() => {
document
.getElementById("approver-live-cms")
?.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
async function onApprove(id: string) {
const ok = await Swal.fire({
icon: "question",
@ -102,6 +128,7 @@ export default function ApproverContentWebsite() {
timer: 1600,
showConfirmButton: false,
});
setLiveDataReloadSignal((n) => n + 1);
await load();
} finally {
setActingId(null);
@ -144,17 +171,33 @@ export default function ApproverContentWebsite() {
}
return (
<div className="space-y-6">
<div className="space-y-10">
<div>
<h1 className="text-2xl font-bold text-slate-800">Content Website</h1>
<p className="text-slate-500">
Tinjau pengajuan perubahan dari kontributor dan terapkan ke konten live.
<p className="mt-1 text-slate-500">
Di bagian atas: pengajuan baru yang perlu disetujui. Di bawah: konten
live di semua tab (hanya lihat) untuk membandingkan dengan pengajuan.
</p>
</div>
<section className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-slate-900">
Perubahan menunggu persetujuan
</h2>
<Badge variant="secondary" className="font-normal">
{filtered.length} pending
</Badge>
</div>
<p className="text-sm text-slate-500">
Setujui atau tolak di sini. Gunakan{" "}
<span className="font-medium text-slate-700">Lihat konten live</span>{" "}
untuk membuka tab yang sama dengan bagian yang diajukan.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
placeholder="Cari judul, domain, atau nama pengaju…"
placeholder="Cari judul, bagian, atau nama pengaju…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-md"
@ -189,13 +232,16 @@ export default function ApproverContentWebsite() {
<TableHead>Bagian</TableHead>
<TableHead>Pengaju</TableHead>
<TableHead>Tanggal</TableHead>
<TableHead className="text-right">Aksi</TableHead>
<TableHead className="text-right w-[280px]">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-slate-500 py-10">
<TableCell
colSpan={5}
className="text-center text-slate-500 py-10"
>
Tidak ada pengajuan tertunda.
</TableCell>
</TableRow>
@ -211,12 +257,24 @@ export default function ApproverContentWebsite() {
</Badge>
</TableCell>
<TableCell className="text-slate-600">
{item.submitter_name || `User #${item.submitted_by_id}`}
{item.submitter_name ||
`User #${item.submitted_by_id}`}
</TableCell>
<TableCell className="text-slate-600">
{formatDate(item.created_at)}
</TableCell>
<TableCell className="text-right space-x-2">
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="gap-1"
onClick={() => jumpToLiveTab(item.domain)}
>
<ExternalLink className="h-3.5 w-3.5" />
Lihat konten live
</Button>
<Button
type="button"
size="sm"
@ -236,6 +294,7 @@ export default function ApproverContentWebsite() {
>
Tolak
</Button>
</div>
</TableCell>
</TableRow>
))
@ -245,6 +304,30 @@ export default function ApproverContentWebsite() {
)}
</CardContent>
</Card>
</section>
<section
id="approver-live-cms"
className="space-y-4 border-t border-slate-200 pt-10 scroll-mt-6"
>
<div>
<h2 className="text-lg font-semibold text-slate-900">
Konten live (semua tab, hanya lihat)
</h2>
<p className="mt-1 text-sm text-slate-500">
Data yang sedang ditampilkan di website. Field tidak dapat diubah di
sini bandingkan dengan baris pengajuan di atas sebelum
menyetujui.
</p>
</div>
<ContentWebsite
viewOnly
hideHeader
tabFocusSignal={tabFocusSignal}
tabFocusTarget={tabFocusTarget}
liveDataReloadSignal={liveDataReloadSignal}
/>
</section>
</div>
);
}

View File

@ -91,16 +91,33 @@ function setPickedFile(
type ContentWebsiteProps = {
/** User level 2: changes go through approval instead of live CMS APIs. */
contributorMode?: boolean;
/** Approver (or admin): load live CMS data but disable all edits (all tabs). */
viewOnly?: boolean;
/** Omit page title/actions row — parent supplies section headings (e.g. approver layout). */
hideHeader?: boolean;
/** Parent increments this (e.g. 1,2,3…) to switch the visible tab. */
tabFocusSignal?: number;
/** Tab id matching `TabsTrigger` values: hero | about | products | services | partners | popup */
tabFocusTarget?: string;
/** Increment (e.g. after approver applies CMS) to reload live data from API without remounting. */
liveDataReloadSignal?: number;
};
export default function ContentWebsite({
contributorMode = false,
viewOnly = false,
hideHeader = false,
tabFocusSignal = 0,
tabFocusTarget = "",
liveDataReloadSignal = 0,
}: ContentWebsiteProps) {
const [activeTab, setActiveTab] = useState("hero");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editMode, setEditMode] = useState(!contributorMode);
const canInteract = !contributorMode || editMode;
const canInteract = (!contributorMode || editMode) && !viewOnly;
const dimContributorPreview =
contributorMode && !editMode && !viewOnly;
const [heroId, setHeroId] = useState<string | null>(null);
const [heroImageId, setHeroImageId] = useState<string | null>(null);
@ -262,6 +279,24 @@ export default function ContentWebsite({
loadAll();
}, [loadAll]);
useEffect(() => {
if (!viewOnly) return;
setProductModalOpen(false);
setServiceModalOpen(false);
setPartnerModalOpen(false);
setPopupModalOpen(false);
}, [viewOnly]);
useEffect(() => {
if (tabFocusSignal < 1 || !tabFocusTarget) return;
setActiveTab(tabFocusTarget);
}, [tabFocusSignal, tabFocusTarget]);
useEffect(() => {
if (liveDataReloadSignal < 1) return;
void loadAll();
}, [liveDataReloadSignal, loadAll]);
async function saveHeroTab() {
if (!heroPrimary.trim()) {
await Swal.fire({ icon: "warning", title: "Main title is required" });
@ -1239,11 +1274,16 @@ export default function ContentWebsite({
return (
<div className="space-y-6">
{!hideHeader ? (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-800">Content Website</h1>
<h1 className="text-2xl font-semibold text-slate-800">
Content Website
</h1>
<p className="mt-1 text-sm text-slate-500">
Update homepage content, products, services, and partners.
{viewOnly
? "Konten live di semua tab: hanya lihat. Pengajuan perubahan ditangani di bagian atas halaman."
: "Update homepage content, products, services, and partners."}
</p>
</div>
<div className="flex flex-wrap gap-3">
@ -1268,8 +1308,21 @@ export default function ContentWebsite({
) : null}
</div>
</div>
) : viewOnly ? (
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-lg"
onClick={() => window.open("/", "_blank")}
>
<Eye className="mr-2 h-4 w-4" />
Preview website
</Button>
</div>
) : null}
{contributorMode && !editMode ? (
{contributorMode && !editMode && !viewOnly ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
Aktifkan <strong>Edit Mode</strong> untuk mengusulkan perubahan. Perubahan akan masuk ke{" "}
<strong>My Content</strong> menunggu persetujuan approver. Unggah file gambar tidak tersedia sebagai kontributor;
@ -1279,7 +1332,9 @@ export default function ContentWebsite({
<div
className={
canInteract ? "" : "pointer-events-none select-none opacity-50"
dimContributorPreview
? "pointer-events-none select-none opacity-50"
: ""
}
>
<Tabs value={activeTab} onValueChange={setActiveTab}>
@ -1305,6 +1360,10 @@ export default function ContentWebsite({
</TabsList>
<TabsContent value="hero" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="grid gap-6 md:grid-cols-2">
@ -1415,9 +1474,14 @@ export default function ContentWebsite({
</Button>
</CardContent>
</Card>
</fieldset>
</TabsContent>
<TabsContent value="about" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="grid gap-6 md:grid-cols-2">
@ -1562,9 +1626,14 @@ export default function ContentWebsite({
</Button>
</CardContent>
</Card>
</fieldset>
</TabsContent>
<TabsContent value="products" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -1752,9 +1821,14 @@ export default function ContentWebsite({
</Dialog>
</CardContent>
</Card>
</fieldset>
</TabsContent>
<TabsContent value="services" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -1942,9 +2016,14 @@ export default function ContentWebsite({
</Dialog>
</CardContent>
</Card>
</fieldset>
</TabsContent>
<TabsContent value="partners" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -2102,9 +2181,14 @@ export default function ContentWebsite({
</Dialog>
</CardContent>
</Card>
</fieldset>
</TabsContent>
<TabsContent value="popup" className="mt-4">
<fieldset
disabled={viewOnly}
className="min-w-0 border-0 p-0 m-0"
>
<Card className="rounded-2xl border shadow-sm">
<CardContent className="space-y-6 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -2295,6 +2379,7 @@ export default function ContentWebsite({
</Dialog>
</CardContent>
</Card>
</fieldset>
</TabsContent>
</Tabs>
</div>

View File

@ -54,8 +54,8 @@ interface PostCount {
export default function DashboardContainer() {
const [levelName, setLevelName] = useState<string | undefined>();
useEffect(() => {
const levelId = Cookies.get("ulne");
setLevelName(levelId);
const roleId = Cookies.get("urie");
setLevelName(roleId);
}, []);
const username = Cookies.get("username");

View File

@ -13,6 +13,10 @@ import { listCmsContentSubmissions } from "@/service/cms-content-submissions";
import { getArticlesForMyContent } from "@/service/article";
import { apiPayload } from "@/service/cms-landing";
import { Loader2 } from "lucide-react";
import {
isApproverOrAdmin,
isContributorRole,
} from "@/constants/user-roles";
const PLACEHOLDER_IMG =
"https://placehold.co/400x240/f1f5f9/64748b?text=Content";
@ -149,11 +153,11 @@ export default function MyContent() {
const [articleRows, setArticleRows] = useState<ArticleRow[]>([]);
const [page, setPage] = useState(1);
const isApprover = levelId === "3";
const isContributor = levelId === "2";
const isContributor = isContributorRole(levelId);
const isApprover = isApproverOrAdmin(levelId);
useEffect(() => {
setLevelId(Cookies.get("ulne"));
setLevelId(Cookies.get("urie"));
}, []);
useEffect(() => {

View File

@ -19,6 +19,7 @@ import { getArticlePagination, deleteArticle } from "@/service/article";
import { formatDate } from "@/utils/global";
import { close, loading } from "@/config/swal";
import Cookies from "js-cookie";
import { isContributorRole } from "@/constants/user-roles";
import Swal from "sweetalert2";
import type { ArticleContentKind } from "@/constants/article-content-types";
import { ARTICLE_KIND_LABEL, articleListPath } from "@/constants/article-content-types";
@ -37,8 +38,7 @@ export default function NewsArticleList({ kind, typeId }: Props) {
const [levelId, setLevelId] = useState<string | undefined>();
useEffect(() => {
const ulne = Cookies.get("ulne");
setLevelId(ulne);
setLevelId(Cookies.get("urie"));
}, []);
useEffect(() => {
@ -128,7 +128,7 @@ export default function NewsArticleList({ kind, typeId }: Props) {
Create and manage {label.toLowerCase()} articles. Organize with tags only.
</p>
</div>
{levelId === "3" && (
{isContributorRole(levelId) && (
<Link href={`${basePath}/create`}>
<Button className="bg-blue-600 hover:bg-blue-700 rounded-lg">
<Plus className="w-4 h-4 mr-2" />
@ -203,7 +203,7 @@ export default function NewsArticleList({ kind, typeId }: Props) {
<Pencil className="w-4 h-4" />
</Button>
</Link>
{levelId === "3" && (
{isContributorRole(levelId) && (
<Button
size="icon"
variant="ghost"

View File

@ -19,6 +19,7 @@ import { getArticleByCategory, getArticlePagination } from "@/service/article";
import { formatDate } from "@/utils/global";
import { close, loading } from "@/config/swal";
import Cookies from "js-cookie";
import { isContributorRole } from "@/constants/user-roles";
export default function NewsImage() {
const [articles, setArticles] = useState<any[]>([]);
@ -30,8 +31,7 @@ export default function NewsImage() {
const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
useEffect(() => {
const ulne = Cookies.get("ulne");
setLevelId(ulne);
setLevelId(Cookies.get("urie"));
}, []);
useEffect(() => {
@ -127,7 +127,7 @@ export default function NewsImage() {
Create and manage news articles and blog posts
</p>
</div>
{levelId === "3" && (
{isContributorRole(levelId) && (
<Link href={"/admin/news-article/image/create"}>
<Button className="bg-blue-600 hover:bg-blue-700 rounded-lg">
<Plus className="w-4 h-4 mr-2" />

16
constants/user-roles.ts Normal file
View File

@ -0,0 +1,16 @@
/** Matches cookie `urie` / profile `userRoleId`. */
export const USER_ROLE_ADMIN = "1";
export const USER_ROLE_APPROVER = "2";
export const USER_ROLE_CONTRIBUTOR = "3";
export function isApproverOrAdmin(
roleId: string | undefined | null,
): boolean {
return roleId === USER_ROLE_ADMIN || roleId === USER_ROLE_APPROVER;
}
export function isContributorRole(
roleId: string | undefined | null,
): boolean {
return roleId === USER_ROLE_CONTRIBUTOR;
}