-
-
+
{p.cropPhotoTitle}
@@ -297,18 +296,18 @@ export function AvatarCropDialog({
-
- {p.changePhoto}
-
+ {onChooseDifferentPicture && (
+
+ {p.changePhoto}
+
+ )}
@@ -379,7 +383,7 @@ export function AvatarCropDialog({
{
event.preventDefault();
diff --git a/src/components/molecules/brand-card/BrandCard.module.css b/src/components/molecules/brand-card/BrandCard.module.css
index 3c1d370..f3475fd 100644
--- a/src/components/molecules/brand-card/BrandCard.module.css
+++ b/src/components/molecules/brand-card/BrandCard.module.css
@@ -362,6 +362,14 @@
overflow: hidden;
}
+.description :where(p, ul, ol) {
+ margin: 0;
+}
+
+.description :where(strong, b) {
+ font-weight: 700;
+}
+
.footer {
display: flex;
align-items: center;
diff --git a/src/components/molecules/brand-card/BrandCard.tsx b/src/components/molecules/brand-card/BrandCard.tsx
index 3b6374e..e7d0528 100644
--- a/src/components/molecules/brand-card/BrandCard.tsx
+++ b/src/components/molecules/brand-card/BrandCard.tsx
@@ -2,6 +2,7 @@
import Image from "next/image";
import { ProfileBox } from "@/components/molecules/profile-box";
+import { RichTextDisplay } from "@/components/molecules/rich-text-editor/rich-text-display";
import { useLocale } from "@/components/providers/locale-provider";
import styles from "./BrandCard.module.css";
@@ -201,7 +202,7 @@ export function BrandCard({
{hasDescription && (
{t.brandCardDescriptionLabel}
-
{description}
+
)}
diff --git a/src/components/molecules/data-table/data-table.module.css b/src/components/molecules/data-table/data-table.module.css
new file mode 100644
index 0000000..d70403f
--- /dev/null
+++ b/src/components/molecules/data-table/data-table.module.css
@@ -0,0 +1,198 @@
+.wrapper {
+ display: grid;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.scrollArea {
+ width: 100%;
+ overflow-x: clip;
+}
+
+.table {
+ width: 100%;
+ min-width: 0;
+ border-collapse: separate;
+ border-spacing: 0 0.38rem;
+ table-layout: fixed;
+}
+
+.headerCell {
+ padding: 0.42rem 0.55rem;
+ color: var(--app-text-subtle);
+ font-size: 0.6rem;
+ font-weight: 900;
+ letter-spacing: 0.14em;
+ line-height: 1.2;
+ text-transform: uppercase;
+ white-space: nowrap;
+ border-left: 1px solid color-mix(in srgb, var(--brand-border) 58%, transparent);
+}
+
+.headerCell:first-child {
+ border-left: 0;
+}
+
+.row {
+ position: relative;
+}
+
+.cell {
+ height: 3.35rem;
+ padding: 0.42rem 0.55rem;
+ vertical-align: middle;
+ background: var(--brand-surface-base);
+ border-top: 1px solid color-mix(in srgb, var(--brand-border) 90%, transparent);
+ border-bottom: 1px solid color-mix(in srgb, var(--brand-border) 90%, transparent);
+ border-left: 1px solid color-mix(in srgb, var(--brand-border) 58%, transparent);
+}
+
+.cell:first-child {
+ border-left: 1px solid color-mix(in srgb, var(--brand-border) 90%, transparent);
+ border-top-left-radius: var(--general-border-radius);
+ border-bottom-left-radius: var(--general-border-radius);
+}
+
+.cell:last-child {
+ border-right: 1px solid color-mix(in srgb, var(--brand-border) 90%, transparent);
+ border-top-right-radius: var(--general-border-radius);
+ border-bottom-right-radius: var(--general-border-radius);
+}
+
+.rowSelected .cell {
+ background: color-mix(in srgb, var(--app-primary-faint) 42%, var(--brand-surface-base) 58%);
+ border-color: color-mix(in srgb, var(--app-primary) 22%, var(--brand-border) 78%);
+}
+
+.alignLeft {
+ text-align: left;
+}
+
+.alignCenter {
+ text-align: center;
+}
+
+.alignRight {
+ text-align: right;
+}
+
+.selectionCell {
+ width: 2.5rem;
+ text-align: center;
+}
+
+.bulkBar {
+ min-height: 2.35rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.45rem 0.6rem;
+ border: 1px solid var(--app-border-soft);
+ border-radius: var(--general-border-radius);
+ background: var(--app-bg-surface);
+}
+
+.bulkCount {
+ min-width: 1.75rem;
+ height: 1.75rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ background: var(--app-primary-faint);
+ color: var(--app-primary);
+ font-weight: 900;
+}
+
+.bulkActions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+@media (max-width: 64rem) {
+ .headerCell {
+ padding-inline: 0.42rem;
+ font-size: 0.56rem;
+ letter-spacing: 0.11em;
+ }
+
+ .cell {
+ height: 3rem;
+ padding: 0.36rem 0.42rem;
+ }
+}
+
+@media (max-width: 48rem) {
+ .scrollArea {
+ overflow-x: visible;
+ }
+
+ .table,
+ .table colgroup,
+ .table thead,
+ .table tbody,
+ .table tr,
+ .table th,
+ .table td {
+ display: block;
+ }
+
+ .table colgroup,
+ .table thead {
+ display: none;
+ }
+
+ .table {
+ border-spacing: 0;
+ }
+
+ .row {
+ margin-bottom: 0.65rem;
+ border-radius: var(--general-border-radius);
+ overflow: hidden;
+ border: 1px solid color-mix(in srgb, var(--brand-border) 90%, transparent);
+ background: var(--brand-surface-base);
+ }
+
+ .cell {
+ min-height: 2.6rem;
+ height: auto;
+ display: grid;
+ grid-template-columns: minmax(5.75rem, 0.42fr) minmax(0, 1fr);
+ align-items: center;
+ gap: 0.7rem;
+ padding: 0.55rem 0.7rem;
+ text-align: left;
+ border: 0;
+ border-top: 1px solid color-mix(in srgb, var(--brand-border) 58%, transparent);
+ border-radius: 0;
+ }
+
+ .cell:first-child {
+ border: 0;
+ border-radius: 0;
+ }
+
+ .cell:last-child {
+ border-right: 0;
+ border-radius: 0;
+ }
+
+ .cell::before {
+ content: attr(data-label);
+ min-width: 0;
+ color: var(--app-text-subtle);
+ font-size: 0.58rem;
+ font-weight: 900;
+ letter-spacing: 0.12em;
+ line-height: 1.2;
+ text-transform: uppercase;
+ }
+
+ .selectionCell {
+ width: auto;
+ }
+}
diff --git a/src/components/molecules/data-table/data-table.tsx b/src/components/molecules/data-table/data-table.tsx
new file mode 100644
index 0000000..9a16d7a
--- /dev/null
+++ b/src/components/molecules/data-table/data-table.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import {
+ type CSSProperties,
+ type ReactNode,
+ useEffect,
+ useMemo,
+ useRef,
+} from "react";
+import { Checkbox } from "@/components/atoms/checkbox";
+import styles from "./data-table.module.css";
+
+type Align = "left" | "center" | "right";
+
+export type DataTableColumn = {
+ id: string;
+ header: ReactNode;
+ width?: string;
+ align?: Align;
+ className?: string;
+ cellClassName?: string;
+ render: (row: TRow) => ReactNode;
+};
+
+type DataTableProps = {
+ rows: TRow[];
+ columns: DataTableColumn[];
+ getRowId: (row: TRow) => string;
+ className?: string;
+ tableClassName?: string;
+ rowClassName?: (row: TRow) => string | undefined;
+ emptyState?: ReactNode;
+ bulkActions?: ReactNode;
+ selectedRowIds?: string[];
+ onSelectedRowIdsChange?: (ids: string[]) => void;
+ selectLabel?: string;
+};
+
+function joinClassNames(...classNames: Array) {
+ return classNames.filter(Boolean).join(" ");
+}
+
+function alignClassName(align: Align | undefined) {
+ if (align === "center") return styles.alignCenter;
+ if (align === "right") return styles.alignRight;
+ return styles.alignLeft;
+}
+
+function getColumnLabel(header: ReactNode) {
+ return typeof header === "string" || typeof header === "number"
+ ? String(header)
+ : undefined;
+}
+
+export function DataTable({
+ rows,
+ columns,
+ getRowId,
+ className,
+ tableClassName,
+ rowClassName,
+ emptyState,
+ bulkActions,
+ selectedRowIds,
+ onSelectedRowIdsChange,
+ selectLabel = "Select rows",
+}: DataTableProps) {
+ const selectable = Boolean(onSelectedRowIdsChange);
+ const selectedIds = selectedRowIds ?? [];
+ const rowIds = useMemo(() => rows.map(getRowId), [getRowId, rows]);
+ const allSelected = rowIds.length > 0 && rowIds.every((id) => selectedIds.includes(id));
+ const someSelected = rowIds.some((id) => selectedIds.includes(id));
+ const headerCheckboxRef = useRef(null);
+
+ useEffect(() => {
+ if (headerCheckboxRef.current) {
+ headerCheckboxRef.current.indeterminate = someSelected && !allSelected;
+ }
+ }, [allSelected, someSelected]);
+
+ function toggleAll(checked: boolean) {
+ if (!onSelectedRowIdsChange) return;
+ onSelectedRowIdsChange(checked ? rowIds : []);
+ }
+
+ function toggleRow(rowId: string, checked: boolean) {
+ if (!onSelectedRowIdsChange) return;
+ const next = checked
+ ? [...new Set([...selectedIds, rowId])]
+ : selectedIds.filter((id) => id !== rowId);
+ onSelectedRowIdsChange(next);
+ }
+
+ if (rows.length === 0) {
+ return <>{emptyState ?? null}>;
+ }
+
+ return (
+
+ {selectable && selectedIds.length > 0 ? (
+
+
{selectedIds.length}
+ {bulkActions ?
{bulkActions}
: null}
+
+ ) : null}
+
+
+
+
+ {selectable ? : null}
+ {columns.map((column) => (
+
+ ))}
+
+
+
+ {selectable ? (
+
+ toggleAll(event.target.checked)}
+ />
+
+ ) : null}
+ {columns.map((column) => (
+
+ {column.header}
+
+ ))}
+
+
+
+ {rows.map((row) => {
+ const rowId = getRowId(row);
+ const selected = selectedIds.includes(rowId);
+
+ return (
+
+ {selectable ? (
+
+ toggleRow(rowId, event.target.checked)}
+ />
+
+ ) : null}
+ {columns.map((column) => (
+
+ {column.render(row)}
+
+ ))}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/components/molecules/data-table/index.ts b/src/components/molecules/data-table/index.ts
new file mode 100644
index 0000000..25087ac
--- /dev/null
+++ b/src/components/molecules/data-table/index.ts
@@ -0,0 +1 @@
+export { DataTable, type DataTableColumn } from "./data-table";
diff --git a/src/components/molecules/form-actions/form-actions.module.css b/src/components/molecules/form-actions/form-actions.module.css
new file mode 100644
index 0000000..e96a197
--- /dev/null
+++ b/src/components/molecules/form-actions/form-actions.module.css
@@ -0,0 +1,101 @@
+.footer {
+ display: flex;
+ position: relative;
+ align-items: center;
+ gap: 0.75rem;
+ overflow: hidden;
+ isolation: isolate;
+ padding: 1.25rem 1.75rem;
+ border: 1px solid var(--app-border-soft);
+ border-radius: var(--general-border-radius-hero);
+ background: color-mix(in srgb, var(--app-bg-surface) 12%, transparent);
+ box-shadow: 0 1px 4px var(--app-shadow-card-soft);
+}
+
+.footer::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ background: color-mix(in srgb, var(--app-bg-surface) 16%, transparent);
+ backdrop-filter: blur(22px) saturate(1.08);
+ -webkit-backdrop-filter: blur(22px) saturate(1.08);
+}
+
+.footer > * {
+ position: relative;
+ z-index: 1;
+}
+
+.spacer {
+ flex: 1;
+}
+
+.danger {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding-left: 0.75rem;
+ border-left: 1px solid var(--app-border-soft);
+}
+
+.aside {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 0.625rem;
+ padding: 1.125rem 1.25rem;
+}
+
+.aside > *:not(.spacer):not(.danger) {
+ width: 100%;
+}
+
+.aside .spacer {
+ display: none;
+}
+
+.aside .danger {
+ width: 100%;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.5rem;
+ padding-top: 0.75rem;
+ padding-left: 0;
+ border-top: 1px solid var(--app-border-soft);
+ border-left: none;
+}
+
+.aside .danger > * {
+ width: 100%;
+}
+
+@media (max-width: 640px) {
+ .footer {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 0.625rem;
+ padding: 1rem 1.25rem;
+ }
+
+ .footer > *:not(.spacer):not(.danger) {
+ width: 100%;
+ }
+
+ .spacer {
+ display: none;
+ }
+
+ .danger {
+ justify-content: stretch;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding-top: 0.625rem;
+ padding-left: 0;
+ border-top: 1px solid var(--app-border-soft);
+ border-left: none;
+ }
+
+ .danger > * {
+ width: 100%;
+ }
+}
diff --git a/src/components/molecules/form-actions/form-actions.tsx b/src/components/molecules/form-actions/form-actions.tsx
new file mode 100644
index 0000000..06a102d
--- /dev/null
+++ b/src/components/molecules/form-actions/form-actions.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from "react";
+import styles from "./form-actions.module.css";
+
+type FormActionsProps = {
+ children: ReactNode;
+ layout?: "default" | "aside";
+ className?: string;
+};
+
+type FormActionsSectionProps = {
+ children: ReactNode;
+};
+
+function joinClassNames(...classNames: Array) {
+ return classNames.filter(Boolean).join(" ");
+}
+
+export function FormActions({ children, layout = "default", className }: FormActionsProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function FormActionsSpacer() {
+ return
;
+}
+
+export function FormActionsDanger({ children }: FormActionsSectionProps) {
+ return {children}
;
+}
diff --git a/src/components/molecules/form-actions/index.ts b/src/components/molecules/form-actions/index.ts
new file mode 100644
index 0000000..56fa075
--- /dev/null
+++ b/src/components/molecules/form-actions/index.ts
@@ -0,0 +1 @@
+export { FormActions, FormActionsDanger, FormActionsSpacer } from "./form-actions";
diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts
index b3bb76c..5593cce 100644
--- a/src/components/molecules/index.ts
+++ b/src/components/molecules/index.ts
@@ -1,6 +1,9 @@
export { AvatarCropDialog } from "./avatar-crop-dialog/avatar-crop-dialog";
+export { OwnerCard } from "./owner-card";
export { BrandCard } from "./brand-card";
+export { DataTable, type DataTableColumn } from "./data-table";
export { FeedbackPopup } from "./feedback-popup/feedback-popup";
+export { FormActions, FormActionsDanger, FormActionsSpacer } from "./form-actions";
export { LanguageSwitcher } from "./language-switcher";
export { ProfileBox } from "./profile-box";
export { ThemeSwitcher } from "./theme-switcher";
diff --git a/src/components/molecules/language-switcher/language-switcher.tsx b/src/components/molecules/language-switcher/language-switcher.tsx
index 090b36d..da9d525 100644
--- a/src/components/molecules/language-switcher/language-switcher.tsx
+++ b/src/components/molecules/language-switcher/language-switcher.tsx
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, Languages } from "lucide-react";
+import { Button } from "@/components/atoms/button";
import { useLocale } from "@/components/providers/locale-provider";
import {
getLocaleNames,
@@ -61,7 +62,8 @@ export function LanguageSwitcher({
className={joinClassNames(styles.panel, className)}
data-open={isOpen ? "" : undefined}
>
-
-
+
{isOpen ? (
{isActive ? : null}
-
+
);
})}
@@ -138,7 +141,8 @@ export function LanguageSwitcher({
className={joinClassNames(styles.compact, className)}
data-open={isOpen ? "" : undefined}
>
-
-
+
{isOpen ? (
{isActive ? : null}
-
+
);
})}
diff --git a/src/components/molecules/marketplace-search-box/marketplace-search-box.module.css b/src/components/molecules/marketplace-search-box/marketplace-search-box.module.css
new file mode 100644
index 0000000..a3c554f
--- /dev/null
+++ b/src/components/molecules/marketplace-search-box/marketplace-search-box.module.css
@@ -0,0 +1,185 @@
+.wrapper {
+ position: relative;
+ min-width: 0;
+}
+
+.inputShell {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ width: 100%;
+ min-height: 2rem;
+ padding: 0 0.75rem;
+ border-radius: 999px;
+ background: var(--app-bg-surface-strong);
+ border: 1px solid var(--app-border-soft);
+ color: var(--app-text-muted);
+ transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
+}
+
+.inputShell:focus-within {
+ border-color: var(--app-border-strong);
+ background: var(--app-bg-surface);
+ color: var(--app-text-strong);
+}
+
+.inputShell input {
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--app-text-strong);
+ font: inherit;
+ font-size: var(--font-size-extra-small);
+ font-weight: 700;
+}
+
+.inputShell input::placeholder {
+ color: var(--app-text-muted);
+}
+
+.header {
+ width: clamp(13rem, 24vw, 24rem);
+}
+
+.page {
+ width: 100%;
+}
+
+.page .inputShell {
+ min-height: 3.5rem;
+ padding: 0 1.1rem;
+ border-radius: 999px;
+}
+
+.page .inputShell input {
+ font-size: var(--font-size-base);
+}
+
+.dropdown {
+ position: absolute;
+ top: calc(100% + 0.5rem);
+ left: 0;
+ right: 0;
+ z-index: 40;
+ overflow: hidden;
+ border: 1px solid var(--app-border-soft);
+ border-radius: var(--general-border-radius-2xl);
+ background: var(--app-bg-sidebar);
+ box-shadow: 0 1rem 2.5rem var(--app-shadow-card-soft);
+}
+
+.dropdownHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.7rem 0.85rem 0.45rem;
+ color: var(--app-text-muted);
+ font-size: 0.68rem;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.suggestionList {
+ display: grid;
+ gap: 0.1rem;
+ padding: 0.25rem;
+}
+
+.suggestionItem,
+.searchSubmit {
+ width: 100%;
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 2.25rem minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.55rem 0.6rem;
+ border: 0;
+ border-radius: var(--general-border-radius-xl);
+ background: transparent;
+ color: var(--app-text-strong);
+ cursor: pointer;
+ text-align: left;
+}
+
+.searchSubmit {
+ grid-template-columns: 1rem minmax(0, 1fr);
+ margin: 0.25rem;
+ width: calc(100% - 0.5rem);
+}
+
+.suggestionItem:hover,
+.suggestionItem[data-active="true"],
+.searchSubmit:hover {
+ background: var(--app-bg-surface-strong);
+}
+
+.suggestionMedia {
+ position: relative;
+ width: 2.25rem;
+ height: 2.25rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ border-radius: 999px;
+ background: var(--app-bg-surface-strong);
+ color: var(--app-text-muted);
+ flex-shrink: 0;
+}
+
+.suggestionImage {
+ object-fit: cover;
+}
+
+.suggestionText {
+ min-width: 0;
+ display: grid;
+ gap: 0.12rem;
+}
+
+.suggestionText strong,
+.suggestionText small,
+.searchSubmit span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.suggestionText strong {
+ font-size: var(--font-size-extra-small);
+}
+
+.suggestionText small {
+ color: var(--app-text-muted);
+ font-size: 0.7rem;
+}
+
+.suggestionType {
+ padding: 0.2rem 0.45rem;
+ border-radius: 999px;
+ background: var(--app-bg-surface-strong);
+ color: var(--app-text-muted);
+ font-size: 0.62rem;
+ font-weight: 800;
+}
+
+@media (max-width: 48rem) {
+ .header {
+ width: 2rem;
+ }
+
+ .header .inputShell {
+ justify-content: center;
+ padding: 0;
+ }
+
+ .header .inputShell input {
+ display: none;
+ }
+}
diff --git a/src/components/molecules/marketplace-search-box/marketplace-search-box.tsx b/src/components/molecules/marketplace-search-box/marketplace-search-box.tsx
new file mode 100644
index 0000000..df3136a
--- /dev/null
+++ b/src/components/molecules/marketplace-search-box/marketplace-search-box.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import Image from "next/image";
+import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { Icon } from "@/components/icon";
+import { searchMarketplace, type MarketplaceSearchItem } from "@/lib/search-api";
+import { proxyMediaUrl } from "@/lib/media";
+import styles from "./marketplace-search-box.module.css";
+
+type MarketplaceSearchBoxProps = {
+ accessToken?: string;
+ placeholder: string;
+ className?: string;
+ variant?: "header" | "page";
+};
+
+function itemTypeLabel(type: MarketplaceSearchItem["type"]) {
+ const labels: Record
= {
+ brand: "Brend",
+ branch: "Filial",
+ service: "Servis",
+ uso: "USO",
+ address: "Ünvan",
+ };
+ return labels[type];
+}
+
+export function MarketplaceSearchBox({
+ accessToken,
+ placeholder,
+ className,
+ variant = "header",
+}: MarketplaceSearchBoxProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const listId = useId();
+ const wrapperRef = useRef(null);
+ const queryFromUrl = searchParams.get("query") ?? searchParams.get("queary") ?? "";
+ const [value, setValue] = useState(queryFromUrl);
+ const [focused, setFocused] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [activeIndex, setActiveIndex] = useState(-1);
+ const [suggestions, setSuggestions] = useState([]);
+
+ useEffect(() => {
+ setValue(queryFromUrl);
+ }, [queryFromUrl]);
+
+ useEffect(() => {
+ if (!focused || value.trim().length < 2) {
+ setSuggestions([]);
+ return undefined;
+ }
+
+ const timer = window.setTimeout(() => {
+ setLoading(true);
+ void searchMarketplace(value, accessToken, { limit: 7 })
+ .then((result) => {
+ setSuggestions(result.suggestions);
+ setActiveIndex(-1);
+ })
+ .finally(() => setLoading(false));
+ }, 180);
+
+ return () => window.clearTimeout(timer);
+ }, [accessToken, focused, value]);
+
+ useEffect(() => {
+ function handlePointerDown(event: MouseEvent) {
+ if (!wrapperRef.current?.contains(event.target as Node)) {
+ setFocused(false);
+ setActiveIndex(-1);
+ }
+ }
+
+ document.addEventListener("mousedown", handlePointerDown);
+ return () => document.removeEventListener("mousedown", handlePointerDown);
+ }, []);
+
+ function goSearch() {
+ const trimmed = value.trim();
+ if (!trimmed) return;
+ setFocused(false);
+ router.push(`/search?query=${encodeURIComponent(trimmed)}`);
+ }
+
+ function openSuggestion(item: MarketplaceSearchItem) {
+ setFocused(false);
+ router.push(item.href);
+ }
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setFocused(true);
+ setActiveIndex((index) => Math.min(suggestions.length - 1, index + 1));
+ return;
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setActiveIndex((index) => Math.max(-1, index - 1));
+ return;
+ }
+ if (event.key === "Enter") {
+ event.preventDefault();
+ if (activeIndex >= 0 && suggestions[activeIndex]) {
+ openSuggestion(suggestions[activeIndex]);
+ } else {
+ goSearch();
+ }
+ }
+ if (event.key === "Escape") {
+ setFocused(false);
+ setActiveIndex(-1);
+ }
+ }
+
+ const showDropdown = focused && value.trim().length >= 2;
+
+ return (
+
+
+
+ setFocused(true)}
+ onChange={(event) => setValue(event.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ aria-label={placeholder}
+ aria-controls={showDropdown ? listId : undefined}
+ aria-expanded={showDropdown}
+ role="combobox"
+ />
+
+
+ {showDropdown ? (
+
+
+ Did you mean
+ {loading ? : null}
+
+ {suggestions.length > 0 ? (
+
+ {suggestions.map((item, index) => {
+ const img = proxyMediaUrl(item.image_url);
+ return (
+ setActiveIndex(index)}
+ onClick={() => openSuggestion(item)}
+ >
+
+ {item.type === "address" ? (
+
+ ) : img ? (
+
+ ) : (
+
+ )}
+
+
+ {item.title}
+ {item.subtitle}
+
+ {itemTypeLabel(item.type)}
+
+ );
+ })}
+
+ ) : (
+
+
+ {value}
+
+ )}
+
+ ) : null}
+ {pathname === "/search" ? null : null}
+
+ );
+}
diff --git a/src/components/molecules/owner-card/index.ts b/src/components/molecules/owner-card/index.ts
new file mode 100644
index 0000000..1968114
--- /dev/null
+++ b/src/components/molecules/owner-card/index.ts
@@ -0,0 +1 @@
+export { OwnerCard } from "./owner-card";
diff --git a/src/components/molecules/owner-card/owner-card.module.css b/src/components/molecules/owner-card/owner-card.module.css
new file mode 100644
index 0000000..77e78e8
--- /dev/null
+++ b/src/components/molecules/owner-card/owner-card.module.css
@@ -0,0 +1,173 @@
+.card {
+ display: flex;
+ align-items: center;
+ gap: 0.875rem;
+ padding: 0.875rem 1rem;
+ border-radius: var(--general-border-radius-card);
+ border: 1px solid var(--app-border-soft);
+ background: var(--app-bg-surface-strong);
+ text-decoration: none;
+ transition:
+ background-color 140ms ease,
+ border-color 140ms ease,
+ box-shadow 140ms ease;
+ cursor: pointer;
+}
+
+.card:hover {
+ background: var(--app-bg-surface);
+ border-color: var(--app-border-strong);
+ box-shadow: 0 2px 8px var(--app-shadow-card-soft);
+}
+
+.disabled {
+ cursor: default;
+}
+
+.disabled:hover {
+ background: var(--app-bg-surface-strong);
+ border-color: var(--app-border-soft);
+ box-shadow: none;
+}
+
+.compact {
+ width: 100%;
+ min-width: 0;
+ gap: 0.55rem;
+ padding: 0.45rem 0.55rem;
+ border-radius: 8px;
+ background: transparent;
+ box-shadow: none;
+}
+
+.compact:hover {
+ background: var(--app-bg-surface-strong);
+ box-shadow: none;
+}
+
+/* Avatar area */
+.avatar {
+ flex-shrink: 0;
+}
+
+.logoWrap {
+ position: relative;
+ width: 3rem;
+ height: 3rem;
+ border-radius: 50%;
+ overflow: hidden;
+ border: 1.5px solid var(--app-border-soft);
+ background: var(--app-bg-surface-strong);
+}
+
+.logoImage {
+ object-fit: cover;
+}
+
+.userAvatar {
+ width: 3rem !important;
+ height: 3rem !important;
+}
+
+.compact .logoWrap,
+.compact .userAvatar {
+ width: 2.25rem !important;
+ height: 2.25rem !important;
+}
+
+/* Info column */
+.info {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.roleLabel {
+ color: var(--app-text-subtle);
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ line-height: 1.2;
+}
+
+.compact .roleLabel {
+ display: none;
+}
+
+.name {
+ color: var(--app-text-strong);
+ font-size: var(--font-size-base);
+ font-weight: 700;
+ line-height: 1.25;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.compact .name {
+ font-size: var(--font-size-small);
+ line-height: 1.2;
+}
+
+.compact .subtitle,
+.compact .ratingValue,
+.compact .ratingCount {
+ font-size: 0.7rem;
+}
+
+.compact .chevron {
+ display: none;
+}
+
+.disabled .chevron {
+ display: none;
+}
+
+.rating {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ color: var(--app-warning);
+ line-height: 1;
+}
+
+.starIcon {
+ color: var(--app-warning);
+ flex-shrink: 0;
+}
+
+.ratingValue {
+ color: var(--app-text-strong);
+ font-size: var(--font-size-small);
+ font-weight: 700;
+}
+
+.ratingCount {
+ color: var(--app-text-muted);
+ font-size: var(--font-size-extra-small);
+ font-weight: 500;
+}
+
+.subtitle {
+ color: var(--app-text-muted);
+ font-size: var(--font-size-extra-small);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Chevron */
+.chevron {
+ flex-shrink: 0;
+ color: var(--app-text-subtle);
+ opacity: 0.5;
+ transition: opacity 140ms ease, transform 140ms ease;
+}
+
+.card:hover .chevron {
+ opacity: 1;
+ transform: translateX(2px);
+}
diff --git a/src/components/molecules/owner-card/owner-card.tsx b/src/components/molecules/owner-card/owner-card.tsx
new file mode 100644
index 0000000..4035b54
--- /dev/null
+++ b/src/components/molecules/owner-card/owner-card.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import Link from "next/link";
+import Image from "next/image";
+import type { ReactNode } from "react";
+import { Icon } from "@/components/icon";
+import { UserAvatar } from "@/components/molecules/user-avatar/user-avatar";
+import { proxyMediaUrl } from "@/lib/media";
+import styles from "./owner-card.module.css";
+
+type OwnerCardProps = {
+ roleLabel: string;
+ name: string;
+ href?: string;
+ // Brand variant
+ logoUrl?: string | null;
+ rating?: number | null;
+ ratingCount?: number;
+ // User variant
+ avatarUrl?: string | null;
+ initials?: string;
+ subtitle?: string;
+ compact?: boolean;
+ disabled?: boolean;
+};
+
+function StarRating({ rating, count }: { rating: number; count: number }) {
+ return (
+
+
+ {rating.toFixed(1)}
+ {count > 0 && (
+ ({count})
+ )}
+
+ );
+}
+
+export function OwnerCard({
+ roleLabel,
+ name,
+ href,
+ logoUrl,
+ rating,
+ ratingCount,
+ avatarUrl,
+ initials = "?",
+ subtitle,
+ compact = false,
+ disabled = false,
+}: OwnerCardProps) {
+ const proxiedLogo = proxyMediaUrl(logoUrl ?? undefined);
+ const hasBrandLogo = !!proxiedLogo;
+ const hasRating = typeof rating === "number" && rating > 0;
+ const className = [
+ styles.card,
+ compact ? styles.compact : "",
+ disabled ? styles.disabled : "",
+ ].filter(Boolean).join(" ");
+
+ const content: ReactNode = (
+ <>
+
+ {hasBrandLogo ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {roleLabel}
+ {name}
+ {hasRating && (
+
+ )}
+ {!hasRating && subtitle && (
+ {subtitle}
+ )}
+
+
+
+ >
+ );
+
+ if (disabled || !href) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/src/components/molecules/page-surface-header/index.ts b/src/components/molecules/page-surface-header/index.ts
new file mode 100644
index 0000000..45ac627
--- /dev/null
+++ b/src/components/molecules/page-surface-header/index.ts
@@ -0,0 +1 @@
+export { PageSurfaceHeader } from "./page-surface-header";
diff --git a/src/components/molecules/page-surface-header/page-surface-header.module.css b/src/components/molecules/page-surface-header/page-surface-header.module.css
new file mode 100644
index 0000000..33680df
--- /dev/null
+++ b/src/components/molecules/page-surface-header/page-surface-header.module.css
@@ -0,0 +1,116 @@
+.header {
+ position: sticky;
+ top: 0.875rem;
+ z-index: 2;
+ overflow: hidden;
+ isolation: isolate;
+ display: flex;
+ align-items: center;
+ gap: 0.875rem;
+ width: 100%;
+ min-width: 0;
+ padding: 0.875rem 1rem;
+ margin-bottom: 2rem;
+ border-radius: var(--general-border-radius-card);
+ background: color-mix(in srgb, var(--app-bg-surface) 32%, transparent);
+ border: 1px solid var(--app-border-soft);
+ box-shadow: 0 1px 4px var(--app-shadow-card-soft);
+ backdrop-filter: blur(22px) saturate(1.08);
+}
+
+.header::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ background: color-mix(in srgb, var(--app-bg-surface) 36%, transparent);
+ -webkit-backdrop-filter: blur(22px) saturate(1.08);
+}
+
+.header > * {
+ position: relative;
+ z-index: 1;
+}
+
+.backButton {
+ flex-shrink: 0;
+}
+
+.meta {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.titleRow {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+ flex-wrap: wrap;
+}
+
+.title {
+ margin: 0;
+ color: var(--app-text-strong);
+ font-size: var(--font-size-large);
+ font-weight: 800;
+ letter-spacing: -0.03em;
+ line-height: 1.15;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.titleAddon {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.subtitle {
+ display: inline-flex;
+ width: fit-content;
+ max-width: 100%;
+ padding: 2px 8px;
+ border-radius: var(--general-border-radius-pill);
+ background: var(--app-bg-primary-soft);
+ border: 1px solid var(--app-border-primary-soft);
+ color: var(--app-primary);
+ font-size: var(--font-size-extra-small);
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ flex-shrink: 0;
+}
+
+@media (max-width: 640px) {
+ .header {
+ align-items: flex-start;
+ }
+
+ .title {
+ font-size: 1.25rem;
+ }
+
+ .subtitle {
+ max-width: min(16rem, 100%);
+ }
+
+ .actions {
+ align-self: stretch;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ }
+}
diff --git a/src/components/molecules/page-surface-header/page-surface-header.tsx b/src/components/molecules/page-surface-header/page-surface-header.tsx
new file mode 100644
index 0000000..75f205d
--- /dev/null
+++ b/src/components/molecules/page-surface-header/page-surface-header.tsx
@@ -0,0 +1,52 @@
+import type { ReactNode } from "react";
+import { Button } from "@/components/atoms/button";
+import styles from "./page-surface-header.module.css";
+
+type PageSurfaceHeaderProps = {
+ title: string;
+ onBack?: () => void;
+ backLabel?: string;
+ titleAddon?: ReactNode;
+ subtitle?: ReactNode;
+ actions?: ReactNode;
+ className?: string;
+};
+
+function joinClassNames(...classNames: Array) {
+ return classNames.filter(Boolean).join(" ");
+}
+
+export function PageSurfaceHeader({
+ title,
+ onBack,
+ backLabel,
+ titleAddon,
+ subtitle,
+ actions,
+ className,
+}: PageSurfaceHeaderProps) {
+ return (
+
+ {onBack ? (
+
+ ) : null}
+
+
+
+
{title}
+ {titleAddon ?
{titleAddon}
: null}
+
+ {subtitle ?
{subtitle}
: null}
+
+
+ {actions ? {actions}
: null}
+
+ );
+}
diff --git a/src/components/molecules/profile-box/profile-box.tsx b/src/components/molecules/profile-box/profile-box.tsx
index 9da0a5d..19a75d6 100644
--- a/src/components/molecules/profile-box/profile-box.tsx
+++ b/src/components/molecules/profile-box/profile-box.tsx
@@ -11,6 +11,7 @@ type ProfileBoxProps = {
label?: string;
subtitle?: string;
className?: string;
+ priority?: boolean;
};
export function ProfileBox({
@@ -20,6 +21,7 @@ export function ProfileBox({
label,
subtitle,
className,
+ priority = false,
}: ProfileBoxProps) {
const href = userId ? `/account?id=${userId}` : null;
const content = (
@@ -30,6 +32,7 @@ export function ProfileBox({
width={48}
height={48}
className={styles.avatar}
+ priority={priority}
/>
{label ?
{label} : null}
diff --git a/src/components/molecules/rich-text-editor/rich-text-display.module.css b/src/components/molecules/rich-text-editor/rich-text-display.module.css
new file mode 100644
index 0000000..328a2dd
--- /dev/null
+++ b/src/components/molecules/rich-text-editor/rich-text-display.module.css
@@ -0,0 +1,59 @@
+.empty {
+ color: var(--app-text-subtle);
+ font-style: italic;
+}
+
+.plain {
+ white-space: pre-wrap;
+ line-height: 1.6;
+ color: var(--app-text-base);
+}
+
+/* Rich HTML output from TipTap */
+.richText {
+ line-height: 1.6;
+ color: var(--app-text-base);
+}
+
+.richText p {
+ margin: 0 0 0.4rem;
+}
+
+.richText p:last-child {
+ margin-bottom: 0;
+}
+
+.richText strong {
+ font-weight: 700;
+}
+
+.richText em {
+ font-style: italic;
+}
+
+.richText u {
+ text-decoration: underline;
+}
+
+.richText ul,
+.richText ol {
+ margin: 0.25rem 0;
+ padding-left: 1.5rem;
+}
+
+.richText ul {
+ list-style-type: disc;
+}
+
+.richText ol {
+ list-style-type: decimal;
+}
+
+.richText li {
+ margin: 0.1rem 0;
+ line-height: 1.6;
+}
+
+.richText li p {
+ margin: 0;
+}
diff --git a/src/components/molecules/rich-text-editor/rich-text-display.tsx b/src/components/molecules/rich-text-editor/rich-text-display.tsx
new file mode 100644
index 0000000..cb6f463
--- /dev/null
+++ b/src/components/molecules/rich-text-editor/rich-text-display.tsx
@@ -0,0 +1,29 @@
+import { isRichHtml, sanitizeRichHtml } from "@/lib/rich-text";
+import styles from "./rich-text-display.module.css";
+
+type RichTextDisplayProps = {
+ html: string;
+ className?: string;
+ emptyFallback?: string;
+};
+
+export function RichTextDisplay({ html, className, emptyFallback }: RichTextDisplayProps) {
+ if (!html || html === "
") {
+ if (!emptyFallback) return null;
+ return
{emptyFallback} ;
+ }
+
+ // Plain text stored before rich text was introduced — render with whitespace preserved
+ if (!isRichHtml(html)) {
+ return (
+
{html}
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/molecules/rich-text-editor/rich-text-editor.module.css b/src/components/molecules/rich-text-editor/rich-text-editor.module.css
new file mode 100644
index 0000000..2143eca
--- /dev/null
+++ b/src/components/molecules/rich-text-editor/rich-text-editor.module.css
@@ -0,0 +1,176 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--app-border-soft);
+ border-radius: var(--general-border-radius-lg);
+ background: var(--app-bg-surface-strong);
+ transition:
+ border-color 160ms ease,
+ background-color 160ms ease,
+ box-shadow 160ms ease;
+}
+
+.wrapper:hover:not(.disabled) {
+ border-color: var(--app-border-primary-strong);
+ background: var(--app-bg-canvas);
+}
+
+.wrapper:focus-within {
+ border-color: var(--app-primary);
+ box-shadow: 0 0 0 0.286rem var(--app-focus-ring);
+ background: var(--app-bg-canvas);
+}
+
+.wrapper.disabled {
+ border-color: var(--app-border-soft);
+ background: var(--app-bg-surface-muted);
+ opacity: 0.6;
+ pointer-events: none;
+}
+
+/* Toolbar */
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.375rem 0.5rem;
+ border-bottom: 1px solid var(--app-border-soft);
+ flex-wrap: wrap;
+}
+
+.toolBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ border: none;
+ border-radius: var(--general-border-radius-small);
+ background: transparent;
+ color: var(--app-text-muted);
+ font-size: var(--font-size-small);
+ cursor: pointer;
+ transition: background 120ms ease, color 120ms ease;
+}
+
+.toolBtn:hover {
+ background: var(--app-bg-surface-strong);
+ color: var(--app-text-strong);
+}
+
+.toolBtnActive {
+ background: var(--app-bg-primary-soft);
+ color: var(--app-primary);
+}
+
+.underlineIcon {
+ text-decoration: underline;
+}
+
+.listIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+}
+
+.separator {
+ width: 1px;
+ height: 1.25rem;
+ background: var(--app-border-soft);
+ flex-shrink: 0;
+ margin: 0 0.125rem;
+}
+
+/* Color swatches */
+.colorRow {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.colorSwatch {
+ width: 1.1rem;
+ height: 1.1rem;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ cursor: pointer;
+ transition: transform 120ms ease, border-color 120ms ease;
+ flex-shrink: 0;
+ padding: 0;
+}
+
+/* "Default" swatch */
+.colorSwatch:first-child {
+ background: linear-gradient(135deg, #fff 50%, #e2e8f0 50%);
+ border-color: var(--app-border-default);
+}
+
+.colorSwatch:hover {
+ transform: scale(1.2);
+}
+
+.colorSwatchActive {
+ border-color: var(--app-text-strong) !important;
+ transform: scale(1.2);
+}
+
+/* Editor area */
+.editorContent {
+ position: relative;
+ padding: 0.5rem calc(var(--general-control-height, 2.25rem) * 0.35);
+ min-height: 10rem;
+ cursor: text;
+ resize: vertical;
+ overflow: auto;
+}
+
+/* ProseMirror root */
+.editorContent :global(.ProseMirror) {
+ outline: none;
+ min-height: 9rem;
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-small);
+ line-height: 1.6;
+ color: var(--app-text-strong);
+}
+
+.editorContent :global(.ProseMirror p) {
+ margin: 0 0 0.25rem;
+}
+
+.editorContent :global(.ProseMirror p:last-child) {
+ margin-bottom: 0;
+}
+
+.editorContent :global(.ProseMirror ul),
+.editorContent :global(.ProseMirror ol) {
+ margin: 0.25rem 0;
+ padding-left: 1.5rem;
+}
+
+.editorContent :global(.ProseMirror ul) {
+ list-style-type: disc;
+}
+
+.editorContent :global(.ProseMirror ol) {
+ list-style-type: decimal;
+}
+
+.editorContent :global(.ProseMirror li) {
+ margin: 0.1rem 0;
+ line-height: 1.6;
+}
+
+.editorContent :global(.ProseMirror li p) {
+ margin: 0;
+}
+
+/* TipTap placeholder */
+.editorContent :global(.ProseMirror p.is-editor-empty:first-child::before) {
+ content: attr(data-placeholder);
+ color: var(--app-text-subtle);
+ pointer-events: none;
+ float: left;
+ height: 0;
+}
diff --git a/src/components/molecules/rich-text-editor/rich-text-editor.tsx b/src/components/molecules/rich-text-editor/rich-text-editor.tsx
new file mode 100644
index 0000000..9583282
--- /dev/null
+++ b/src/components/molecules/rich-text-editor/rich-text-editor.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+import { useEditor, EditorContent } from "@tiptap/react";
+import { Button } from "@/components/atoms/button";
+import StarterKit from "@tiptap/starter-kit";
+import { Color } from "@tiptap/extension-color";
+import { TextStyle } from "@tiptap/extension-text-style";
+import Underline from "@tiptap/extension-underline";
+import Placeholder from "@tiptap/extension-placeholder";
+import { useEffect } from "react";
+import styles from "./rich-text-editor.module.css";
+import { useLocale } from "@/components/providers/locale-provider";
+
+type RichTextEditorProps = {
+ value: string;
+ onChange: (html: string) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ className?: string;
+};
+
+export function RichTextEditor({
+ value,
+ onChange,
+ placeholder,
+ disabled,
+ className,
+}: RichTextEditorProps) {
+ const { messages } = useLocale();
+ const rt = messages.richText;
+
+ const COLORS = [
+ { label: rt.colorDefault, value: null },
+ { label: rt.colorRed, value: "#e53e3e" },
+ { label: rt.colorOrange, value: "#dd6b20" },
+ { label: rt.colorGreen, value: "#38a169" },
+ { label: rt.colorBlue, value: "#3182ce" },
+ { label: rt.colorPurple, value: "#805ad5" },
+ { label: rt.colorGray, value: "#718096" },
+ ];
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ blockquote: false,
+ codeBlock: false,
+ horizontalRule: false,
+ heading: false,
+ code: false,
+ strike: false,
+ }),
+ TextStyle,
+ Color,
+ Underline,
+ Placeholder.configure({ placeholder: placeholder ?? "" }),
+ ],
+ content: value || "",
+ immediatelyRender: false,
+ editable: !disabled,
+ onUpdate({ editor }) {
+ const html = editor.getHTML();
+ // Treat empty editor as empty string
+ onChange(html === "
" ? "" : html);
+ },
+ });
+
+ // Sync external value changes (e.g. form reset)
+ useEffect(() => {
+ if (!editor) return;
+ const current = editor.getHTML();
+ const incoming = value || "";
+ if (current !== incoming && incoming !== (current === "
" ? "" : current)) {
+ editor.commands.setContent(incoming || "");
+ }
+ }, [value]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ editor?.setEditable(!disabled);
+ }, [disabled, editor]);
+
+ if (!editor) return null;
+
+ function toggleList(type: "bulletList" | "orderedList") {
+ const toggleCmd = type === "bulletList"
+ ? () => editor!.chain().focus().toggleBulletList().run()
+ : () => editor!.chain().focus().toggleOrderedList().run();
+
+ if (editor!.isActive(type)) {
+ toggleCmd();
+ return;
+ }
+
+ // Remove empty paragraphs inside selection before converting to list
+ // so blank lines don't become empty list items
+ editor!
+ .chain()
+ .focus()
+ .command(({ tr, state, dispatch }) => {
+ const { from, to } = state.selection;
+ const toDelete: Array<{ pos: number; size: number }> = [];
+ state.doc.nodesBetween(from, to, (node, pos) => {
+ if (node.type.name === "paragraph" && node.childCount === 0) {
+ toDelete.push({ pos, size: node.nodeSize });
+ }
+ });
+ if (dispatch && toDelete.length > 0) {
+ toDelete.reverse().forEach(({ pos, size }) => tr.delete(pos, pos + size));
+ dispatch(tr);
+ }
+ return true;
+ })
+ [type === "bulletList" ? "toggleBulletList" : "toggleOrderedList"]()
+ .run();
+ }
+
+ const isBold = editor.isActive("bold");
+ const isItalic = editor.isActive("italic");
+ const isUnderline = editor.isActive("underline");
+ const isBulletList = editor.isActive("bulletList");
+ const isOrderedList = editor.isActive("orderedList");
+ const activeColor = COLORS.find((c) => c.value && editor.isActive("textStyle", { color: c.value }));
+
+ return (
+
+
+
editor.chain().focus().toggleBold().run()}
+ disabled={disabled}
+ >
+ B
+
+
+
editor.chain().focus().toggleItalic().run()}
+ disabled={disabled}
+ >
+ I
+
+
+
editor.chain().focus().toggleUnderline().run()}
+ disabled={disabled}
+ >
+ U
+
+
+
+
+
toggleList("bulletList")}
+ disabled={disabled}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
toggleList("orderedList")}
+ disabled={disabled}
+ >
+
+
+ 1.
+
+ 2.
+
+ 3.
+
+
+
+
+
+
+
+
+ {COLORS.map((c) => (
+ {
+ if (c.value === null) {
+ editor.chain().focus().unsetColor().run();
+ } else {
+ editor.chain().focus().setColor(c.value).run();
+ }
+ }}
+ />
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/molecules/social-links-editor/social-links-editor.module.css b/src/components/molecules/social-links-editor/social-links-editor.module.css
new file mode 100644
index 0000000..e503a22
--- /dev/null
+++ b/src/components/molecules/social-links-editor/social-links-editor.module.css
@@ -0,0 +1,124 @@
+.editor {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.625rem;
+ border-radius: var(--general-border-radius-lg);
+ background: var(--app-bg-surface-strong);
+ border: 1px solid var(--app-border-soft);
+ min-width: 0;
+}
+
+.itemIcon {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.itemUrl {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--font-size-small);
+ color: var(--app-text-strong);
+ font-weight: 500;
+}
+
+.removeBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 1.25rem;
+ height: 1.25rem;
+ border-radius: 50%;
+ border: none;
+ background: transparent;
+ color: var(--app-text-subtle);
+ cursor: pointer;
+ transition: color 0.15s, background 0.15s;
+ padding: 0;
+}
+
+.removeBtn:hover {
+ color: var(--app-text-strong);
+ background: var(--app-bg-canvas);
+}
+
+.addRow {
+ display: flex;
+ gap: 0;
+ border: 1px solid var(--app-border-soft);
+ border-radius: var(--general-border-radius-lg);
+ background: var(--app-bg-surface-strong);
+ overflow: hidden;
+ transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
+}
+
+.addRow:focus-within {
+ border-color: var(--app-border-primary-strong);
+ background: var(--app-bg-canvas);
+ box-shadow: 0 0 0 0.286rem var(--app-focus-ring);
+}
+
+.addRowError {
+ border-color: var(--app-danger-border, #fca5a5);
+}
+
+.addInput {
+ flex: 1;
+ min-width: 0;
+ padding: 0.571rem 0.75rem;
+ border: none;
+ background: transparent;
+ outline: none;
+ font-size: var(--font-size-small);
+ color: var(--app-text-strong);
+ font-family: inherit;
+}
+
+.addInput::placeholder {
+ color: var(--app-text-subtle);
+}
+
+.addBtn {
+ flex-shrink: 0;
+ padding: 0 0.875rem;
+ border: none;
+ border-left: 1px solid var(--app-border-soft);
+ background: var(--app-bg-surface);
+ color: var(--app-text-muted);
+ font-size: var(--font-size-small);
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+ white-space: nowrap;
+}
+
+.addBtn:hover {
+ background: var(--app-primary-faint);
+ color: var(--app-primary-soft);
+}
+
+.errorMsg {
+ margin: 0;
+ font-size: var(--font-size-extra-small);
+ color: var(--app-danger-strong, #dc2626);
+}
diff --git a/src/components/molecules/social-links-editor/social-links-editor.tsx b/src/components/molecules/social-links-editor/social-links-editor.tsx
new file mode 100644
index 0000000..a578f22
--- /dev/null
+++ b/src/components/molecules/social-links-editor/social-links-editor.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import { forwardRef, useImperativeHandle, useRef, useState } from "react";
+import { Button } from "@/components/atoms/button";
+import { SocialIcon, SOCIAL_COLORS } from "@/components/atoms/social-icon/social-icon";
+import {
+ detectSocialPlatform,
+ validateSocialUrl,
+ type SocialUrlErrorKey,
+} from "@/lib/social-url";
+import styles from "./social-links-editor.module.css";
+
+export type SocialLinksEditorMessages = {
+ addPlaceholder: string;
+ addButton: string;
+ removeLabel: string;
+ errorInvalidFormat: string;
+ errorInvalidProtocol: string;
+ errorTooLong: string;
+ errorInvalidChars: string;
+};
+
+export type SocialLinksEditorRef = {
+ /** Commit any pending typed URL before form submit. Safe to call when input is empty. */
+ flush: () => void;
+};
+
+type Props = {
+ value: string[];
+ onChange: (urls: string[]) => void;
+ messages: SocialLinksEditorMessages;
+};
+
+export const SocialLinksEditor = forwardRef
(
+ function SocialLinksEditor({ value, onChange, messages }, ref) {
+ const [input, setInput] = useState("");
+ const [error, setError] = useState(null);
+ const inputRef = useRef(null);
+
+ const ERROR_LABELS: Record = {
+ invalid_format: messages.errorInvalidFormat,
+ invalid_protocol: messages.errorInvalidProtocol,
+ too_long: messages.errorTooLong,
+ invalid_chars: messages.errorInvalidChars,
+ };
+
+ function commit() {
+ const url = input.trim();
+ if (!url) return;
+
+ const errorKey = validateSocialUrl(url);
+ if (errorKey) {
+ setError(ERROR_LABELS[errorKey]);
+ return;
+ }
+
+ const platform = detectSocialPlatform(url);
+ const filtered = value.filter((u) => detectSocialPlatform(u) !== platform);
+ onChange([...filtered, url]);
+ setInput("");
+ setError(null);
+ // Don't re-focus after commit — lets the browser move focus normally
+ // (e.g. to the submit button the user just clicked)
+ }
+
+ useImperativeHandle(ref, () => ({
+ flush: commit,
+ }));
+
+ function remove(index: number) {
+ onChange(value.filter((_, i) => i !== index));
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commit();
+ }
+ if (e.key === "Escape") {
+ setInput("");
+ setError(null);
+ }
+ }
+
+ return (
+
+ {value.length > 0 && (
+
+ {value.map((url, i) => {
+ const platform = detectSocialPlatform(url);
+ return (
+
+
+
+
+ {url}
+ remove(i)}
+ >
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {
+ setInput(e.target.value);
+ setError(null);
+ }}
+ onKeyDown={handleKeyDown}
+ autoComplete="off"
+ spellCheck={false}
+ />
+
+ {messages.addButton}
+
+
+
+ {error &&
{error}
}
+
+ );
+ },
+);
diff --git a/src/components/molecules/status-badge/index.ts b/src/components/molecules/status-badge/index.ts
new file mode 100644
index 0000000..18bd0d3
--- /dev/null
+++ b/src/components/molecules/status-badge/index.ts
@@ -0,0 +1,2 @@
+export { StatusBadge } from "./status-badge";
+export type { StatusBadgeTone } from "./status-badge";
diff --git a/src/components/molecules/status-badge/status-badge.module.css b/src/components/molecules/status-badge/status-badge.module.css
new file mode 100644
index 0000000..e168bf2
--- /dev/null
+++ b/src/components/molecules/status-badge/status-badge.module.css
@@ -0,0 +1,100 @@
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.32rem;
+ min-height: 1.85rem;
+ padding: 0.34rem 0.72rem;
+ border-radius: var(--general-border-radius-pill);
+ border: 1px solid var(--badge-border);
+ background: var(--badge-bg);
+ color: var(--badge-text);
+ font-size: var(--font-size-small);
+ font-weight: 800;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.success {
+ --badge-soft-bg: var(--app-success-bg);
+ --badge-soft-border: var(--app-success-border);
+ --badge-soft-text: var(--app-success);
+ --badge-solid-bg: var(--app-success);
+ --badge-solid-border: color-mix(in srgb, var(--app-success) 72%, white 28%);
+ --badge-solid-text: var(--app-text-inverse);
+ --badge-overlay-bg: var(--app-success);
+ --badge-overlay-border: color-mix(in srgb, var(--app-success) 74%, white 26%);
+ --badge-overlay-text: var(--app-text-inverse);
+}
+
+.warning {
+ --badge-soft-bg: var(--app-warning-bg);
+ --badge-soft-border: var(--app-warning-border);
+ --badge-soft-text: var(--app-warning-strong);
+ --badge-solid-bg: var(--app-warning);
+ --badge-solid-border: color-mix(in srgb, var(--app-warning) 76%, white 24%);
+ --badge-solid-text: rgb(0 0 0 / 0.78);
+ --badge-overlay-bg: color-mix(in srgb, var(--app-warning) 88%, white 12%);
+ --badge-overlay-border: color-mix(in srgb, var(--app-warning) 72%, white 28%);
+ --badge-overlay-text: rgb(0 0 0 / 0.82);
+}
+
+.error {
+ --badge-soft-bg: var(--app-error-bg);
+ --badge-soft-border: var(--app-error-border);
+ --badge-soft-text: var(--app-error);
+ --badge-solid-bg: var(--app-error);
+ --badge-solid-border: color-mix(in srgb, var(--app-error) 72%, white 28%);
+ --badge-solid-text: var(--app-text-inverse);
+ --badge-overlay-bg: var(--app-error);
+ --badge-overlay-border: color-mix(in srgb, var(--app-error) 72%, white 28%);
+ --badge-overlay-text: var(--app-text-inverse);
+}
+
+.info {
+ --badge-soft-bg: color-mix(in srgb, var(--app-primary) 8%, transparent);
+ --badge-soft-border: color-mix(in srgb, var(--app-primary) 22%, transparent);
+ --badge-soft-text: color-mix(in srgb, var(--app-primary) 82%, var(--app-text-strong));
+ --badge-solid-bg: var(--app-primary);
+ --badge-solid-border: color-mix(in srgb, var(--app-primary) 72%, white 28%);
+ --badge-solid-text: var(--app-text-inverse);
+ --badge-overlay-bg: var(--app-primary);
+ --badge-overlay-border: color-mix(in srgb, var(--app-primary) 72%, white 28%);
+ --badge-overlay-text: var(--app-text-inverse);
+}
+
+.muted {
+ --badge-soft-bg: var(--app-bg-surface-strong);
+ --badge-soft-border: var(--app-border-soft);
+ --badge-soft-text: var(--app-text-muted);
+ --badge-solid-bg: rgb(0 0 0 / 0.62);
+ --badge-solid-border: rgb(255 255 255 / 0.16);
+ --badge-solid-text: var(--app-text-inverse);
+ --badge-overlay-bg: color-mix(in srgb, var(--app-bg-surface) 92%, transparent);
+ --badge-overlay-border: color-mix(in srgb, var(--app-border-soft) 84%, transparent);
+ --badge-overlay-text: var(--app-text-muted);
+}
+
+.soft {
+ --badge-bg: var(--badge-soft-bg);
+ --badge-border: var(--badge-soft-border);
+ --badge-text: var(--badge-soft-text);
+}
+
+.solid {
+ --badge-bg: var(--badge-solid-bg);
+ --badge-border: var(--badge-solid-border);
+ --badge-text: var(--badge-solid-text);
+ box-shadow: 0 0.75rem 1.5rem rgb(0 0 0 / 0.18);
+}
+
+.overlay {
+ --badge-bg: var(--badge-overlay-bg);
+ --badge-border: var(--badge-overlay-border);
+ --badge-text: var(--badge-overlay-text);
+ box-shadow: 0 0.75rem 1.5rem rgb(0 0 0 / 0.18);
+}
+
+.icon {
+ flex-shrink: 0;
+}
diff --git a/src/components/molecules/status-badge/status-badge.tsx b/src/components/molecules/status-badge/status-badge.tsx
new file mode 100644
index 0000000..f9cd765
--- /dev/null
+++ b/src/components/molecules/status-badge/status-badge.tsx
@@ -0,0 +1,36 @@
+import type { HTMLAttributes, ReactNode } from "react";
+import { Icon } from "@/components/icon";
+import styles from "./status-badge.module.css";
+
+export type StatusBadgeTone = "success" | "warning" | "error" | "info" | "muted";
+type StatusBadgeAppearance = "soft" | "solid" | "overlay";
+
+type StatusBadgeProps = HTMLAttributes & {
+ appearance?: StatusBadgeAppearance;
+ children: ReactNode;
+ icon?: string;
+ tone?: StatusBadgeTone;
+};
+
+function joinClassNames(...classNames: Array) {
+ return classNames.filter(Boolean).join(" ");
+}
+
+export function StatusBadge({
+ appearance = "soft",
+ children,
+ className,
+ icon,
+ tone = "info",
+ ...props
+}: StatusBadgeProps) {
+ return (
+
+ {icon ? : null}
+ {children}
+
+ );
+}
diff --git a/src/components/molecules/status-banner/index.ts b/src/components/molecules/status-banner/index.ts
new file mode 100644
index 0000000..552a5c2
--- /dev/null
+++ b/src/components/molecules/status-banner/index.ts
@@ -0,0 +1,2 @@
+export { StatusBanner } from "./status-banner";
+export type { StatusBannerVariant } from "./status-banner";
diff --git a/src/components/molecules/status-banner/status-banner.module.css b/src/components/molecules/status-banner/status-banner.module.css
new file mode 100644
index 0000000..adc551b
--- /dev/null
+++ b/src/components/molecules/status-banner/status-banner.module.css
@@ -0,0 +1,49 @@
+.banner {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ width: 100%;
+ min-width: 0;
+ padding: 0.75rem 1rem;
+ border-radius: var(--general-border-radius-card);
+ border: 1px solid var(--banner-border);
+ background: var(--banner-bg);
+ color: var(--banner-text);
+ font-size: var(--font-size-small);
+ line-height: 1.5;
+}
+
+.success {
+ --banner-bg: var(--app-success-bg);
+ --banner-border: var(--app-success-border);
+ --banner-text: var(--app-success);
+}
+
+.warning {
+ --banner-bg: var(--app-warning-bg);
+ --banner-border: var(--app-warning-border);
+ --banner-text: var(--app-warning-strong);
+}
+
+.error {
+ --banner-bg: var(--app-error-bg);
+ --banner-border: var(--app-error-border);
+ --banner-text: var(--app-error);
+}
+
+.info {
+ --banner-bg: color-mix(in srgb, var(--app-primary) 8%, transparent);
+ --banner-border: color-mix(in srgb, var(--app-primary) 20%, transparent);
+ --banner-text: color-mix(in srgb, var(--app-primary) 80%, var(--app-text-strong));
+}
+
+.muted {
+ --banner-bg: var(--app-bg-surface-strong);
+ --banner-border: var(--app-border-soft);
+ --banner-text: var(--app-text-muted);
+}
+
+.icon {
+ flex-shrink: 0;
+ margin-top: 0.1rem;
+}
diff --git a/src/components/molecules/status-banner/status-banner.tsx b/src/components/molecules/status-banner/status-banner.tsx
new file mode 100644
index 0000000..e98dfbd
--- /dev/null
+++ b/src/components/molecules/status-banner/status-banner.tsx
@@ -0,0 +1,37 @@
+import type { HTMLAttributes, ReactNode } from "react";
+import { Icon } from "@/components/icon";
+import styles from "./status-banner.module.css";
+
+export type StatusBannerVariant = "success" | "warning" | "error" | "info" | "muted";
+
+type StatusBannerProps = Omit, "title"> & {
+ children: ReactNode;
+ icon?: string;
+ variant?: StatusBannerVariant;
+};
+
+function joinClassNames(...classNames: Array) {
+ return classNames.filter(Boolean).join(" ");
+}
+
+export function StatusBanner({
+ children,
+ className,
+ icon,
+ role,
+ variant = "info",
+ ...props
+}: StatusBannerProps) {
+ return (
+
+ {icon ? (
+
+ ) : null}
+ {children}
+
+ );
+}
diff --git a/src/components/molecules/theme-switcher/theme-switcher.module.css b/src/components/molecules/theme-switcher/theme-switcher.module.css
index c0c6eea..17c9a22 100644
--- a/src/components/molecules/theme-switcher/theme-switcher.module.css
+++ b/src/components/molecules/theme-switcher/theme-switcher.module.css
@@ -29,12 +29,12 @@
align-items: center;
justify-content: center;
padding: 0.286rem 0.571rem;
- border: 1px solid var(--app-border-primary-soft);
+ border: 1px solid var(--app-border-soft);
border-radius: var(--general-border-radius-pill);
- background: var(--app-bg-primary-soft);
- color: var(--app-primary-strong);
+ background: var(--app-bg-surface-strong);
+ color: var(--app-text-muted);
font-size: var(--font-size-extra-small);
- font-weight: 800;
+ font-weight: 700;
line-height: 1;
}
@@ -97,15 +97,11 @@
}
.optionActive {
- color: var(--app-primary-strong);
- background: linear-gradient(
- 180deg,
- color-mix(in srgb, var(--app-bg-primary-soft) 82%, var(--app-bg-surface-strong) 18%),
- var(--app-bg-surface-strong)
- );
+ color: var(--app-text-strong);
+ background: var(--app-bg-surface);
box-shadow:
- 0 0.714rem 1.5rem var(--app-shadow-card-soft),
- inset 0 0 0 1px var(--app-border-primary-soft);
+ 0 0.5rem 1.5rem var(--app-shadow-card-soft),
+ inset 0 0 0 1px var(--app-border-soft);
}
.compact .option:hover {
@@ -115,9 +111,9 @@
}
.compact .optionActive {
- color: var(--app-primary);
- background: var(--app-bg-primary-soft);
- box-shadow: inset 0 0 0 1px var(--app-border-primary-soft);
+ color: var(--app-text-strong);
+ background: var(--app-bg-surface);
+ box-shadow: 0 0.286rem 0.857rem var(--app-shadow-card-soft), inset 0 0 0 1px var(--app-border-soft);
}
.optionIcon {
@@ -149,7 +145,7 @@
.compact .optionActive .optionIcon {
background: transparent;
- color: var(--app-primary);
+ color: var(--app-text-strong);
box-shadow: none;
}
diff --git a/src/components/molecules/theme-switcher/theme-switcher.tsx b/src/components/molecules/theme-switcher/theme-switcher.tsx
index ee1cb26..d4ce75c 100644
--- a/src/components/molecules/theme-switcher/theme-switcher.tsx
+++ b/src/components/molecules/theme-switcher/theme-switcher.tsx
@@ -1,9 +1,9 @@
"use client";
import { Monitor, Moon, Sun, type LucideIcon } from "lucide-react";
+import { Button } from "@/components/atoms/button";
import { useLocale } from "@/components/providers/locale-provider";
import { useTheme } from "@/components/providers/theme-provider";
-import type { Locale } from "@/i18n/config";
import {
themePreferences,
type ThemePreference,
@@ -15,34 +15,6 @@ type ThemeSwitcherProps = {
variant?: "compact" | "panel";
};
-type ThemeCopy = {
- title: string;
- system: string;
- light: string;
- dark: string;
-};
-
-const themeCopy: Record = {
- az: { title: "Tema", system: "Sistem", light: "Açıq", dark: "Tünd" },
- en: { title: "Theme", system: "System", light: "Light", dark: "Dark" },
- ru: { title: "Тема", system: "Система", light: "Светлая", dark: "Тёмная" },
- es: { title: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
- fr: { title: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
- tr: { title: "Tema", system: "Sistem", light: "Light", dark: "Dark" },
- ar: { title: "السمة", system: "النظام", light: "فاتح", dark: "داكن" },
- de: { title: "Thema", system: "System", light: "Hell", dark: "Dunkel" },
- zh: { title: "主题", system: "系统", light: "浅色", dark: "深色" },
- ja: { title: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
- hi: { title: "थीम", system: "सिस्टम", light: "लाइट", dark: "डार्क" },
- la: { title: "Thema", system: "Systema", light: "Clarum", dark: "Obscurum" },
- fa: { title: "پوسته", system: "سیستم", light: "روشن", dark: "تیره" },
- it: { title: "Tema", system: "Sistema", light: "Chiaro", dark: "Scuro" },
- uk: { title: "Тема", system: "Система", light: "Світла", dark: "Темна" },
- pt: { title: "Tema", system: "Sistema", light: "Claro", dark: "Escuro" },
- he: { title: "ערכת נושא", system: "מערכת", light: "בהיר", dark: "כהה" },
- uz: { title: "Mavzu", system: "Tizim", light: "Yorug‘", dark: "Qorong‘i" },
-};
-
const themeIcons: Record = {
system: Monitor,
light: Sun,
@@ -57,9 +29,9 @@ export function ThemeSwitcher({
className,
variant = "compact",
}: ThemeSwitcherProps) {
- const { locale } = useLocale();
+ const { messages } = useLocale();
const { theme, resolvedTheme, setTheme } = useTheme();
- const copy = themeCopy[locale];
+ const copy = messages.theme;
const labels: Record = {
system: copy.system,
light: copy.light,
@@ -87,7 +59,8 @@ export function ThemeSwitcher({
const isActive = option === theme;
return (
- {labels[option]}
) : null}
-
+
);
})}
diff --git a/src/components/molecules/user-avatar/user-avatar.tsx b/src/components/molecules/user-avatar/user-avatar.tsx
index d745843..15ec740 100644
--- a/src/components/molecules/user-avatar/user-avatar.tsx
+++ b/src/components/molecules/user-avatar/user-avatar.tsx
@@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
+import { Button } from "@/components/atoms/button";
import { Icon } from "@/components/icon";
import styles from "./user-avatar.module.css";
@@ -98,6 +99,8 @@ export function UserAvatar({
className={styles.image}
src={avatarSource}
alt={alt}
+ loading="eager"
+ fetchPriority="high"
/>
) : (
{fallbackLabel}
@@ -105,7 +108,8 @@ export function UserAvatar({
{editable ? (
-
{renderEditIcon(isUploading)}
-
+
) : null}
);
diff --git a/src/components/organisms/account-brands-section/account-brands-section.tsx b/src/components/organisms/account-brands-section/account-brands-section.tsx
index f1632a6..ae85e69 100644
--- a/src/components/organisms/account-brands-section/account-brands-section.tsx
+++ b/src/components/organisms/account-brands-section/account-brands-section.tsx
@@ -109,7 +109,7 @@ export function AccountBrandsSection({
backgroundImage={{ src: backgroundImage, alt: brand.name }}
title={brand.name}
description={brand.description ?? ""}
- category={brand.categories[0]?.name}
+ category={brand.categories[0] ? (messages.categories[brand.categories[0].key as keyof typeof messages.categories] ?? brand.categories[0].key) : undefined}
badgeText={
brand.rating_count > 0
? `${brand.rating_count} ${t.brandCardReviewsSuffix}`
diff --git a/src/components/organisms/account-services-section/account-services-section.module.css b/src/components/organisms/account-services-section/account-services-section.module.css
new file mode 100644
index 0000000..026da0b
--- /dev/null
+++ b/src/components/organisms/account-services-section/account-services-section.module.css
@@ -0,0 +1,93 @@
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ padding: 1.714rem;
+ border-radius: var(--general-border-radius-2xl);
+ background: var(--app-bg-surface-strong);
+ border: 1px solid var(--app-border-soft);
+ box-shadow: 0 0.5rem 1.5rem var(--app-shadow-card-soft);
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.headerContent {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ min-width: 0;
+}
+
+.title {
+ margin: 0;
+ color: var(--app-text-strong);
+ font-size: var(--font-size-medium);
+ font-weight: 700;
+ letter-spacing: 0;
+}
+
+.description {
+ margin: 0;
+ color: var(--app-text-muted);
+ font-size: var(--font-size-small);
+ line-height: 1.6;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr));
+ gap: 1rem;
+}
+
+.empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ padding: 2.5rem 1.5rem;
+ border-radius: var(--general-border-radius-xl);
+ border: 1px dashed var(--app-border-soft);
+ background: var(--app-bg-surface);
+ text-align: center;
+}
+
+.emptyIcon {
+ color: var(--app-text-subtle);
+ opacity: 0.7;
+}
+
+.emptyTitle {
+ margin: 0;
+ color: var(--app-text-strong);
+ font-size: var(--font-size-base);
+ font-weight: 700;
+}
+
+.emptyDescription {
+ margin: 0;
+ max-width: 28rem;
+ color: var(--app-text-muted);
+ font-size: var(--font-size-small);
+ line-height: 1.6;
+}
+
+@media (max-width: 52rem) {
+ .header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .header > *:last-child {
+ width: 100%;
+ }
+
+ .header > *:last-child button {
+ width: 100%;
+ }
+}
diff --git a/src/components/organisms/account-services-section/account-services-section.tsx b/src/components/organisms/account-services-section/account-services-section.tsx
new file mode 100644
index 0000000..9083fca
--- /dev/null
+++ b/src/components/organisms/account-services-section/account-services-section.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { useMemo } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/atoms/button";
+import { Icon } from "@/components/icon";
+import { ServiceCard } from "@/components/organisms/services-uso-page/services-uso-page";
+import { useLocale } from "@/components/providers/locale-provider";
+import type { AccountUserProfile, AuthenticatedUser } from "@/types/user_types";
+import type { Brand } from "@/types/brand";
+import type { Service } from "@/types/service";
+import styles from "./account-services-section.module.css";
+
+type AccountServicesOwner = Pick<
+ AccountUserProfile,
+ "id" | "first_name" | "last_name" | "email" | "avatar_url" | "type"
+>;
+
+type AccountServicesSectionProps = {
+ services: Service[];
+ owner: AccountServicesOwner;
+ brands?: Brand[];
+ title: string;
+ description?: string;
+ emptyTitle: string;
+ emptyDescription: string;
+ viewMoreHref?: string;
+ maxItems?: number;
+};
+
+function sortVisibleServices(services: Service[]) {
+ return [...services].sort((a, b) => {
+ const ratingDiff = (b.rating ?? 0) - (a.rating ?? 0);
+ if (ratingDiff !== 0) return ratingDiff;
+
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
+ });
+}
+
+function ownerAsAuthenticatedUser(owner: AccountServicesOwner): AuthenticatedUser {
+ return {
+ id: owner.id,
+ email: owner.email,
+ type: owner.type,
+ first_name: owner.first_name,
+ last_name: owner.last_name,
+ email_verified: false,
+ avatar_url: owner.avatar_url,
+ };
+}
+
+export function AccountServicesSection({
+ services,
+ owner,
+ brands = [],
+ title,
+ description,
+ emptyTitle,
+ emptyDescription,
+ viewMoreHref,
+ maxItems,
+}: AccountServicesSectionProps) {
+ const router = useRouter();
+ const { messages } = useLocale();
+ const visibleServices = useMemo(
+ () => sortVisibleServices(services.filter((service) => service.status === "ACTIVE" && service.brand_id === null)),
+ [services],
+ );
+ const displayedServices =
+ typeof maxItems === "number" ? visibleServices.slice(0, maxItems) : visibleServices;
+ const hasMore =
+ typeof maxItems === "number" &&
+ visibleServices.length > maxItems &&
+ Boolean(viewMoreHref);
+ const cardUser = ownerAsAuthenticatedUser(owner);
+
+ return (
+
+
+
+
{title}
+ {description ?
{description}
: null}
+
+ {hasMore && viewMoreHref ? (
+
router.push(viewMoreHref)}>
+ {messages.profile.viewMoreServices}
+
+ ) : null}
+
+
+ {displayedServices.length === 0 ? (
+
+
+
{emptyTitle}
+
{emptyDescription}
+
+ ) : (
+
+ {displayedServices.map((service) => (
+ router.push(`/services?id=${service.id}`)}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/organisms/account-services-section/index.ts b/src/components/organisms/account-services-section/index.ts
new file mode 100644
index 0000000..6ca2577
--- /dev/null
+++ b/src/components/organisms/account-services-section/index.ts
@@ -0,0 +1 @@
+export { AccountServicesSection } from "./account-services-section";
diff --git a/src/components/organisms/admin-moderation-workspace/admin-moderation-workspace.module.css b/src/components/organisms/admin-moderation-workspace/admin-moderation-workspace.module.css
new file mode 100644
index 0000000..f1f9637
--- /dev/null
+++ b/src/components/organisms/admin-moderation-workspace/admin-moderation-workspace.module.css
@@ -0,0 +1,660 @@
+/* ─── Layout ─────────────────────────────────────────────────────────────── */
+
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 1.714rem;
+ position: relative;
+ left: 50%;
+ width: min(calc(100vw - 30rem), 90rem);
+ max-width: calc(100vw - 4rem);
+ transform: translateX(-50%);
+}
+
+.pageHeader {
+ padding-bottom: 1rem;
+ border-bottom: 1px solid var(--app-border-soft);
+}
+
+.pageTitle {
+ margin: 0 0 0.25rem;
+ color: var(--app-text-strong);
+ font-size: var(--font-size-large);
+ font-weight: 700;
+ letter-spacing: -0.025em;
+ line-height: 1.2;
+}
+
+.pageDescription {
+ margin: 0;
+ color: var(--app-text-muted);
+ font-size: var(--font-size-small);
+}
+
+/* ─── Tabs ───────────────────────────────────────────────────────────────── */
+
+.tabs {
+ display: flex;
+ gap: 0.25rem;
+ border-bottom: 1px solid var(--app-border-soft);
+}
+
+.tab {
+ padding: 0.625rem 1.25rem;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--app-text-muted);
+ font-size: var(--font-size-small);
+ font-weight: 500;
+ cursor: pointer;
+ transition: color 140ms ease, border-color 140ms ease;
+ margin-bottom: -1px;
+ white-space: nowrap;
+}
+
+.tab:hover {
+ color: var(--app-text-base);
+}
+
+.tabActive {
+ color: var(--app-primary);
+ border-bottom-color: var(--app-primary);
+ font-weight: 600;
+}
+
+/* ─── Feedback ───────────────────────────────────────────────────────────── */
+
+.feedback {
+ margin-bottom: 0.5rem;
+}
+
+/* ─── Queue Table ────────────────────────────────────────────────────────── */
+
+.tableWrapper {
+ overflow: hidden;
+ border-radius: var(--general-border-radius-card);
+ border: 1px solid var(--app-border-soft);
+ background: var(--app-bg-surface);
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-small);
+ table-layout: fixed;
+}
+
+.tableService .colOwnerWidth {
+ width: 20%;
+}
+
+.tableService .colNameWidth {
+ width: 17%;
+}
+
+.tableService .colCategoryWidth {
+ width: 17%;
+}
+
+.tableService .colAddressWidth {
+ width: 21%;
+}
+
+.tableService .colDateWidth {
+ width: 15%;
+}
+
+.tableService .colActionWidth {
+ width: 9.5rem;
+}
+
+.tableBrand .colOwnerWidth {
+ width: 25%;
+}
+
+.tableBrand .colNameWidth {
+ width: 29%;
+}
+
+.tableBrand .colCategoryWidth {
+ width: 23%;
+}
+
+.tableBrand .colDateWidth {
+ width: 16%;
+}
+
+.tableBrand .colActionWidth {
+ width: 9.5rem;
+}
+
+.table th {
+ padding: 0.75rem 1.1rem;
+ text-align: left;
+ color: var(--app-text-muted);
+ font-weight: 600;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-bottom: 1px solid var(--app-border-soft);
+ white-space: nowrap;
+ background: var(--app-bg-surface-strong);
+}
+
+.table td {
+ padding: 0.6rem 1.1rem;
+ color: var(--app-text-base);
+ border-bottom: 1px solid var(--app-border-soft);
+ vertical-align: middle;
+}
+
+.table tr:last-child td {
+ border-bottom: none;
+}
+
+.table tbody tr:hover {
+ background: var(--app-bg-surface-strong);
+}
+
+.colName {
+ font-weight: 500;
+ color: var(--app-text-strong);
+ overflow-wrap: anywhere;
+}
+
+.ownerCell {
+ width: min(100%, 16rem);
+}
+
+.ownerCell :global(a) {
+ min-height: 3.1rem;
+}
+
+.colDate {
+ color: var(--app-text-muted);
+ white-space: nowrap;
+}
+
+.colMeta {
+ color: var(--app-text-base);
+ overflow-wrap: anywhere;
+}
+
+.colAddress {
+ color: var(--app-text-muted);
+ line-height: 1.4;
+ overflow-wrap: anywhere;
+}
+
+.colAction {
+ text-align: center;
+ white-space: nowrap;
+}
+
+.table th.colAction,
+.table td.colAction {
+ padding-inline: 0.75rem;
+ text-align: center;
+}
+
+.actionCell {
+ display: grid;
+ place-items: center;
+ width: 100%;
+}
+
+@media (max-width: 90rem) {
+ .wrapper {
+ width: 100%;
+ max-width: 100%;
+ left: auto;
+ transform: none;
+ }
+}
+
+@media (max-width: 58rem) {
+ .tableWrapper {
+ overflow-x: auto;
+ }
+
+ .table {
+ min-width: 52rem;
+ }
+}
+
+/* ─── Skeleton ───────────────────────────────────────────────────────────── */
+
+.skeletonRow {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1rem;
+}
+
+.skeletonLine {
+ height: 1rem;
+ background: var(--app-bg-surface-strong);
+ border-radius: 4px;
+ animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+ 0% { opacity: 1; }
+ 50% { opacity: 0.5; }
+ 100% { opacity: 1; }
+}
+
+/* ─── Empty State ────────────────────────────────────────────────────────── */
+
+.empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ padding: 4rem 2rem;
+ text-align: center;
+ border-radius: var(--general-border-radius-card);
+ background: var(--app-bg-surface-strong);
+ border: 1px dashed var(--app-border-soft);
+}
+
+.emptyTitle {
+ margin: 0;
+ color: var(--app-text-strong);
+ font-size: var(--font-size-base);
+ font-weight: 600;
+}
+
+/* ─── Detail / Review Panel ─────────────────────────────────────────────── */
+
+.detailHeader {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.backButton {
+ flex-shrink: 0;
+}
+
+.detailTitle {
+ margin: 0;
+ color: var(--app-text-strong);
+ font-size: var(--font-size-large);
+ font-weight: 700;
+ letter-spacing: -0.025em;
+}
+
+.reviewShell {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+.reviewMain {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.reviewSidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+@media (min-width: 960px) {
+ .reviewShell {
+ flex-direction: row;
+ align-items: flex-start;
+ }
+
+ .reviewMain {
+ flex: 1;
+ }
+
+ .reviewSidebar {
+ width: 21rem;
+ min-width: 19rem;
+ position: sticky;
+ top: 5rem;
+ }
+}
+
+.detailCard {
+ background: var(--app-bg-surface);
+ border: 1px solid var(--app-border-soft);
+ border-radius: var(--general-border-radius-card);
+ padding: 1.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.sidebarTitle {
+ margin: 0;
+ color: var(--app-text-strong);
+ font-size: var(--font-size-base);
+ font-weight: 700;
+}
+
+.detailSection {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.detailLabel {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--app-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0;
+}
+
+.detailValue {
+ font-size: var(--font-size-small);
+ color: var(--app-text-base);
+ margin: 0;
+ line-height: 1.5;
+}
+
+.detailValueStrong {
+ font-weight: 600;
+ color: var(--app-text-strong);
+}
+
+.detailDl {
+ display: grid;
+ grid-template-columns: minmax(6rem, 0.45fr) 1fr;
+ gap: 0.75rem 1rem;
+ margin: 0;
+}
+
+.detailDl dt {
+ color: var(--app-text-muted);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.detailDl dd {
+ margin: 0;
+ color: var(--app-text-base);
+ font-size: var(--font-size-small);
+ line-height: 1.45;
+}
+
+.detailChip {
+ display: inline-flex;
+ padding: 0.15rem 0.5rem;
+ border-radius: var(--general-border-radius-pill);
+ background: var(--app-bg-surface-strong);
+ border: 1px solid var(--app-border-soft);
+ color: var(--app-text-muted);
+ font-size: 0.7rem;
+ font-weight: 600;
+}
+
+.detailOwnerCardWrap {
+ margin-top: 0.25rem;
+ padding-top: 0.875rem;
+ border-top: 1px solid var(--app-border-soft);
+}
+
+/* ─── Gallery ─────────────────────────────────────────────────────────────── */
+
+.gallery {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
+ gap: 0.5rem;
+}
+
+.galleryImg {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ object-fit: cover;
+ border-radius: calc(var(--general-border-radius-card) / 2);
+ border: 1px solid var(--app-border-soft);
+}
+
+.logoImg {
+ width: 6rem;
+ height: 6rem;
+ object-fit: cover;
+ border-radius: calc(var(--general-border-radius-card) / 2);
+ border: 1px solid var(--app-border-soft);
+}
+
+.heroMedia {
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+}
+
+.heroImage {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ object-fit: cover;
+ border: 1px solid var(--app-border-soft);
+ border-radius: var(--general-border-radius-card);
+ background: var(--app-bg-surface-strong);
+}
+
+.thumbnailRow {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr));
+ gap: 0.5rem;
+}
+
+.thumbnailImage {
+ width: 100%;
+ aspect-ratio: 4 / 3;
+ object-fit: cover;
+ border: 1px solid var(--app-border-soft);
+ border-radius: calc(var(--general-border-radius-card) / 2);
+}
+
+.logoHero,
+.heroPlaceholder {
+ display: flex;
+ min-height: 14rem;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--general-border-radius-card);
+ border: 1px solid var(--app-border-soft);
+ background: var(--app-bg-surface-strong);
+}
+
+.heroPlaceholder {
+ color: var(--app-text-muted);
+ font-weight: 700;
+}
+
+.branchList {
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+}
+
+.branchItem {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ padding: 0.875rem 1rem;
+ border: 1px solid var(--app-border-soft);
+ border-radius: var(--general-border-radius-card);
+ background: var(--app-bg-surface);
+}
+
+.branchItem strong {
+ color: var(--app-text-strong);
+}
+
+.branchItem span,
+.branchItem small {
+ color: var(--app-text-muted);
+ line-height: 1.4;
+}
+
+/* ─── Checklist ───────────────────────────────────────────────────────────── */
+
+.checklistItem {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ gap: 0.75rem;
+ padding: 0.625rem 0;
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid var(--app-border-soft);
+ cursor: pointer;
+ text-align: left;
+}
+
+.checklistItem:last-child {
+ border-bottom: none;
+}
+
+.checklistLabel {
+ flex: 1;
+ font-size: var(--font-size-small);
+ color: var(--app-text-base);
+}
+
+.checklistPassed {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--app-success, #16a34a);
+}
+
+.checklistFailed {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--app-error, #dc2626);
+}
+
+/* ─── Action Bar ─────────────────────────────────────────────────────────── */
+
+.actionBar {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+/* ─── Reject Form ─────────────────────────────────────────────────────────── */
+
+.rejectForm {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.rejectTextarea {
+ width: 100%;
+ min-height: 6rem;
+ padding: 0.625rem 0.875rem;
+ border: 1px solid var(--app-border-soft);
+ border-radius: var(--general-border-radius-input, 8px);
+ background: var(--app-bg-surface);
+ color: var(--app-text-base);
+ font-size: var(--font-size-small);
+ font-family: inherit;
+ resize: vertical;
+ transition: border-color 140ms ease;
+ box-sizing: border-box;
+}
+
+.rejectTextarea:focus {
+ outline: none;
+ border-color: var(--app-primary);
+}
+
+.rejectTextarea::placeholder {
+ color: var(--app-text-muted);
+}
+
+.rejectError {
+ font-size: 0.75rem;
+ color: var(--app-error, #dc2626);
+ margin: 0;
+}
+
+.rejectActions {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+/* ─── Pending Badge ───────────────────────────────────────────────────────── */
+
+.pendingBadge {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.2rem 0.6rem;
+ border-radius: 999px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ background: var(--app-warning-faint, #fef9c3);
+ color: var(--app-warning, #a16207);
+ border: 1px solid var(--app-warning-border, #fde047);
+}
+
+/* ─── Confirm Modal Overlay ───────────────────────────────────────────────── */
+
+.overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.modal {
+ background: var(--app-bg-surface);
+ border: 1px solid var(--app-border-soft);
+ border-radius: var(--general-border-radius-card);
+ padding: 1.5rem;
+ max-width: 28rem;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.16);
+}
+
+.modalChecklist {
+ display: flex;
+ flex-direction: column;
+}
+
+.modalTitle {
+ margin: 0;
+ color: var(--app-text-strong);
+ font-size: var(--font-size-base);
+ font-weight: 700;
+}
+
+.modalDescription {
+ margin: 0;
+ color: var(--app-text-muted);
+ font-size: var(--font-size-small);
+ line-height: 1.5;
+}
+
+.modalActions {
+ display: flex;
+ gap: 0.75rem;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+}
diff --git a/src/components/organisms/admin-moderation-workspace/admin-moderation-workspace.tsx b/src/components/organisms/admin-moderation-workspace/admin-moderation-workspace.tsx
new file mode 100644
index 0000000..5b6bc7c
--- /dev/null
+++ b/src/components/organisms/admin-moderation-workspace/admin-moderation-workspace.tsx
@@ -0,0 +1,700 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Button } from "@/components/atoms/button";
+import { OwnerCard } from "@/components/molecules/owner-card";
+import { StatusBanner } from "@/components/molecules/status-banner";
+import { BrandDetail } from "@/components/organisms/brand-detail";
+import { ServiceReadOnlyDetailView } from "@/components/organisms/services-uso-page/services-uso-page";
+import { useLocale } from "@/components/providers/locale-provider";
+import { useAppSelector } from "@/store/hooks";
+import { selectAuthSession } from "@/store/auth";
+import {
+ fetchModerationQueue,
+ fetchBrandForReview,
+ fetchServiceForReview,
+ approveBrand,
+ rejectBrand,
+ approveService,
+ rejectService,
+} from "@/lib/moderation-api";
+import type {
+ QueueItem,
+ ModerationBrandDetail,
+ ModerationServiceDetail,
+ ChecklistItem,
+} from "@/types/moderation";
+import type { Brand } from "@/types/brand";
+import type { Service } from "@/types/service";
+import type { AuthenticatedUser, PublicUserProfile } from "@/types/user_types";
+import styles from "./admin-moderation-workspace.module.css";
+
+type ActiveTab = "brand" | "service";
+type ViewMode = "queue" | "detail";
+type ActionStatus = "idle" | "loading" | "success" | "error";
+
+type DetailState =
+ | { type: "brand"; data: ModerationBrandDetail }
+ | { type: "service"; data: ModerationServiceDetail }
+ | null;
+
+function tabToProgress(tab: ActiveTab): "brands" | "services" {
+ return tab === "service" ? "services" : "brands";
+}
+
+function progressToTab(progress: string | null): ActiveTab {
+ return progress === "services" ? "service" : "brand";
+}
+
+function isValidProgress(progress: string | null): progress is "brands" | "services" {
+ return progress === "brands" || progress === "services";
+}
+
+function moderationHref(tab: ActiveTab, id?: string): string {
+ const params = new URLSearchParams({ progress: tabToProgress(tab) });
+ if (id) params.set("id", id);
+ return `/moderation?${params.toString()}`;
+}
+
+function getInitials(owner: QueueItem["owner"]): string {
+ return `${owner.first_name[0] ?? ""}${owner.last_name[0] ?? ""}`.toUpperCase() || "?";
+}
+
+function getOwnerName(owner: QueueItem["owner"]): string {
+ return `${owner.first_name} ${owner.last_name}`.trim() || owner.email;
+}
+
+function formatDate(iso: string): string {
+ try {
+ return new Date(iso).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+function mapModerationOwnerToProfile(
+ owner: ModerationBrandDetail["owner"] | ModerationServiceDetail["owner"],
+ fallbackDate: string,
+): PublicUserProfile {
+ return {
+ id: owner.id,
+ first_name: owner.first_name,
+ last_name: owner.last_name,
+ email: owner.email,
+ type: owner.type === "ucr" || owner.type === "admin" ? owner.type : "uso",
+ avatar_url: owner.avatar_url ?? null,
+ created_at: owner.created_at ?? fallbackDate,
+ updated_at: owner.created_at ?? fallbackDate,
+ };
+}
+
+function mapModerationOwnerToUser(
+ owner: ModerationServiceDetail["owner"],
+): AuthenticatedUser {
+ return {
+ id: owner.id,
+ first_name: owner.first_name,
+ last_name: owner.last_name,
+ email: owner.email,
+ type: owner.type === "ucr" || owner.type === "admin" ? owner.type : "uso",
+ avatar_url: owner.avatar_url ?? null,
+ email_verified: true,
+ };
+}
+
+function mapModerationBrandToBrand(brand: ModerationBrandDetail): Brand {
+ return {
+ id: brand.id,
+ name: brand.name,
+ description: brand.description ?? undefined,
+ status: brand.status as Brand["status"],
+ owner_id: brand.owner.id,
+ logo_url: brand.logo_url ?? undefined,
+ gallery: (brand.gallery ?? []).map((item, index) => ({
+ id: `${brand.id}-gallery-${index}`,
+ media_id: `${brand.id}-gallery-media-${index}`,
+ url: item.url,
+ order: item.order ?? index,
+ })),
+ branches: (brand.branches ?? []).map((branch) => ({
+ id: branch.id,
+ brand_id: brand.id,
+ name: branch.name,
+ description: branch.description ?? undefined,
+ address1: branch.address1,
+ address2: branch.address2 ?? undefined,
+ phone: branch.phone ?? undefined,
+ email: branch.email ?? undefined,
+ is_24_7: Boolean(branch.is_24_7),
+ opening: branch.opening ?? undefined,
+ closing: branch.closing ?? undefined,
+ breaks: [],
+ cover_url: branch.cover_url ?? undefined,
+ })),
+ categories: brand.categories ?? [],
+ rating: null,
+ rating_count: 0,
+ my_rating: null,
+ created_at: brand.created_at,
+ updated_at: brand.updated_at ?? brand.created_at,
+ };
+}
+
+function mapServiceBrandToBrand(service: ModerationServiceDetail): Brand | null {
+ const brand = service.brand;
+ if (!brand) return null;
+
+ return {
+ id: brand.id,
+ name: brand.name,
+ description: undefined,
+ status: "ACTIVE",
+ owner_id: service.owner.id,
+ logo_url: brand.logo_url ?? undefined,
+ gallery: [],
+ branches: [],
+ categories: [],
+ rating: brand.rating ?? null,
+ rating_count: brand.rating_count ?? 0,
+ my_rating: null,
+ created_at: service.created_at,
+ updated_at: service.updated_at ?? service.created_at,
+ };
+}
+
+function mapModerationServiceToService(service: ModerationServiceDetail): Service {
+ return {
+ id: service.id,
+ title: service.title,
+ description: service.description ?? undefined,
+ owner_id: service.owner.id,
+ brand_id: service.brand?.id ?? null,
+ brand: service.brand
+ ? {
+ id: service.brand.id,
+ name: service.brand.name,
+ owner_id: service.owner.id,
+ logo_url: service.brand.logo_url ?? undefined,
+ rating: service.brand.rating ?? null,
+ rating_count: service.brand.rating_count ?? 0,
+ }
+ : null,
+ service_category_id: service.service_category_id ?? service.service_category?.id ?? null,
+ service_category: service.service_category ?? null,
+ price: service.price ?? null,
+ price_type: service.price_type === "STARTING_FROM" || service.price_type === "FREE" ? service.price_type : "FIXED",
+ duration: service.duration ?? null,
+ address: service.address ?? undefined,
+ status: service.status as Service["status"],
+ images: (service.images ?? []).map((item, index) => ({
+ id: `${service.id}-image-${index}`,
+ media_id: `${service.id}-image-media-${index}`,
+ order: index,
+ url: item.url,
+ })),
+ rating: null,
+ rating_count: 0,
+ my_rating: null,
+ created_at: service.created_at,
+ updated_at: service.updated_at ?? service.created_at,
+ };
+}
+
+export function AdminModerationWorkspace() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { locale, messages } = useLocale();
+ const t = messages.moderation;
+ const { accessToken } = useAppSelector(selectAuthSession);
+ const decisionLabel = locale === "az" ? "Qərar" : "Decision";
+ const closeLabel = locale === "az" ? "Bağla" : "Close";
+ const addressLabel = locale === "az" ? "Ünvan" : "Address";
+ const brandRoleLabel = locale === "az" ? "Brend" : "Brand";
+ const providerRoleLabel = "Provider";
+
+ // Queue state
+ const [activeTab, setActiveTab] = useState
("brand");
+ const [queue, setQueue] = useState([]);
+ const [queueLoading, setQueueLoading] = useState(false);
+ const [queueError, setQueueError] = useState(null);
+
+ // View state
+ const [viewMode, setViewMode] = useState("queue");
+ const [detail, setDetail] = useState(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+
+ // Checklist state for current detail
+ const [checklist, setChecklist] = useState([]);
+
+ // Reject flow
+ const [rejectionReason, setRejectionReason] = useState("");
+ const [rejectionError, setRejectionError] = useState(null);
+
+ // Decision modal
+ const [showDecisionModal, setShowDecisionModal] = useState(false);
+
+ // Action feedback
+ const [actionStatus, setActionStatus] = useState("idle");
+ const [actionFeedback, setActionFeedback] = useState(null);
+ const progressParam = searchParams.get("progress");
+ const detailIdParam = searchParams.get("id");
+
+ const resetDetailState = useCallback(() => {
+ setViewMode("queue");
+ setDetail(null);
+ setRejectionReason("");
+ setRejectionError(null);
+ setShowDecisionModal(false);
+ setActionStatus("idle");
+ setActionFeedback(null);
+ }, []);
+
+ // ── Load queue ──────────────────────────────────────────────────────────
+
+ const loadQueue = useCallback(async () => {
+ if (!accessToken) return;
+ setQueueLoading(true);
+ setQueueError(null);
+ try {
+ const items = await fetchModerationQueue(undefined, accessToken);
+ setQueue(items);
+ } catch {
+ setQueueError(t.queueLoadError);
+ } finally {
+ setQueueLoading(false);
+ }
+ }, [accessToken, t.queueLoadError]);
+
+ useEffect(() => {
+ loadQueue();
+ }, [loadQueue]);
+
+ const filteredQueue = queue.filter((item) => item.type === activeTab);
+
+ // ── Open review detail ───────────────────────────────────────────────────
+
+ const openDetailByType = useCallback(async (type: ActiveTab, id: string) => {
+ if (!accessToken) return;
+ setDetailLoading(true);
+ setViewMode("detail");
+ setDetail(null);
+ setChecklist([]);
+ setRejectionReason("");
+ setRejectionError(null);
+ setShowDecisionModal(false);
+ setActionStatus("idle");
+ setActionFeedback(null);
+
+ try {
+ if (type === "brand") {
+ const brand = await fetchBrandForReview(id, accessToken);
+ setDetail({ type: "brand", data: brand });
+ setChecklist(brand.checklist ?? []);
+ } else {
+ const service = await fetchServiceForReview(id, accessToken);
+ setDetail({ type: "service", data: service });
+ setChecklist(service.checklist ?? []);
+ }
+ } catch {
+ setActionFeedback(t.actionError);
+ setActionStatus("error");
+ } finally {
+ setDetailLoading(false);
+ }
+ }, [accessToken, t.actionError]);
+
+ useEffect(() => {
+ if (!isValidProgress(progressParam)) {
+ router.replace(moderationHref("brand"));
+ return;
+ }
+
+ const nextTab = progressToTab(progressParam);
+ setActiveTab(nextTab);
+
+ if (!detailIdParam) {
+ resetDetailState();
+ return;
+ }
+
+ void openDetailByType(nextTab, detailIdParam);
+ }, [detailIdParam, openDetailByType, progressParam, resetDetailState, router]);
+
+ function handleTabChange(tab: ActiveTab) {
+ setActiveTab(tab);
+ resetDetailState();
+ router.push(moderationHref(tab));
+ }
+
+ function handleReview(item: QueueItem) {
+ const nextTab = item.type === "service" ? "service" : "brand";
+ setActiveTab(nextTab);
+ router.push(moderationHref(nextTab, item.id));
+ }
+
+ // ── Back to queue ────────────────────────────────────────────────────────
+
+ function handleBack() {
+ resetDetailState();
+ router.push(moderationHref(activeTab));
+ }
+
+ // ── Checklist toggle ─────────────────────────────────────────────────────
+
+ function toggleChecklist(key: string) {
+ setChecklist((prev) =>
+ prev.map((item) =>
+ item.key === key ? { ...item, passed: !item.passed } : item,
+ ),
+ );
+ }
+
+ // ── Shared action handler ─────────────────────────────────────────────────
+
+ async function handleAction(action: "approve" | "reject") {
+ if (!accessToken || !detail) return;
+
+ const payload =
+ action === "reject"
+ ? { rejection_reason: rejectionReason, checklist: checklist.length > 0 ? checklist : undefined }
+ : { checklist: checklist.length > 0 ? checklist : undefined };
+
+ setActionStatus("loading");
+ setActionFeedback(null);
+
+ try {
+ if (detail.type === "brand") {
+ if (action === "approve") {
+ await approveBrand(detail.data.id, payload, accessToken);
+ } else {
+ await rejectBrand(
+ detail.data.id,
+ { rejection_reason: rejectionReason, checklist: checklist.length > 0 ? checklist : undefined },
+ accessToken,
+ );
+ }
+ } else {
+ if (action === "approve") {
+ await approveService(detail.data.id, payload, accessToken);
+ } else {
+ await rejectService(
+ detail.data.id,
+ { rejection_reason: rejectionReason, checklist: checklist.length > 0 ? checklist : undefined },
+ accessToken,
+ );
+ }
+ }
+
+ setActionStatus("success");
+ setActionFeedback(t.actionSuccess);
+ // Remove from queue and return
+ setQueue((prev) => prev.filter((item) => item.id !== detail.data.id));
+ setTimeout(() => {
+ handleBack();
+ }, 1200);
+ } catch {
+ setActionStatus("error");
+ setActionFeedback(t.actionError);
+ }
+ }
+
+ function handleDecisionClick() {
+ setShowDecisionModal(true);
+ setRejectionReason("");
+ setRejectionError(null);
+ }
+
+ async function handleApproveConfirm() {
+ setShowDecisionModal(false);
+ await handleAction("approve");
+ }
+
+ async function handleRejectConfirm() {
+ if (!rejectionReason || rejectionReason.trim().length < 10) {
+ setRejectionError(t.rejectReasonRequired);
+ return;
+ }
+ setRejectionError(null);
+ await handleAction("reject");
+ }
+
+ // ── Render helpers ────────────────────────────────────────────────────────
+
+ function getCategoryLabel(item: QueueItem): string {
+ const category =
+ item.type === "service"
+ ? item.service_category
+ : item.categories?.[0] ?? null;
+ if (!category) return "—";
+ return messages.categories[category.key as keyof typeof messages.categories] ?? category.key;
+ }
+
+ function renderOwnerCell(item: QueueItem) {
+ if (item.type === "service" && item.brand) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ // ── Queue View ────────────────────────────────────────────────────────────
+
+ if (viewMode === "queue") {
+ return (
+
+
+
{t.pageTitle}
+
{t.pageDescription}
+
+
+ {actionFeedback && actionStatus === "success" && (
+
+ {actionFeedback}
+
+ )}
+
+
+ handleTabChange("brand")}
+ >
+ {t.tabBrands}
+
+ handleTabChange("service")}
+ >
+ {t.tabServices}
+
+
+
+ {queueLoading ? (
+
+ {[1, 2, 3].map((n) => (
+
+ ))}
+
+ ) : queueError ? (
+
{queueError}
+ ) : filteredQueue.length === 0 ? (
+
+ ) : (
+
+
+
+
+
+
+ {activeTab === "service" && }
+
+
+
+
+
+ {t.colOwner}
+ {t.colName}
+ {t.categoryLabel}
+ {activeTab === "service" && {addressLabel} }
+ {t.colSubmitted}
+ {t.colAction}
+
+
+
+ {filteredQueue.map((item) => (
+
+ {renderOwnerCell(item)}
+ {item.title}
+ {getCategoryLabel(item)}
+ {activeTab === "service" && (
+ {item.address || "—"}
+ )}
+ {formatDate(item.created_at)}
+
+
+ handleReview(item)}
+ >
+ {t.reviewButton}
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ );
+ }
+
+ // ── Detail / Review View ──────────────────────────────────────────────────
+
+ const entityTitle =
+ detail?.type === "brand"
+ ? detail.data.name
+ : detail?.type === "service"
+ ? detail.data.title
+ : "…";
+
+ const decisionAction = (
+
+ {decisionLabel}
+
+ );
+
+ return (
+
+ {showDecisionModal && (
+
+
+
{decisionLabel}
+
{t.approveConfirmDescription}
+
+ {checklist.length > 0 && (
+
+
{t.checklistTitle}
+ {checklist.map((item) => (
+
toggleChecklist(item.key)}
+ >
+ {item.label}
+
+ {item.passed ? t.checklistPassed : t.checklistFailed}
+
+
+ ))}
+
+ )}
+
+
+ {t.rejectReasonLabel}
+
+
+
+ setShowDecisionModal(false)} disabled={actionStatus === "loading"}>
+ {closeLabel}
+
+
+ {t.rejectButton}
+
+
+ {t.approveButton}
+
+
+
+
+ )}
+
+ {/* Detail header */}
+
+
+ {t.backToQueue}
+
+
{detailLoading ? t.loadingDetail : entityTitle}
+ {t.statusPending}
+
+
+ {actionFeedback && (
+
+
+ {actionFeedback}
+
+
+ )}
+
+ {detailLoading ? (
+
+ {[1, 2, 3, 4].map((n) => (
+
+ ))}
+
+ ) : detail ? (
+ detail.type === "brand" ? (
+
+ ) : (
+
+ )
+ ) : null}
+
+ );
+}
diff --git a/src/components/organisms/admin-moderation-workspace/index.ts b/src/components/organisms/admin-moderation-workspace/index.ts
new file mode 100644
index 0000000..b3da7b3
--- /dev/null
+++ b/src/components/organisms/admin-moderation-workspace/index.ts
@@ -0,0 +1 @@
+export { AdminModerationWorkspace } from "./admin-moderation-workspace";
diff --git a/src/components/organisms/app-sidebar/app-sidebar.module.css b/src/components/organisms/app-sidebar/app-sidebar.module.css
index 2d07879..a91409c 100644
--- a/src/components/organisms/app-sidebar/app-sidebar.module.css
+++ b/src/components/organisms/app-sidebar/app-sidebar.module.css
@@ -1,5 +1,6 @@
+/* ── Sidebar shell ── */
.sidebar {
- width: 20rem;
+ width: 18rem;
flex-shrink: 0;
display: flex;
flex-direction: column;
@@ -8,22 +9,15 @@
top: 0;
border-right: 1px solid var(--app-border-soft);
background: var(--app-bg-sidebar);
- box-shadow: 0.429rem 0 1.286rem var(--app-shadow-card-soft);
transition: width 240ms cubic-bezier(0.4, 0, 0.2, 1);
+ overflow: hidden;
}
.sidebarCollapsed {
- width: 4.5rem;
-}
-
-@media (max-width: 767px) {
- .sidebar {
- width: 100% !important;
- transition: none;
- }
+ width: 4.25rem;
}
-/* ── Brand row — same height as header ── */
+/* ── Brand row ── */
.brand {
height: 3.143rem;
flex-shrink: 0;
@@ -48,9 +42,10 @@
}
.brandLink:hover {
- background: var(--app-bg-primary-soft);
+ background: var(--app-bg-surface-strong);
}
+
.brandIcon {
display: inline-flex;
align-items: center;
@@ -83,7 +78,7 @@
}
.brandSub {
- color: var(--app-primary-deep);
+ color: var(--app-text-subtle);
font-size: var(--font-size-extra-small);
font-weight: 600;
line-height: 1.2;
@@ -92,7 +87,7 @@
text-overflow: ellipsis;
}
-/* ── Collapsed: hide text, center icon ── */
+/* ── Collapsed: hide text ── */
.sidebarCollapsed .brandText {
opacity: 0;
pointer-events: none;
@@ -115,17 +110,21 @@
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
- padding: 0.571rem 0.714rem;
+ padding: 0.75rem 0.714rem;
}
/* ── Section ── */
+.section {
+ margin-bottom: 0.5rem;
+}
+
.sectionLabel {
- margin: 0 0 0.286rem;
- padding: 0 0.429rem;
+ margin: 0 0 0.375rem;
+ padding: 0 0.571rem;
color: var(--app-text-subtle);
- font-size: var(--font-size-extra-small);
+ font-size: 0.65rem;
font-weight: 700;
- letter-spacing: 0.04em;
+ letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
@@ -145,65 +144,146 @@
.nav {
display: flex;
flex-direction: column;
- gap: 0.143rem;
+ gap: 0.125rem;
}
.sidebarCollapsed .nav {
align-items: center;
}
+.navGroup {
+ display: flex;
+ flex-direction: column;
+}
+
.navItem {
position: relative;
display: flex;
align-items: center;
- gap: 0.571rem;
- padding: 0.5rem 0.429rem;
+ gap: 0.625rem;
+ width: 100%;
+ padding: 0.571rem 0.714rem;
+ border: none;
border-radius: var(--general-border-radius-xl);
+ background: transparent;
color: var(--app-text-muted);
font-size: var(--font-size-small);
font-weight: 500;
line-height: 1;
- text-decoration: none;
+ text-align: left;
white-space: nowrap;
overflow: hidden;
+ cursor: pointer;
transition:
- background-color 120ms ease,
- color 120ms ease;
+ background-color 140ms ease,
+ color 140ms ease;
}
.navItem:hover {
- color: var(--app-text-strong);
+ background: var(--app-bg-surface-strong);
+ color: var(--app-text-base);
}
+/* ── Bold filled active state ── */
.navItemActive {
- background: var(--app-bg-primary-soft);
+ background: var(--app-primary);
+ color: var(--app-text-inverse);
font-weight: 600;
+ box-shadow:
+ 0 1px 3px rgb(0 0 0 / 0.18),
+ 0 0 0 1px rgb(0 0 0 / 0.06);
+}
+
+.navItemActive:hover {
+ background: var(--app-primary-deep);
+ color: var(--app-text-inverse);
+}
+
+/* ── Icon wrapper (for badge positioning) ── */
+.navIconWrap {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
}
.navIcon {
width: 1rem;
height: 1rem;
- flex-shrink: 0;
- color: var(--app-text-strong);
- transition:
- color 120ms ease,
- transform 120ms ease;
+ color: inherit;
}
-.navItem:hover .navIcon {
- color: var(--app-primary);
- transform: translateY(-1px);
+.navItemActive .navIcon {
+ color: var(--app-text-inverse);
}
-.navItemActive .navIcon {
+/* ── Badge (number) on icon top-right ── */
+.badge {
+ position: absolute;
+ top: -0.45rem;
+ right: -0.55rem;
+ min-width: 1rem;
+ height: 1rem;
+ padding: 0 0.25rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ background: var(--app-primary);
+ color: var(--app-text-inverse);
+ font-size: 0.6rem;
+ font-weight: 800;
+ line-height: 1;
+ box-shadow: 0 0 0 2px var(--app-bg-sidebar);
+ pointer-events: none;
+}
+
+.badgeActive {
+ background: var(--app-text-inverse);
color: var(--app-primary);
}
+/* ── Badge dot (collapsed mode) ── */
+.badgeDot {
+ position: absolute;
+ top: -0.2rem;
+ right: -0.2rem;
+ width: 0.45rem;
+ height: 0.45rem;
+ border-radius: 999px;
+ background: var(--app-primary);
+ box-shadow: 0 0 0 1.5px var(--app-bg-sidebar);
+ pointer-events: none;
+}
+
+.badgeDotActive {
+ background: var(--app-text-inverse);
+}
+
+/* ── Chevron for expandable items ── */
+.chevronIcon {
+ width: 0.875rem;
+ height: 0.875rem;
+ flex-shrink: 0;
+ color: inherit;
+ opacity: 0.6;
+ transition: transform 200ms ease, opacity 140ms ease;
+ margin-left: auto;
+}
+
+.chevronExpanded {
+ transform: rotate(90deg);
+ opacity: 0.9;
+}
+
.navLabel {
opacity: 1;
transition: opacity 160ms ease;
overflow: hidden;
text-overflow: ellipsis;
+ flex: 1;
+ min-width: 0;
}
.sidebarCollapsed .navLabel {
@@ -218,67 +298,136 @@
.sidebarCollapsed .navItem {
width: auto;
justify-content: center;
- padding: 0.6rem;
- gap: 0;
-}
-
-/* ── Footer ── */
-.footer {
- flex-shrink: 0;
- padding: 0 0.714rem 1.25rem;
- display: flex;
- flex-direction: column;
+ padding: 0.625rem;
gap: 0;
}
-.footerNav {
+/* ── Sub-items ── */
+.subNav {
display: flex;
flex-direction: column;
- gap: 0.143rem;
+ gap: 0.125rem;
+ margin: 0.25rem 0 0.25rem 0.875rem;
+ padding-left: 0.875rem;
+ border-left: 1.5px solid var(--app-border-soft);
}
-.footerItem {
+.subItem {
display: flex;
align-items: center;
- gap: 0.571rem;
- padding: 0.5rem 0.429rem;
- border-radius: var(--general-border-radius-xl);
+ gap: 0.5rem;
+ padding: 0.429rem 0.571rem;
+ border-radius: var(--general-border-radius-lg);
color: var(--app-text-muted);
- font-size: var(--font-size-small);
+ font-size: var(--font-size-extra-small);
font-weight: 500;
- line-height: 1;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
+ text-overflow: ellipsis;
transition:
background-color 120ms ease,
color 120ms ease;
}
-.footerItem:hover {
- background: var(--app-bg-primary-soft);
+.subItem:hover {
+ background: var(--app-bg-surface-strong);
+ color: var(--app-text-base);
+}
+
+.subItemActive {
color: var(--app-text-strong);
+ font-weight: 600;
+ background: var(--app-bg-surface-strong);
}
-.sidebarCollapsed .footerNav {
- align-items: center;
+.subDot {
+ width: 0.375rem;
+ height: 0.375rem;
+ border-radius: 999px;
+ flex-shrink: 0;
+ flex: none;
}
-.sidebarCollapsed .footerItem {
- justify-content: center;
- padding: 0.6rem;
- gap: 0;
- width: auto;
+.subDotActive {
+ background: var(--app-success);
+ opacity: 0.85;
+}
+
+.subDotPending {
+ background: var(--app-warning);
+ opacity: 0.85;
+}
+
+.subDotRejected {
+ background: var(--brand-error);
+ opacity: 0.9;
+}
+
+.subDotNeutral {
+ background: currentColor;
+ opacity: 0.35;
+}
+
+.subIcon {
+ width: 0.8125rem;
+ height: 0.8125rem;
+ flex-shrink: 0;
+ opacity: 0.72;
+}
+
+.subItemActive .subIcon,
+.subItem:hover .subIcon {
+ opacity: 1;
+}
+
+/* ── Branch sub-sub-items ── */
+.subGroup {
+ display: flex;
+ flex-direction: column;
}
-.sidebarCollapsed .userRow {
+.branchNav {
display: flex;
+ flex-direction: column;
+ gap: 0.0625rem;
+ margin: 0.125rem 0 0.25rem 0.75rem;
+ padding-left: 0.75rem;
+ border-left: 1px solid var(--app-border-soft);
+}
+
+.branchItem {
+ display: flex;
+ align-items: center;
+ padding: 0.286rem 0.5rem;
+ border-radius: var(--general-border-radius-lg);
+ text-decoration: none;
+ transition: background-color 120ms ease, color 120ms ease;
+}
+
+.branchItem:hover {
+ background: var(--app-bg-surface-strong);
+}
+
+.branchLabel {
+ color: var(--app-text-subtle);
+ font-size: 0.675rem;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ transition: color 120ms ease;
}
-.divider {
- height: 1px;
- background: var(--app-border-soft);
- margin: 0.429rem 0;
+.branchItem:hover .branchLabel {
+ color: var(--app-text-muted);
+}
+
+/* ── Footer ── */
+.footer {
+ flex-shrink: 0;
+ padding: 0.5rem 0.714rem 1rem;
+ border-top: 1px solid var(--app-border-soft);
}
/* ── User button ── */
@@ -287,29 +436,30 @@
align-items: center;
gap: 0.571rem;
width: 100%;
- padding: 0.429rem;
+ padding: 0.5rem 0.571rem;
border: none;
border-radius: var(--general-border-radius-xl);
background: transparent;
cursor: pointer;
white-space: nowrap;
- transition: background-color 120ms ease;
+ transition: background-color 140ms ease;
text-align: left;
}
.userBtn:hover {
- background: var(--app-bg-primary-soft);
+ background: var(--app-bg-surface-strong);
}
.sidebarCollapsed .userBtn {
width: auto;
justify-content: center;
- padding: 0.429rem;
+ padding: 0.5rem;
gap: 0;
}
.userAvatar {
min-width: 2rem;
+ flex-shrink: 0;
}
.userInfo {
@@ -332,7 +482,7 @@
}
.userName {
- color: var(--app-primary);
+ color: var(--app-text-strong);
font-size: var(--font-size-small);
font-weight: 600;
line-height: 1.2;
@@ -342,7 +492,7 @@
}
.userEmail {
- color: var(--app-text-strong);
+ color: var(--app-text-muted);
font-size: var(--font-size-extra-small);
line-height: 1.2;
white-space: nowrap;
@@ -361,4 +511,29 @@
.sidebarCollapsed .chevron {
opacity: 0;
+ width: 0;
+ overflow: hidden;
+}
+
+/* ── Mobile overlay ── */
+@media (max-width: 64rem) {
+ .sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ z-index: 50;
+ width: min(18rem, 85vw) !important;
+ height: 100dvh;
+ transform: translateX(-100%);
+ transition:
+ transform 260ms cubic-bezier(0.4, 0, 0.2, 1),
+ box-shadow 260ms ease;
+ box-shadow: none;
+ }
+
+ .sidebarMobileOpen {
+ transform: translateX(0);
+ box-shadow: 4px 0 32px rgb(0 0 0 / 0.22);
+ }
}
diff --git a/src/components/organisms/app-sidebar/app-sidebar.tsx b/src/components/organisms/app-sidebar/app-sidebar.tsx
index 3f47338..33111e1 100644
--- a/src/components/organisms/app-sidebar/app-sidebar.tsx
+++ b/src/components/organisms/app-sidebar/app-sidebar.tsx
@@ -1,10 +1,12 @@
"use client";
+import { useEffect, useState } from "react";
import Link from "next/link";
-import { usePathname, useRouter } from "next/navigation";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useAppSelector } from "@/store/hooks";
import { selectAuthSession } from "@/store/auth";
import { useLocale } from "@/components/providers/locale-provider";
+import { Button } from "@/components/atoms/button";
import { Logo } from "@/components/logo";
import { Icon } from "@/components/icon";
import { UserAvatar } from "@/components/molecules/user-avatar/user-avatar";
@@ -12,25 +14,39 @@ import {
getDefaultAppRouteForUserType,
getSidebarRoutesForUserType,
} from "@/lib/app-routes";
+import { fetchMyBrands } from "@/lib/brands-api";
+import { fetchMyAssignedServices, fetchMyServices } from "@/lib/services-api";
+import type { Brand } from "@/types/brand";
+import type { AssignedService, Service } from "@/types/service";
import styles from "./app-sidebar.module.css";
type AppSidebarProps = {
collapsed: boolean;
+ mobileOpen?: boolean;
onClose?: () => void;
};
-export function AppSidebar({ collapsed, onClose }: AppSidebarProps) {
+type SidebarSubItem = {
+ id: string;
+ label: string;
+ href: string;
+ status?: string;
+ icon?: string;
+ branches: { id: string; label: string; href: string }[];
+};
+
+export function AppSidebar({ collapsed, mobileOpen, onClose }: AppSidebarProps) {
const pathname = usePathname();
+ const searchParams = useSearchParams();
const router = useRouter();
const { messages } = useLocale();
const session = useAppSelector(selectAuthSession);
const db = messages.dashboard;
const user = session.user;
+ const accessToken = session.accessToken;
const defaultHref = getDefaultAppRouteForUserType(user?.type);
- const platformNav = user
- ? getSidebarRoutesForUserType(messages, user.type)
- : [];
+ const platformNav = user ? getSidebarRoutesForUserType(messages, user.type) : [];
const initials = user
? `${user.first_name[0] ?? ""}${user.last_name[0] ?? ""}`.toUpperCase()
: "?";
@@ -41,13 +57,144 @@ export function AppSidebar({ collapsed, onClose }: AppSidebarProps) {
admin: db.typeAdmin,
};
+ // Sub-data
+ const [brands, setBrands] = useState([]);
+ const [services, setServices] = useState([]);
+ const [assignedServices, setAssignedServices] = useState([]);
+
+ function loadSidebarData() {
+ if (!user || !accessToken || user.type !== "uso") return;
+ fetchMyBrands(accessToken).then(setBrands).catch(() => setBrands([]));
+ fetchMyServices(accessToken).then(setServices).catch(() => setServices([]));
+ fetchMyAssignedServices(accessToken)
+ .then(setAssignedServices)
+ .catch(() => setAssignedServices([]));
+ }
+
+ useEffect(() => {
+ loadSidebarData();
+ }, [user, accessToken]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ const handler = () => loadSidebarData();
+ window.addEventListener("reziphay:services-changed", handler);
+ window.addEventListener("reziphay:brands-changed", handler);
+ return () => {
+ window.removeEventListener("reziphay:services-changed", handler);
+ window.removeEventListener("reziphay:brands-changed", handler);
+ };
+ }, [user, accessToken]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Expanded sub-menu state — auto-follows route
+ const [expandedKey, setExpandedKey] = useState(() => {
+ if (user?.type === "ucr" && (pathname === "/home" || pathname.startsWith("/services") || pathname.startsWith("/brands"))) return "home";
+ if (pathname.startsWith("/brands")) return "brands";
+ if (pathname.startsWith("/services")) return "services";
+ return null;
+ });
+
+ useEffect(() => {
+ queueMicrotask(() => {
+ if (user?.type === "ucr" && (pathname === "/home" || pathname.startsWith("/services") || pathname.startsWith("/brands"))) setExpandedKey("home");
+ else if (pathname.startsWith("/brands")) setExpandedKey("brands");
+ else if (pathname.startsWith("/services")) setExpandedKey("services");
+ });
+ }, [pathname, user?.type]);
+
function isActive(href: string) {
return href === "/home" ? pathname === href : pathname.startsWith(href);
}
+ function getSubItems(href: string): SidebarSubItem[] {
+ if (user?.type === "ucr" && href === "/home") {
+ return [
+ {
+ id: "marketplace-services",
+ label: messages.dashboard.services,
+ href: "/services",
+ icon: "room_service",
+ branches: [],
+ },
+ {
+ id: "marketplace-brands",
+ label: messages.dashboard.brands,
+ href: "/brands",
+ icon: "sell",
+ branches: [],
+ },
+ ];
+ }
+ if (href === "/brands") return brands.map((b) => ({
+ id: b.id,
+ label: b.name,
+ href: `/brands?id=${b.id}`,
+ status: b.status,
+ branches: (b.branches ?? []).map((br) => ({ id: br.id, label: br.name, href: `/brands?id=${b.id}` })),
+ }));
+ if (href === "/services") {
+ const ownedItems = services.map((s) => ({
+ id: s.id,
+ label: s.title,
+ href: `/services?id=${s.id}`,
+ status: s.status,
+ branches: [] as { id: string; label: string; href: string }[],
+ }));
+ const assignedItems = assignedServices.map((assignment) => ({
+ id: `assigned-${assignment.id}`,
+ label: assignment.service.title,
+ href: `/services?id=${assignment.service.id}`,
+ status: assignment.service.brand?.name,
+ icon: "store",
+ branches: [] as { id: string; label: string; href: string }[],
+ }));
+
+ return [...ownedItems, ...assignedItems];
+ }
+ return [];
+ }
+
+ function getBadgeCount(href: string): number | null {
+ if (href === "/brands" && brands.length > 0) return brands.length;
+ if (href === "/services" && services.length + assignedServices.length > 0) {
+ return services.length + assignedServices.length;
+ }
+ return null;
+ }
+
+ function isSubActive(subHref: string): boolean {
+ if (!subHref.includes("?")) return pathname === subHref;
+ const subId = new URLSearchParams(subHref.split("?")[1] ?? "").get("id");
+ return searchParams.get("id") === subId;
+ }
+
+ function handleNavClick(href: string) {
+ const subKey = user?.type === "ucr" && href === "/home"
+ ? "home"
+ : href === "/brands"
+ ? "brands"
+ : href === "/services"
+ ? "services"
+ : null;
+ if (subKey) {
+ if (expandedKey === subKey && isActive(href)) {
+ setExpandedKey(null);
+ } else {
+ setExpandedKey(subKey);
+ router.push(href);
+ }
+ } else {
+ onClose?.();
+ router.push(href);
+ }
+ }
+
return (
{/* Brand */}
@@ -69,19 +216,98 @@ export function AppSidebar({ collapsed, onClose }: AppSidebarProps) {
{db.platform}
- {platformNav.map((item) => (
-
-
- {item.label}
-
- ))}
+ {platformNav.map((item) => {
+ const active = isActive(item.href);
+ const subItems = getSubItems(item.href);
+ const hasSubItems = subItems.length > 0;
+ const groupKey = user?.type === "ucr" && item.href === "/home"
+ ? "home"
+ : item.href === "/brands"
+ ? "brands"
+ : "services";
+ const isExpanded = expandedKey === groupKey;
+ const badge = getBadgeCount(item.href);
+
+ return (
+
+
handleNavClick(item.href)}
+ title={collapsed ? item.label : undefined}
+ >
+
+
+ {badge !== null && !collapsed && (
+
+ {badge > 99 ? "99+" : badge}
+
+ )}
+ {badge !== null && collapsed && (
+
+ )}
+
+ {item.label}
+ {hasSubItems && !collapsed && (
+
+ )}
+
+
+ {/* Sub-items */}
+ {hasSubItems && isExpanded && !collapsed && (
+
+ {subItems.map((sub) => (
+
+
+ {sub.icon ? (
+
+ ) : (
+
+ )}
+
{sub.label}
+
+ {sub.branches && sub.branches.length > 0 && (
+
+ {sub.branches.map((br) => (
+
+ {br.label}
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+ })}
@@ -89,7 +315,8 @@ export function AppSidebar({ collapsed, onClose }: AppSidebarProps) {
{/* Footer */}
- {user?.email ?? ""}
-
+
diff --git a/src/components/organisms/auth-header/auth-header.tsx b/src/components/organisms/auth-header/auth-header.tsx
index cfb0b98..1a1e051 100644
--- a/src/components/organisms/auth-header/auth-header.tsx
+++ b/src/components/organisms/auth-header/auth-header.tsx
@@ -77,7 +77,7 @@ export function AuthHeader() {
return (
-
+
Reziphay
@@ -170,7 +170,7 @@ export function AuthHeader() {
href="/"
onClick={() => setIsMenuOpen(false)}
>
-
+
Reziphay
diff --git a/src/components/organisms/auth-panel.module.css b/src/components/organisms/auth-panel.module.css
index b34d22f..d533d3b 100644
--- a/src/components/organisms/auth-panel.module.css
+++ b/src/components/organisms/auth-panel.module.css
@@ -60,6 +60,31 @@
margin-top: 0.143rem;
}
+.datePickerControl {
+ position: relative;
+ width: 100%;
+}
+
+.datePickerInput {
+ padding-right: calc(var(--general-control-height) + 0.35rem);
+}
+
+.datePickerIcon {
+ position: absolute;
+ top: 50%;
+ right: 0.857rem;
+ width: 1.143rem;
+ height: 1.143rem;
+ color: var(--app-text-subtle);
+ pointer-events: none;
+ transform: translateY(-50%);
+}
+
+.datePickerPopover {
+ width: auto;
+ padding: 0;
+}
+
.footer {
text-align: center;
}
diff --git a/src/components/organisms/auth-register-panel/auth-register-panel.tsx b/src/components/organisms/auth-register-panel/auth-register-panel.tsx
index 578ca42..d1b297c 100644
--- a/src/components/organisms/auth-register-panel/auth-register-panel.tsx
+++ b/src/components/organisms/auth-register-panel/auth-register-panel.tsx
@@ -1,7 +1,8 @@
"use client";
-import { useEffect, useMemo, type FormEvent } from "react";
+import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
+import { CalendarIcon } from "lucide-react";
import {
Alert,
AlertDescription,
@@ -30,10 +31,63 @@ import {
setRegisterField,
submitRegister,
} from "@/store/auth";
+import { Calendar } from "@/components/ui/calendar";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
import sharedStyles from "../auth-panel.module.css";
-function getTodayDateValue() {
- return new Date().toISOString().slice(0, 10);
+const minimumRegisterAge = 18;
+const earliestBirthday = new Date(1900, 0, 1);
+
+function formatBirthdayInput(value: string) {
+ const digits = value.replace(/\D/g, "").slice(0, 8);
+ const parts = [digits.slice(0, 2), digits.slice(2, 4), digits.slice(4, 8)]
+ .filter(Boolean);
+
+ return parts.join("-");
+}
+
+function getMaximumBirthday() {
+ const date = new Date();
+ date.setFullYear(date.getFullYear() - minimumRegisterAge);
+ date.setHours(0, 0, 0, 0);
+
+ return date;
+}
+
+function formatBirthdayDate(date: Date) {
+ const day = String(date.getDate()).padStart(2, "0");
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const year = String(date.getFullYear());
+
+ return `${day}-${month}-${year}`;
+}
+
+function parseBirthdayDate(value: string) {
+ const match = /^(\d{2})-(\d{2})-(\d{4})$/.exec(value);
+
+ if (!match) {
+ return undefined;
+ }
+
+ const [, day, month, year] = match;
+ const dayNumber = Number(day);
+ const monthNumber = Number(month);
+ const yearNumber = Number(year);
+ const date = new Date(yearNumber, monthNumber - 1, dayNumber);
+
+ if (
+ date.getFullYear() !== yearNumber ||
+ date.getMonth() !== monthNumber - 1 ||
+ date.getDate() !== dayNumber
+ ) {
+ return undefined;
+ }
+
+ return date;
}
export function AuthRegisterPanel() {
@@ -46,6 +100,13 @@ export function AuthRegisterPanel() {
const { errors, feedback, status, values } = useAppSelector(selectRegisterState);
const register = messages.auth.register;
const isSubmitting = status === "loading";
+ const [isBirthdayPickerOpen, setBirthdayPickerOpen] = useState(false);
+ const birthdayPickerControlRef = useRef(null);
+ const maximumBirthday = useMemo(() => getMaximumBirthday(), []);
+ const selectedBirthday = useMemo(
+ () => parseBirthdayDate(values.birthday),
+ [values.birthday],
+ );
const userTypeOptions: readonly ComboboxOption[] = useMemo(
() => [
@@ -99,6 +160,43 @@ export function AuthRegisterPanel() {
session.refreshToken,
]);
+ useEffect(() => {
+ if (!isBirthdayPickerOpen) {
+ return;
+ }
+
+ function closeOnOutsidePointerDown(event: PointerEvent) {
+ const target = event.target;
+
+ if (!(target instanceof Node)) {
+ return;
+ }
+
+ if (birthdayPickerControlRef.current?.contains(target)) {
+ return;
+ }
+
+ if (
+ target instanceof Element &&
+ target.closest('[data-birthday-picker-content="true"]')
+ ) {
+ return;
+ }
+
+ setBirthdayPickerOpen(false);
+ }
+
+ document.addEventListener("pointerdown", closeOnOutsidePointerDown, true);
+
+ return () => {
+ document.removeEventListener(
+ "pointerdown",
+ closeOnOutsidePointerDown,
+ true,
+ );
+ };
+ }, [isBirthdayPickerOpen]);
+
async function handleSubmit(event: FormEvent) {
event.preventDefault();
@@ -196,21 +294,78 @@ export function AuthRegisterPanel() {
{register.birthdayLabel}
- {
- dispatch(
- setRegisterField({
- field: "birthday",
- value: event.target.value,
- }),
- );
- }}
- />
+
+
+ {
+ setBirthdayPickerOpen(true);
+ }}
+ onChange={(event) => {
+ dispatch(
+ setRegisterField({
+ field: "birthday",
+ value: formatBirthdayInput(event.target.value),
+ }),
+ );
+ }}
+ />
+ }
+ />
+
+
+
+ date > maximumBirthday || date < earliestBirthday
+ }
+ onSelect={(date) => {
+ if (!date) {
+ return;
+ }
+
+ dispatch(
+ setRegisterField({
+ field: "birthday",
+ value: formatBirthdayDate(date),
+ }),
+ );
+ setBirthdayPickerOpen(false);
+ }}
+ />
+
+
+
{errors.birthday ? (
{errors.birthday}
) : null}
diff --git a/src/components/organisms/auth-showcase-panel/auth-showcase-panel.module.css b/src/components/organisms/auth-showcase-panel/auth-showcase-panel.module.css
index c432c96..1bcd55e 100644
--- a/src/components/organisms/auth-showcase-panel/auth-showcase-panel.module.css
+++ b/src/components/organisms/auth-showcase-panel/auth-showcase-panel.module.css
@@ -5,6 +5,7 @@
padding: clamp(1.714rem, 2.4vw, 2.857rem);
border-radius: var(--general-border-radius-5xl);
overflow: hidden;
+ background: var(--app-bg-surface-strong);
}
.logoWrap {
diff --git a/src/components/organisms/brand-detail/brand-detail.module.css b/src/components/organisms/brand-detail/brand-detail.module.css
index ab69d28..c3c7c36 100644
--- a/src/components/organisms/brand-detail/brand-detail.module.css
+++ b/src/components/organisms/brand-detail/brand-detail.module.css
@@ -4,11 +4,6 @@
gap: 1.5rem;
}
-.pageAlert {
- width: 100%;
- max-width: none;
-}
-
.hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(18rem, 0.7fr);
@@ -97,6 +92,31 @@
line-height: 1.55;
}
+.socialLinks {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.socialLink {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ border-radius: 50%;
+ background: var(--app-bg-surface-strong);
+ transition: background 140ms ease, transform 120ms ease, opacity 140ms ease;
+ opacity: 0.85;
+}
+
+.socialLink:hover {
+ background: var(--app-bg-surface);
+ opacity: 1;
+ transform: translateY(-2px);
+}
+
.ratingSummary {
display: flex;
align-items: center;
@@ -254,6 +274,16 @@
min-width: 13rem;
}
+.moderationActionCard {
+ display: flex;
+ justify-content: flex-end;
+ padding: 1rem;
+ border-radius: var(--general-border-radius);
+ border: 1px solid color-mix(in srgb, var(--brand-border) 78%, transparent);
+ background: color-mix(in srgb, var(--brand-surface-base) 96%, transparent);
+ box-shadow: 0 10px 24px color-mix(in srgb, var(--brand-shadow-base) 12%, transparent);
+}
+
.logoCard {
display: flex;
flex-direction: column;
@@ -332,6 +362,39 @@
text-wrap: balance;
}
+.branchesSectionHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding-bottom: 0;
+}
+
+.branchesSectionTitleGroup {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+}
+
+.branchesSectionTitle {
+ margin: 0;
+ color: var(--app-text-strong);
+ font-size: 1.15rem;
+ font-weight: 800;
+}
+
+.branchesCount {
+ display: inline-flex;
+ align-items: center;
+ min-height: 1.6rem;
+ padding: 0.2rem 0.6rem;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--app-primary-faint) 78%, var(--brand-surface-base) 22%);
+ color: var(--app-primary);
+ font-size: 0.78rem;
+ font-weight: 800;
+}
+
.controlPanel {
display: flex;
align-items: center;
@@ -441,7 +504,7 @@
display: grid;
grid-template-columns:
minmax(0, 1.4fr) minmax(0, 1.25fr) minmax(9rem, 0.9fr)
- minmax(10rem, 1fr);
+ minmax(10rem, 1fr) auto;
gap: 1rem;
align-items: center;
width: 100%;
@@ -1310,6 +1373,65 @@
grid-template-columns: 1fr;
}
+ .servicesTableHead {
+ display: none;
+ }
+
+ .serviceRow {
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-areas:
+ "identity price"
+ "branch duration";
+ gap: 0.75rem 0.9rem;
+ align-items: start;
+ }
+
+ .serviceIdentity {
+ grid-area: identity;
+ }
+
+ .serviceBranchCell {
+ grid-area: branch;
+ }
+
+ .servicePriceCell {
+ grid-area: price;
+ justify-self: end;
+ justify-content: flex-end;
+ }
+
+ .serviceDurationCell {
+ grid-area: duration;
+ justify-content: flex-start;
+ }
+
+ .assignmentRow {
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-areas:
+ "identity action"
+ "stats stats"
+ "meta meta";
+ align-items: start;
+ }
+
+ .assignmentRow .serviceIdentity {
+ grid-area: identity;
+ }
+
+ .assignmentMeta {
+ grid-area: meta;
+ justify-self: start;
+ }
+
+ .assignmentStats {
+ grid-area: stats;
+ justify-self: start;
+ }
+
+ .assignmentActions {
+ grid-area: action;
+ }
+
.galleryStage {
min-height: 18rem;
aspect-ratio: 16 / 10;
@@ -1510,3 +1632,742 @@
align-items: flex-start;
}
}
+
+/* ─── Services Section ──────────────────────────────────────────────────────── */
+.servicesPanel {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.servicesPanelHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0 0 1rem;
+ border-bottom: 1px solid var(--app-border-soft);
+}
+
+.servicesPanelTitleGroup {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+}
+
+.servicesPanelTitle {
+ margin: 0;
+ color: var(--app-text-strong);
+ font-size: 1.15rem;
+ font-weight: 800;
+}
+
+.servicesCount {
+ display: inline-flex;
+ align-items: center;
+ min-height: 1.6rem;
+ padding: 0.2rem 0.6rem;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--app-primary-faint) 78%, var(--brand-surface-base) 22%);
+ color: var(--app-primary);
+ font-size: 0.78rem;
+ font-weight: 800;
+}
+
+.servicesTableHead {
+ display: grid;
+ grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr) minmax(7rem, 0.8fr) minmax(8rem, 0.8fr) auto;
+ gap: 1rem;
+ padding: 0.85rem 1.25rem;
+ border-radius: var(--general-border-radius);
+ background: color-mix(in srgb, var(--brand-surface-overlay-soft) 68%, var(--brand-surface-base) 32%);
+ border: 1px solid color-mix(in srgb, var(--brand-border) 90%, transparent);
+ color: var(--brand-text-subtle);
+ font-size: 0.78rem;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.servicesTableHead span:nth-child(3),
+.servicesTableHead span:nth-child(4) {
+ justify-self: center;
+}
+
+.servicesTableBody {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+.serviceRow {
+ display: grid;
+ grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr) minmax(7rem, 0.8fr) minmax(8rem, 0.8fr) auto;
+ gap: 1rem;
+ align-items: center;
+ width: 100%;
+ padding: 1rem 1.25rem;
+ border-radius: var(--general-border-radius);
+ border: 1px solid color-mix(in srgb, var(--brand-border) 80%, transparent);
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--brand-surface-base) 98%, transparent),
+ color-mix(in srgb, var(--brand-surface-overlay-soft) 74%, var(--brand-surface-base) 26%)
+ );
+ box-shadow: 0 6px 18px color-mix(in srgb, var(--brand-shadow-base) 32%, transparent);
+ text-align: left;
+ cursor: pointer;
+ transition:
+ border-color 160ms ease,
+ box-shadow 160ms ease,
+ transform 160ms ease;
+}
+
+.serviceRow:hover {
+ border-color: color-mix(in srgb, var(--app-primary) 28%, var(--brand-border) 72%);
+ box-shadow: 0 10px 26px color-mix(in srgb, var(--brand-shadow-base) 42%, transparent);
+ transform: translateY(-1px);
+}
+
+.serviceRow:focus-visible {
+ outline: 2px solid var(--app-primary);
+ outline-offset: 2px;
+}
+
+.serviceIdentity {
+ display: flex;
+ align-items: center;
+ gap: 0.875rem;
+ min-width: 0;
+}
+
+.serviceIdentityLink {
+ width: 100%;
+ text-align: left;
+ cursor: pointer;
+}
+
+.serviceIdentityLink:hover .serviceName {
+ color: var(--app-primary);
+}
+
+.serviceIdentityLink:focus-visible {
+ outline: 2px solid var(--app-focus-ring);
+ outline-offset: 4px;
+ border-radius: var(--general-border-radius);
+}
+
+.serviceThumb {
+ position: relative;
+ width: 3.5rem;
+ height: 3.5rem;
+ flex-shrink: 0;
+ overflow: hidden;
+ border-radius: calc(var(--general-border-radius) - 0.125rem);
+ border: 1px solid color-mix(in srgb, var(--brand-border) 88%, transparent);
+ box-shadow: 0 8px 20px color-mix(in srgb, var(--brand-shadow-base) 18%, transparent);
+}
+
+.serviceThumbImage {
+ object-fit: cover;
+}
+
+.serviceThumbPlaceholder {
+ width: 3.5rem;
+ height: 3.5rem;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: calc(var(--general-border-radius) - 0.125rem);
+ background: color-mix(in srgb, var(--brand-surface-overlay-soft) 62%, var(--brand-surface-base) 38%);
+ color: var(--brand-text-subtle);
+ border: 1px solid color-mix(in srgb, var(--brand-border) 88%, transparent);
+}
+
+.serviceIdentityText {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ min-width: 0;
+}
+
+.serviceName {
+ margin: 0;
+ color: var(--app-text-strong);
+ font-size: 0.96rem;
+ font-weight: 800;
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.serviceCategory {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ min-height: 1.6rem;
+ padding: 0.2rem 0.55rem;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--app-primary) 14%, transparent);
+ background: color-mix(in srgb, var(--brand-accent-faint) 78%, var(--brand-surface-base) 22%);
+ color: var(--brand-accent-strong);
+ font-size: 0.72rem;
+ font-weight: 700;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 12rem;
+}
+
+.serviceCell {
+ min-width: 0;
+}
+
+.servicePriceCell,
+.serviceDurationCell {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.serviceBranchName {
+ color: var(--brand-text-body);
+ font-size: 0.88rem;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.servicePricePill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 3.35rem;
+ min-height: 2rem;
+ padding: 0.35rem 0.72rem;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--app-success-bg, var(--brand-success-bg)) 80%, var(--brand-surface-base) 20%);
+ color: var(--app-success, var(--brand-success));
+ font-size: 0.82rem;
+ font-weight: 800;
+}
+
+.serviceDurationPill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ min-height: 2rem;
+ padding: 0.35rem 0.72rem;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--brand-surface-overlay-soft) 72%, var(--brand-surface-base) 28%);
+ color: var(--brand-text-body);
+ font-size: 0.82rem;
+ font-weight: 700;
+}
+
+.serviceMuted {
+ color: var(--app-text-subtle);
+ font-size: 0.88rem;
+ font-weight: 600;
+}
+
+.rowActions {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ flex-shrink: 0;
+ margin-left: auto;
+}
+
+.rowActionBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ border: 1px solid color-mix(in srgb, var(--brand-border) 80%, transparent);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--brand-surface-overlay-soft) 60%, var(--brand-surface-base) 40%);
+ color: var(--brand-text-subtle);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition:
+ color 140ms ease,
+ border-color 140ms ease,
+ background-color 140ms ease,
+ transform 140ms ease;
+}
+
+.rowActionBtn:hover:not(:disabled) {
+ color: var(--app-primary);
+ border-color: color-mix(in srgb, var(--app-primary) 28%, var(--brand-border) 72%);
+ background: color-mix(in srgb, var(--app-primary-faint) 60%, var(--brand-surface-base) 40%);
+ transform: translateY(-1px);
+}
+
+.rowActionBtn:disabled {
+ opacity: 0.45;
+ cursor: default;
+}
+
+.rowActionBtnDanger:hover:not(:disabled) {
+ color: var(--brand-error);
+ border-color: color-mix(in srgb, var(--brand-error) 28%, var(--brand-border) 72%);
+ background: color-mix(in srgb, var(--brand-error-bg) 60%, var(--brand-surface-base) 40%);
+}
+
+.emptyStateServices {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ padding: 3rem 1.5rem;
+ border-radius: var(--general-border-radius);
+ border: 1px dashed color-mix(in srgb, var(--app-border-soft) 84%, transparent);
+ background: color-mix(in srgb, var(--brand-surface-overlay-soft) 42%, var(--brand-surface-base) 58%);
+ text-align: center;
+}
+
+.emptyStateServicesIcon {
+ color: color-mix(in srgb, var(--app-text-muted) 60%, transparent);
+}
+
+.emptyStateServicesText {
+ margin: 0;
+ color: var(--app-text-muted);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+/* Service modal images */
+.serviceModalImages {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ padding-top: 0.5rem;
+}
+
+.serviceModalImageFrame {
+ position: relative;
+ width: 120px;
+ height: 90px;
+ border-radius: var(--general-border-radius-card);
+ overflow: hidden;
+ background: var(--app-bg-surface-strong);
+ flex-shrink: 0;
+}
+
+.serviceModalImage {
+ object-fit: cover;
+}
+
+/* ─── Member assignment section ─────────────────────────────────────────── */
+
+.assignmentPanel {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.assignmentList {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.assignmentDataTable {
+ min-width: 0;
+}
+
+.assignmentDataTable .assignmentActions {
+ flex-wrap: nowrap;
+}
+
+.assignmentDataTable .serviceIdentity {
+ gap: 0.45rem;
+}
+
+.assignmentDataTable .serviceThumb,
+.assignmentDataTable .serviceThumbPlaceholder {
+ width: 1.75rem;
+ height: 1.75rem;
+ border-radius: calc(var(--general-border-radius) - 0.25rem);
+ box-shadow: 0 4px 10px color-mix(in srgb, var(--brand-shadow-base) 14%, transparent);
+}
+
+.assignmentDataTable .serviceName {
+ font-size: 0.75rem;
+ font-weight: 800;
+ line-height: 1.15;
+}
+
+.assignmentDataTable .servicePricePill {
+ min-width: 2.45rem;
+ min-height: 1.55rem;
+ padding: 0.22rem 0.5rem;
+ font-size: 0.68rem;
+}
+
+.assignmentDataTable .serviceDurationPill {
+ min-height: 1.55rem;
+ gap: 0.22rem;
+ padding: 0.22rem 0.35rem;
+ font-size: 0.68rem;
+ background: transparent;
+}
+
+.assignmentDataTable .assignmentActions > button {
+ min-height: 1.9rem;
+ padding: 0.34rem 0.48rem;
+ border-radius: calc(var(--general-border-radius) - 0.25rem);
+ font-size: 0.68rem;
+}
+
+.assignmentDataTable .assignmentActions > button svg {
+ width: 0.72rem;
+ height: 0.72rem;
+}
+
+.assignmentRow {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(12rem, auto) minmax(18rem, auto) auto;
+ gap: 1rem;
+ align-items: center;
+ padding: 0.85rem 1.25rem;
+ border-radius: var(--general-border-radius);
+ background: var(--brand-surface-base);
+ border: 1px solid color-mix(in srgb, var(--brand-border) 90%, transparent);
+}
+
+.assignmentStats {
+ display: inline-flex;
+ align-items: center;
+ justify-self: center;
+ gap: 0.55rem;
+ min-width: 0;
+}
+
+.assignmentStat {
+ min-height: 2rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.32rem;
+ padding: 0.3rem 0.65rem;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--brand-surface-overlay-soft) 74%, var(--brand-surface-base) 26%);
+ color: var(--brand-text-muted);
+ font-size: 0.76rem;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.assignmentStat strong {
+ color: var(--brand-text-strong);
+ font-size: 0.9rem;
+ font-weight: 900;
+}
+
+.assignmentStatValue {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 1.5rem;
+ height: 1.5rem;
+ color: var(--brand-text-strong);
+ font-size: 0.72rem;
+ font-weight: 900;
+ line-height: 1;
+}
+
+.assignmentTextCell {
+ display: block;
+ min-width: 0;
+ color: var(--brand-text-body);
+ font-size: 0.7rem;
+ font-weight: 750;
+ line-height: 1.35;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.assignmentProfileCell {
+ gap: 0.4rem;
+ max-width: 100%;
+}
+
+.assignmentProfileCell img {
+ width: 1.55rem;
+ height: 1.55rem;
+ border-width: 1px;
+ box-shadow: 0 4px 10px color-mix(in srgb, var(--brand-shadow-base) 22%, transparent);
+}
+
+.assignmentProfileCell span {
+ font-size: 0.7rem;
+ line-height: 1.2;
+}
+
+.assignmentMeta {
+ display: grid;
+ grid-template-columns: minmax(3.35rem, auto) minmax(6.25rem, auto) auto;
+ align-items: center;
+ justify-self: center;
+ column-gap: 0.7rem;
+}
+
+.assignmentMeta .serviceDurationPill {
+ justify-content: flex-start;
+ gap: 0.4rem;
+ min-width: 6.25rem;
+ padding: 0.35rem 0.35rem;
+ background: transparent;
+ font-weight: 800;
+}
+
+.assignmentActions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+}
+
+.assignmentEmptyCell {
+ color: var(--brand-text-muted);
+ font-size: 0.7rem;
+ font-weight: 800;
+}
+
+.assignmentStatusBadge {
+ display: inline-flex;
+ align-items: center;
+ justify-self: center;
+ white-space: nowrap;
+ padding: 0.18rem 0.45rem;
+ border-radius: 999px;
+ font-size: 0.66rem;
+ font-weight: 700;
+}
+
+@media (max-width: 64rem) {
+ .assignmentDataTable .assignmentActions {
+ gap: 0.32rem;
+ }
+
+ .assignmentDataTable .assignmentActions > button {
+ padding-inline: 0.4rem;
+ font-size: 0.64rem;
+ }
+}
+
+@media (max-width: 48rem) {
+ .assignmentDataTable .serviceIdentity {
+ justify-content: flex-start;
+ }
+
+ .assignmentDataTable .assignmentActions {
+ justify-content: flex-end;
+ flex-wrap: nowrap;
+ }
+
+ .assignmentDataTable .assignmentActions > button {
+ min-height: 1.8rem;
+ }
+
+ .assignmentDataTable {
+ display: table !important;
+ width: 100%;
+ table-layout: fixed;
+ border-spacing: 0 0.4rem;
+ }
+
+ .assignmentDataTable colgroup {
+ display: table-column-group !important;
+ }
+
+ .assignmentDataTable thead {
+ display: table-header-group !important;
+ }
+
+ .assignmentDataTable tbody {
+ display: table-row-group !important;
+ }
+
+ .assignmentDataTable tr {
+ display: table-row !important;
+ margin: 0;
+ overflow: visible;
+ border: 0;
+ background: transparent;
+ }
+
+ .assignmentDataTable th,
+ .assignmentDataTable td {
+ display: table-cell !important;
+ min-height: 0;
+ height: 2.85rem;
+ padding: 0.38rem 0.42rem;
+ vertical-align: middle;
+ }
+
+ .assignmentDataTable td::before {
+ display: none;
+ }
+
+ .assignableServicesTable col:nth-child(2),
+ .assignableServicesTable col:nth-child(3),
+ .assignableServicesTable col:nth-child(4),
+ .assignableServicesTable col:nth-child(5),
+ .assignableServicesTable thead th:nth-child(2),
+ .assignableServicesTable thead th:nth-child(3),
+ .assignableServicesTable thead th:nth-child(4),
+ .assignableServicesTable thead th:nth-child(5),
+ .assignableServicesTable tbody td:nth-child(2) {
+ display: none !important;
+ }
+
+ .assignableServicesTable tbody td:nth-child(3),
+ .assignableServicesTable tbody td:nth-child(4),
+ .assignableServicesTable tbody td:nth-child(5),
+ .ownerRequestsTable col:nth-child(2),
+ .ownerRequestsTable col:nth-child(3),
+ .ownerRequestsTable col:nth-child(4),
+ .ownerRequestsTable col:nth-child(5),
+ .ownerRequestsTable thead th:nth-child(2),
+ .ownerRequestsTable thead th:nth-child(3),
+ .ownerRequestsTable thead th:nth-child(4),
+ .ownerRequestsTable thead th:nth-child(5),
+ .ownerRequestsTable tbody td:nth-child(2),
+ .ownerRequestsTable tbody td:nth-child(3),
+ .ownerRequestsTable tbody td:nth-child(4),
+ .ownerRequestsTable tbody td:nth-child(5) {
+ display: none !important;
+ }
+
+ .assignableServicesTable col:nth-child(1) {
+ width: auto !important;
+ }
+
+ .assignableServicesTable col:nth-child(6) {
+ width: 4.8rem !important;
+ }
+
+ .assignableServicesTable col:nth-child(7) {
+ width: 6.2rem !important;
+ }
+
+ .ownerRequestsTable col:nth-child(1) {
+ width: auto !important;
+ }
+
+ .ownerRequestsTable col:nth-child(6) {
+ width: 8.4rem !important;
+ }
+
+ .assignmentDataTable .serviceThumb,
+ .assignmentDataTable .serviceThumbPlaceholder {
+ width: 1.8rem;
+ height: 1.8rem;
+ }
+
+ .assignmentDataTable .serviceName {
+ font-size: 0.78rem;
+ }
+
+ .assignmentDataTable .assignmentActions > button {
+ padding-inline: 0.38rem;
+ font-size: 0.62rem;
+ }
+
+ .assignmentDataTable .servicePricePill,
+ .assignmentDataTable .serviceDurationPill,
+ .assignmentStatusBadge {
+ min-height: 1.5rem;
+ font-size: 0.66rem;
+ }
+}
+
+@media (max-width: 22.5rem) {
+ .assignableServicesTable col:nth-child(6) {
+ width: 4.2rem !important;
+ }
+
+ .assignableServicesTable col:nth-child(7),
+ .ownerRequestsTable col:nth-child(6) {
+ width: 5.6rem !important;
+ }
+
+ .assignmentDataTable .assignmentActions > button {
+ padding-inline: 0.3rem;
+ font-size: 0.58rem;
+ }
+}
+
+.assignmentDialogContent {
+ max-width: min(44rem, calc(100vw - 2rem));
+}
+
+.assignmentDialogBody {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 0 1.714rem 0.25rem;
+}
+
+.assignmentInfoBox {
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ padding: 1rem;
+ border: 1px solid var(--brand-border);
+ border-radius: var(--general-border-radius);
+ background: var(--brand-surface-muted);
+}
+
+.assignmentFormGrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.85rem;
+}
+
+.assignmentField {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ min-width: 0;
+}
+
+.assignmentFieldHint {
+ color: var(--brand-text-muted);
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+/* Branch row "your branch" badge */
+
+.branchNameRow {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ min-width: 0;
+}
+
+.yourBranchBadge {
+ display: inline-flex;
+ align-items: center;
+ flex: 0 0 auto;
+ max-width: 100%;
+ padding: 0.15rem 0.55rem;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--app-primary-faint) 85%, var(--brand-surface-base) 15%);
+ color: var(--app-primary);
+ font-size: 0.7rem;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ line-height: 1.25;
+ white-space: nowrap;
+}
diff --git a/src/components/organisms/brand-detail/brand-detail.tsx b/src/components/organisms/brand-detail/brand-detail.tsx
index 0fd1dbf..532d0c0 100644
--- a/src/components/organisms/brand-detail/brand-detail.tsx
+++ b/src/components/organisms/brand-detail/brand-detail.tsx
@@ -1,144 +1,78 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { isAxiosError } from "axios";
import {
- Alert,
- AlertDescription,
AlertDialog,
AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogCancel,
} from "@/components/atoms";
import { Button } from "@/components/atoms/button";
import { Input } from "@/components/atoms/input";
import { Icon } from "@/components/icon";
-import { ProfileBox } from "@/components/molecules";
+import { DataTable, type DataTableColumn, ProfileBox } from "@/components/molecules";
import { useLocale } from "@/components/providers/locale-provider";
import {
fetchBrandTeamWorkspace,
submitBrandRating,
+ deleteBranchApi,
type BrandTeamWorkspace,
type TeamWorkspaceMember,
} from "@/lib/brands-api";
+import {
+ fetchPublicServices,
+ fetchAssignableBrandServices,
+ fetchBrandServiceAssignmentRequests,
+ requestServiceAssignment,
+ approveServiceAssignment,
+ rejectServiceAssignment,
+ withdrawServiceAssignment,
+} from "@/lib/services-api";
import { translateBackendErrorMessage } from "@/lib/backend-errors";
import { proxyMediaUrl } from "@/lib/media";
import { selectAuthSession } from "@/store/auth";
import { useAppSelector } from "@/store/hooks";
import type { Brand, Branch, BrandStatus } from "@/types/brand";
import type { PublicUserProfile } from "@/types";
+import type { Service, AssignableService, ServiceAssignmentRequest } from "@/types/service";
+import { RichTextDisplay } from "@/components/molecules/rich-text-editor/rich-text-display";
+import { StatusBanner } from "@/components/molecules/status-banner";
+import { SocialIcon, SOCIAL_COLORS } from "@/components/atoms/social-icon/social-icon";
import styles from "./brand-detail.module.css";
type BrandDetailProps = {
brand: Brand;
currentUserId?: string;
owner?: PublicUserProfile | null;
+ actionSlot?: ReactNode;
};
type BranchFilter = "all" | "open247" | "withContact";
-type BranchStudioCopy = {
- rowHint: string;
- branchVisualTitle: string;
- branchVisualLead: string;
- branchVisualHint: string;
- branchVisualEmptyHint: string;
- branchTeamTitle: string;
- branchTeamLead: string;
- branchTeamEmpty: string;
- branchTeamLoading: string;
- branchTeamError: string;
- acceptedShort: string;
- pendingShort: string;
- archivedShort: string;
- ownerShort: string;
- memberShort: string;
- branchPhotoBadge: string;
- branchPhotoReadyBadge: string;
- branchTeamBadge: string;
-};
-
-const EN_BRANCH_STUDIO_COPY: BranchStudioCopy = {
- rowHint: "Tap the branch row to view its details.",
- branchVisualTitle: "Branch photo",
- branchVisualLead:
- "Photo and quick details for this branch.",
- branchVisualHint:
- "Branch photo",
- branchVisualEmptyHint:
- "No photo has been added for this branch yet.",
- branchTeamTitle: "Branch team",
- branchTeamLead:
- "People working in this branch.",
- branchTeamEmpty: "Only the owner is attached here right now.",
- branchTeamLoading: "Loading branch team...",
- branchTeamError: "Branch team data could not be loaded.",
- acceptedShort: "Accepted",
- pendingShort: "Pending",
- archivedShort: "Archived",
- ownerShort: "Owner",
- memberShort: "Member",
- branchPhotoBadge: "No photo",
- branchPhotoReadyBadge: "Photo ready",
- branchTeamBadge: "Team",
-};
-
-const TR_BRANCH_STUDIO_COPY: BranchStudioCopy = {
- rowHint: "Detayları görmek için şube satırına dokun.",
- branchVisualTitle: "Şube fotoğrafı",
- branchVisualLead:
- "Bu şubeye ait fotoğraf ve kısa bilgiler.",
- branchVisualHint:
- "Şube fotoğrafı",
- branchVisualEmptyHint:
- "Bu şube için henüz fotoğraf eklenmedi.",
- branchTeamTitle: "Şube takımı",
- branchTeamLead:
- "Bu şubede çalışan kişiler.",
- branchTeamEmpty: "Şimdilik burada sadece owner bağlı.",
- branchTeamLoading: "Şube takımı yükleniyor...",
- branchTeamError: "Şube takım verisi yüklenemedi.",
- acceptedShort: "Kabul",
- pendingShort: "Bekleyen",
- archivedShort: "Arşiv",
- ownerShort: "Sahip",
- memberShort: "Üye",
- branchPhotoBadge: "Foto yok",
- branchPhotoReadyBadge: "Foto hazır",
- branchTeamBadge: "Takım",
-};
-
-const AZ_BRANCH_STUDIO_COPY: BranchStudioCopy = {
- rowHint: "Detalları görmək üçün filial sətrinə toxun.",
- branchVisualTitle: "Filial fotosu",
- branchVisualLead:
- "Bu filiala aid foto və qısa məlumatlar.",
- branchVisualHint:
- "Filial fotosu",
- branchVisualEmptyHint:
- "Bu filial üçün hələ foto əlavə olunmayıb.",
- branchTeamTitle: "Filial komandası",
- branchTeamLead:
- "Bu filialda çalışan şəxslər.",
- branchTeamEmpty: "Hazırda burada yalnız owner qoşulub.",
- branchTeamLoading: "Filial komandası yüklənir...",
- branchTeamError: "Filial komanda məlumatı yüklənmədi.",
- acceptedShort: "Qəbul",
- pendingShort: "Gözləyən",
- archivedShort: "Arxiv",
- ownerShort: "Sahib",
- memberShort: "Üzv",
- branchPhotoBadge: "Foto yoxdur",
- branchPhotoReadyBadge: "Foto hazırdır",
- branchTeamBadge: "Komanda",
-};
+function formatServicePrice(service: Service, t: { serviceLabelFree: string; serviceLabelFrom: string }): string {
+ if (service.price_type === "FREE") return t.serviceLabelFree;
+ if (service.price === null) return "—";
+ if (service.price_type === "STARTING_FROM") return `${t.serviceLabelFrom} ${service.price}`;
+ return String(service.price);
+}
-function getBranchStudioCopy(locale: string) {
- if (locale.startsWith("az")) return AZ_BRANCH_STUDIO_COPY;
- if (locale.startsWith("tr")) return TR_BRANCH_STUDIO_COPY;
- return EN_BRANCH_STUDIO_COPY;
+function formatServiceDuration(minutes: number | null, unit: string): string {
+ if (!minutes) return "—";
+ if (minutes < 60) return `${minutes} ${unit}`;
+ const h = Math.floor(minutes / 60);
+ const m = minutes % 60;
+ const hourUnit =
+ unit === "мин" ? "ч" : unit === "dəq" ? "saat" : unit === "dk" ? "sa" : "h";
+ return m > 0 ? `${h}${hourUnit} ${m}${unit}` : `${h}${hourUnit}`;
}
+
function StarRating({ rating, max = 5 }: { rating: number; max?: number }) {
return (
-
+
);
})}
@@ -272,12 +207,12 @@ export function BrandDetail({
brand,
currentUserId,
owner,
+ actionSlot,
}: BrandDetailProps) {
const router = useRouter();
const searchParams = useSearchParams();
- const { locale, messages } = useLocale();
+ const { messages } = useLocale();
const t = messages.brands;
- const studioCopy = useMemo(() => getBranchStudioCopy(locale), [locale]);
const session = useAppSelector(selectAuthSession);
const currentUser = session.user;
const dashboard = messages.dashboard;
@@ -293,8 +228,42 @@ export function BrandDetail({
const [teamWorkspaceState, setTeamWorkspaceState] = useState<
"idle" | "loading" | "ready" | "error"
>("idle");
-
- const isOwner = Boolean(currentUserId && brandState.owner_id === currentUserId);
+ const [brandServices, setBrandServices] = useState