diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 423033c..fe96332 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,25 @@ "dependencies": { "@base-ui/react": "^1.3.0", "@reduxjs/toolkit": "^2.11.2", + "@tiptap/extension-color": "^3.22.5", + "@tiptap/extension-placeholder": "^3.22.5", + "@tiptap/extension-text-style": "^3.22.5", + "@tiptap/extension-underline": "^3.22.5", + "@tiptap/pm": "^3.22.5", + "@tiptap/react": "^3.22.5", + "@tiptap/starter-kit": "^3.22.5", + "@types/sanitize-html": "^2.16.1", "axios": "1.14.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^1.7.0", "next": "16.2.1", "react": "19.2.4", + "react-day-picker": "^9.14.0", "react-dom": "19.2.4", "react-redux": "^9.2.0", + "sanitize-html": "^2.17.3", "shadcn": "^4.2.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59d8f23..df14b31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,30 @@ importers: '@reduxjs/toolkit': specifier: ^2.11.2 version: 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + '@tiptap/extension-color': + specifier: ^3.22.5 + version: 3.22.5(@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))) + '@tiptap/extension-placeholder': + specifier: ^3.22.5 + version: 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-text-style': + specifier: ^3.22.5 + version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-underline': + specifier: ^3.22.5 + version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/pm': + specifier: ^3.22.5 + version: 3.22.5 + '@tiptap/react': + specifier: ^3.22.5 + version: 3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tiptap/starter-kit': + specifier: ^3.22.5 + version: 3.22.5 + '@types/sanitize-html': + specifier: ^2.16.1 + version: 2.16.1 axios: specifier: 1.14.0 version: 1.14.0 @@ -23,6 +47,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 lucide-react: specifier: ^1.7.0 version: 1.7.0(react@19.2.4) @@ -32,12 +59,18 @@ importers: react: specifier: 19.2.4 version: 19.2.4 + react-day-picker: + specifier: ^9.14.0 + version: 9.14.0(react@19.2.4) react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) react-redux: specifier: ^9.2.0 version: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + sanitize-html: + specifier: ^2.17.3 + version: 2.17.3 shadcn: specifier: ^4.2.0 version: 4.2.0(@types/node@20.19.37)(typescript@5.9.3) @@ -239,6 +272,9 @@ packages: '@types/react': optional: true + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@dotenvx/dotenvx@1.60.2': resolution: {integrity: sha512-r4AznHUvfLONuWdoSIQtut6Ez/ym+lGXRtDvRaoAEMEhAmwSoK24jRsfR28vcb3ygWm7qeYOcbZolhtseJl6mA==} hasBin: true @@ -679,6 +715,10 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tabby_ai/hijri-converter@1.0.5': + resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} + engines: {node: '>=16.0.0'} + '@tailwindcss/node@4.2.2': resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} @@ -771,6 +811,170 @@ packages: '@tailwindcss/postcss@4.2.2': resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + '@tiptap/core@3.22.5': + resolution: {integrity: sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==} + peerDependencies: + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-blockquote@3.22.5': + resolution: {integrity: sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-bold@3.22.5': + resolution: {integrity: sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-bubble-menu@3.22.5': + resolution: {integrity: sha512-yrNlFQQJY5MmhBpmD8tnmaSmyUQrEvgyPKa3bzVeWEhDSG1CW4A0ZSMx3hrA9yFO0HWfw3IJmvSCycEZQBalpQ==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-bullet-list@3.22.5': + resolution: {integrity: sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + + '@tiptap/extension-code-block@3.22.5': + resolution: {integrity: sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-code@3.22.5': + resolution: {integrity: sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-color@3.22.5': + resolution: {integrity: sha512-4aTygOUlTFBYCvJy67SeKVdXCQw7du3Rj+N5ZutVnDnrpfzUBWsO7f+I+iDS8eMQFbWxVFLlWxGMcTbjtk1a+Q==} + peerDependencies: + '@tiptap/extension-text-style': 3.22.5 + + '@tiptap/extension-document@3.22.5': + resolution: {integrity: sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-dropcursor@3.22.5': + resolution: {integrity: sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==} + peerDependencies: + '@tiptap/extensions': 3.22.5 + + '@tiptap/extension-floating-menu@3.22.5': + resolution: {integrity: sha512-dhem4sTPhyQgQ+pFp2Oud4k4FSQz9PVMgeQAC9288SmGwxBkJNveDAw6sKTMrumqDvwkJrtslXIupq9TZYQnzg==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-gapcursor@3.22.5': + resolution: {integrity: sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==} + peerDependencies: + '@tiptap/extensions': 3.22.5 + + '@tiptap/extension-hard-break@3.22.5': + resolution: {integrity: sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-heading@3.22.5': + resolution: {integrity: sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-horizontal-rule@3.22.5': + resolution: {integrity: sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-italic@3.22.5': + resolution: {integrity: sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-link@3.22.5': + resolution: {integrity: sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-list-item@3.22.5': + resolution: {integrity: sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + + '@tiptap/extension-list-keymap@3.22.5': + resolution: {integrity: sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + + '@tiptap/extension-list@3.22.5': + resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-ordered-list@3.22.5': + resolution: {integrity: sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==} + peerDependencies: + '@tiptap/extension-list': 3.22.5 + + '@tiptap/extension-paragraph@3.22.5': + resolution: {integrity: sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-placeholder@3.22.5': + resolution: {integrity: sha512-MZAohQ3FCS763BkhGXgaWRya6WruZjwRwEAkXP8vkxbERzl2OJRjniS4uXCWzAlRb3ttE103SnY7LMdM8FvsXw==} + peerDependencies: + '@tiptap/extensions': 3.22.5 + + '@tiptap/extension-strike@3.22.5': + resolution: {integrity: sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-text-style@3.22.5': + resolution: {integrity: sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-text@3.22.5': + resolution: {integrity: sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extension-underline@3.22.5': + resolution: {integrity: sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==} + peerDependencies: + '@tiptap/core': 3.22.5 + + '@tiptap/extensions@3.22.5': + resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + + '@tiptap/pm@3.22.5': + resolution: {integrity: sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==} + + '@tiptap/react@3.22.5': + resolution: {integrity: sha512-36WHEs+vPmB//V1ff7Ujcnpz7Ey5g8lhpI/0+hoanSbdiPMTQ7qZVWwMovIkMKDlqWVp2fxBgeYM1861jyFzTw==} + peerDependencies: + '@tiptap/core': 3.22.5 + '@tiptap/pm': 3.22.5 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.22.5': + resolution: {integrity: sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==} + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -797,6 +1001,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -1268,6 +1475,12 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -1340,6 +1553,19 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.4.1: resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} engines: {node: '>=12'} @@ -1375,6 +1601,14 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1583,6 +1817,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -1803,6 +2041,9 @@ packages: resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} engines: {node: '>=16.9.0'} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1960,6 +2201,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -2189,6 +2434,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2414,6 +2662,9 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -2441,6 +2692,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2519,6 +2773,42 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + prosemirror-changeset@2.4.1: + resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.4: + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + + prosemirror-view@1.41.8: + resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2546,6 +2836,12 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-day-picker@9.14.0: + resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -2629,6 +2925,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2655,6 +2954,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-html@2.17.3: + resolution: {integrity: sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2993,6 +3295,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -3306,6 +3611,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@date-fns/tz@1.4.1': {} + '@dotenvx/dotenvx@1.60.2': dependencies: commander: 11.1.0 @@ -3686,6 +3993,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@tabby_ai/hijri-converter@1.0.5': {} + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3755,6 +4064,189 @@ snapshots: postcss: 8.5.9 tailwindcss: 4.2.2 + '@tiptap/core@3.22.5(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-blockquote@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-bold@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-bubble-menu@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + optional: true + + '@tiptap/extension-bullet-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-code@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-color@3.22.5(@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)))': + dependencies: + '@tiptap/extension-text-style': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + + '@tiptap/extension-document@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-dropcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-floating-menu@3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + optional: true + + '@tiptap/extension-gapcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-hard-break@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-heading@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-horizontal-rule@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-italic@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-link@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-list-keymap@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + + '@tiptap/extension-ordered-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-paragraph@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-placeholder@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + + '@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-text@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extension-underline@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + + '@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + + '@tiptap/pm@3.22.5': + dependencies: + prosemirror-changeset: 2.4.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + '@tiptap/react@3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-floating-menu': 3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.22.5': + dependencies: + '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/extension-blockquote': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-bold': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-bullet-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-code': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-code-block': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-document': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-dropcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-gapcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-hard-break': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-heading': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-horizontal-rule': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-italic': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-link': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list-item': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-list-keymap': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-ordered-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + '@tiptap/extension-paragraph': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-strike': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-text': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extension-underline': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/pm': 3.22.5 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -3784,6 +4276,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 + '@types/statuses@2.0.6': {} '@types/use-sync-external-store@0.0.6': {} @@ -4253,6 +4749,10 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns-jalali@4.1.0-0: {} + + date-fns@4.1.0: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -4300,6 +4800,24 @@ snapshots: dependencies: esutils: 2.0.3 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@17.4.1: {} dunder-proto@1.0.1: @@ -4332,6 +4850,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + entities@4.5.0: {} + + entities@7.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -4728,6 +5250,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4941,6 +5465,13 @@ snapshots: hono@4.12.12: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -5082,6 +5613,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-plain-object@5.0.0: {} + is-promise@4.0.0: {} is-regex@1.2.1: @@ -5265,6 +5798,8 @@ snapshots: lines-and-columns@1.2.4: {} + linkifyjs@4.3.2: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -5511,6 +6046,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + orderedmap@2.1.1: {} + outvariant@1.4.3: {} own-keys@1.0.1: @@ -5540,6 +6077,8 @@ snapshots: parse-ms@4.0.0: {} + parse-srcset@1.0.2: {} + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -5602,6 +6141,75 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + prosemirror-changeset@2.4.1: + dependencies: + prosemirror-transform: 1.12.0 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + rope-sequence: 1.3.4 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.4: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-view@1.41.8: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -5626,6 +6234,14 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-day-picker@9.14.0(react@19.2.4): + dependencies: + '@date-fns/tz': 1.4.1 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.4 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -5712,6 +6328,8 @@ snapshots: reusify@1.1.0: {} + rope-sequence@1.3.4: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -5749,6 +6367,15 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-html@2.17.3: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 10.1.0 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.5.9 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -6213,6 +6840,8 @@ snapshots: vary@1.1.2: {} + w3c-keyname@2.2.8: {} + web-streams-polyfill@3.3.3: {} which-boxed-primitive@1.1.1: diff --git a/src/app/(protected)/account/page.tsx b/src/app/(protected)/account/page.tsx index 7c41e2d..13241b8 100644 --- a/src/app/(protected)/account/page.tsx +++ b/src/app/(protected)/account/page.tsx @@ -5,7 +5,9 @@ import { UserProfilePanel } from "@/components/organisms"; import { getMessages } from "@/i18n/config"; import { getServerLocale } from "@/i18n/server"; import { fetchAccountBrands } from "@/lib/brands-api"; +import { buildPageTitle } from "@/lib/page-metadata"; import { requireProtectedRouteAccess } from "@/lib/protected-route"; +import { fetchPublicServices } from "@/lib/services-api"; import { fetchUserProfileById } from "@/lib/users-api"; type AccountPageProps = { @@ -22,9 +24,28 @@ function getSearchParamValue( return value ?? null; } -export async function generateMetadata(): Promise { +export async function generateMetadata({ + searchParams, +}: AccountPageProps): Promise { const locale = await getServerLocale(); const messages = getMessages(locale); + const resolvedSearchParams = (await searchParams) ?? {}; + const requestedUserId = getSearchParamValue(resolvedSearchParams.id)?.trim(); + + if (requestedUserId) { + const cookieStore = await cookies(); + const accessToken = cookieStore.get("rzp_at")?.value; + const targetUser = accessToken + ? await fetchUserProfileById(requestedUserId, accessToken).catch(() => null) + : null; + const fullName = targetUser + ? `${targetUser.first_name} ${targetUser.last_name}`.trim() + : null; + + return { + title: buildPageTitle(messages.dashboard.profile, fullName), + }; + } return { title: messages.dashboard.account, @@ -53,7 +74,13 @@ export default async function AccountPage({ searchParams }: AccountPageProps) { notFound(); } - const brands = await fetchAccountBrands(requestedUserId, accessToken).catch(() => []); + const [brands, services] = await Promise.all([ + fetchAccountBrands(requestedUserId, accessToken).catch(() => []), + fetchPublicServices( + { owner_id: requestedUserId, direct_only: true }, + accessToken, + ).catch(() => []), + ]); - return ; + return ; } diff --git a/src/app/(protected)/brands/page.tsx b/src/app/(protected)/brands/page.tsx index 2a63112..154bb1c 100644 --- a/src/app/(protected)/brands/page.tsx +++ b/src/app/(protected)/brands/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { cookies } from "next/headers"; import { notFound } from "next/navigation"; import { AccountBrandsSection } from "@/components/organisms/account-brands-section/account-brands-section"; @@ -5,11 +6,14 @@ import { requireProtectedRouteAccess } from "@/lib/protected-route"; import { fetchMyBrands, fetchBrandById, - fetchActiveBrands, + fetchActiveBrandsPage, fetchAccountBrands, fetchBrandCategories, fetchBrandTeamWorkspace, } from "@/lib/brands-api"; +import { fetchPublicServices } from "@/lib/services-api"; +import { fetchMarketplaceFacets } from "@/lib/marketplace-api"; +import { fetchBrandForReview } from "@/lib/moderation-api"; import { BrandsUsoPage } from "@/components/organisms/brands-uso-page"; import { BrandsUcrPage } from "@/components/organisms/brands-ucr-page"; import { BrandDetail } from "@/components/organisms/brand-detail"; @@ -18,7 +22,9 @@ import { BrandTeamWorkspace } from "@/components/organisms/brand-team-workspace" import { fetchUserProfileById } from "@/lib/users-api"; import { getMessages } from "@/i18n/config"; import { getServerLocale } from "@/i18n/server"; -import type { Brand, PublicUserProfile } from "@/types"; +import { buildPageTitle } from "@/lib/page-metadata"; +import type { Brand, BrandStatus, PublicUserProfile } from "@/types"; +import type { ModerationBrandDetail } from "@/types/moderation"; type BrandsPageProps = { searchParams?: Promise>; @@ -58,6 +64,113 @@ async function fetchBrandOwnersById( ); } +function mapModerationBrandToBrand(brand: ModerationBrandDetail): Brand { + return { + id: brand.id, + name: brand.name, + description: brand.description ?? undefined, + status: brand.status as BrandStatus, + 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: branch.is_24_7 ?? false, + 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 mapModerationBrandOwnerToProfile(brand: ModerationBrandDetail): PublicUserProfile { + return { + id: brand.owner.id, + first_name: brand.owner.first_name, + last_name: brand.owner.last_name, + email: brand.owner.email, + type: "uso", + avatar_url: brand.owner.avatar_url ?? null, + created_at: brand.owner.created_at ?? brand.created_at, + updated_at: brand.owner.created_at ?? brand.created_at, + }; +} + +export async function generateMetadata({ + searchParams, +}: BrandsPageProps): Promise { + const [locale, resolvedParams, cookieStore] = await Promise.all([ + getServerLocale(), + searchParams ?? Promise.resolve({}), + cookies(), + ]); + const messages = getMessages(locale); + const progress = getStringParam(resolvedParams, "progress"); + const brandId = getStringParam(resolvedParams, "id"); + const accountUserId = getStringParam(resolvedParams, "account"); + const accessToken = cookieStore.get("rzp_at")?.value ?? ""; + + if (accountUserId && !progress && !brandId) { + const targetUser = await fetchUserProfileById(accountUserId, accessToken).catch(() => null); + const fullName = targetUser + ? `${targetUser.first_name} ${targetUser.last_name}`.trim() + : null; + + return { + title: buildPageTitle(messages.profile.brandsSectionTitle, fullName), + }; + } + + if (progress === "create") { + return { + title: buildPageTitle(messages.dashboard.brands, messages.brands.createBrand), + }; + } + + if (brandId) { + const brand = await fetchBrandById(brandId, accessToken).catch(() => null); + + if (progress === "edit") { + return { + title: buildPageTitle(messages.brands.editBrand, brand?.name), + }; + } + + if (progress === "team") { + return { + title: buildPageTitle(messages.brands.teamWorkspace, brand?.name), + }; + } + + return { + title: buildPageTitle(messages.brands.detailTitle, brand?.name), + }; + } + + return { + title: messages.dashboard.brands, + }; +} + export default async function BrandsPage({ searchParams }: BrandsPageProps) { const resolvedParams = await (searchParams ?? Promise.resolve({})); const user = await requireProtectedRouteAccess("/brands", resolvedParams); @@ -137,6 +250,26 @@ export default async function BrandsPage({ searchParams }: BrandsPageProps) { // ── Brand detail view (?id=) ──────────────────────────────────── if (brandId && !progress) { + if (user.type === "admin") { + const moderationBrand = await fetchBrandForReview(brandId, accessToken).catch(() => null); + + if (!moderationBrand) { + return ( +
+ Brand not found. +
+ ); + } + + return ( + + ); + } + const brand = await fetchBrandById(brandId, accessToken).catch(() => null); if (!brand) { @@ -225,8 +358,37 @@ export default async function BrandsPage({ searchParams }: BrandsPageProps) { // ── UCR default view (should only land here with ?id, handled above) ─────── // Fallback: show the active brands gallery - const brands = await fetchActiveBrands(accessToken).catch(() => []); - const ownersById = await fetchBrandOwnersById(brands, accessToken); + const activeBrandCategoryId = + getStringParam(resolvedParams, "category") ?? + getStringParam(resolvedParams, "brand_category_id"); + const [brandsPage, featuredServices, marketplaceFacets] = await Promise.all([ + fetchActiveBrandsPage(accessToken, { + page: 1, + limit: 24, + ...(activeBrandCategoryId && { brand_category_id: activeBrandCategoryId }), + }).catch(() => ({ + brands: [], + meta: { page: 1, limit: 24, total_count: 0, has_more: false }, + })), + fetchPublicServices({}, accessToken).catch(() => []), + fetchMarketplaceFacets(accessToken).catch(() => ({ + service_categories: [], + brand_categories: [], + })), + ]); + const ownersById = await fetchBrandOwnersById(brandsPage.brands, accessToken); + const activeServices = featuredServices.filter((s) => s.status === "ACTIVE"); - return ; + return ( + + ); } diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx index 2e48711..1e1bbf6 100644 --- a/src/app/(protected)/dashboard/page.tsx +++ b/src/app/(protected)/dashboard/page.tsx @@ -1,4 +1,16 @@ +import type { Metadata } from "next"; import { ProtectedComingSoonRoute } from "@/components/organisms/protected-coming-soon-route"; +import { getMessages } from "@/i18n/config"; +import { getServerLocale } from "@/i18n/server"; + +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.dashboard.dashboardPage, + }; +} export default function DashboardPage() { return ; diff --git a/src/app/(protected)/favorites/page.tsx b/src/app/(protected)/favorites/page.tsx index f295d17..a3dbbd1 100644 --- a/src/app/(protected)/favorites/page.tsx +++ b/src/app/(protected)/favorites/page.tsx @@ -1,5 +1,91 @@ -import { ProtectedComingSoonRoute } from "@/components/organisms/protected-coming-soon-route"; +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { UcrFavoritesPage } from "@/components/organisms/ucr-favorites-page"; +import { getMessages } from "@/i18n/config"; +import { getServerLocale } from "@/i18n/server"; +import { fetchFavorites } from "@/lib/favorites-api"; +import { requireProtectedRouteAccess } from "@/lib/protected-route"; +import { fetchUserProfileById } from "@/lib/users-api"; +import type { Brand, PublicUserProfile } from "@/types"; +import type { Service } from "@/types/service"; -export default function FavoritesPage() { - return ; +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.dashboard.favorites, + }; +} + +type FavoritesPageProps = { + searchParams?: Promise>; +}; + +async function fetchOwnersById( + brands: Brand[], + services: Service[], + accessToken: string, +): Promise> { + const ownerIds = [ + ...new Set( + [ + ...brands.map((brand) => brand.owner_id), + ...services.map((service) => service.owner_id), + ].filter(Boolean), + ), + ]; + + if (ownerIds.length === 0) return {}; + + const entries = await Promise.all( + ownerIds.map(async (ownerId) => { + const owner = await fetchUserProfileById(ownerId, accessToken); + return owner ? ([ownerId, owner] as const) : null; + }), + ); + + return Object.fromEntries( + entries.filter( + (entry): entry is readonly [string, PublicUserProfile] => entry !== null, + ), + ); +} + +export const dynamic = "force-dynamic"; + +export default async function FavoritesPage({ searchParams }: FavoritesPageProps) { + const resolvedParams = await (searchParams ?? Promise.resolve({})); + const user = await requireProtectedRouteAccess("/favorites", resolvedParams); + + if (user.type !== "ucr") { + redirect("/home"); + } + + const cookieStore = await cookies(); + const accessToken = cookieStore.get("rzp_at")?.value ?? ""; + const favorites = await fetchFavorites(accessToken).catch(() => ({ + brands: [], + services: [], + service_brands: [], + brand_ids: [], + service_ids: [], + })); + const ownersById = await fetchOwnersById( + [...favorites.brands, ...favorites.service_brands], + favorites.services, + accessToken, + ); + + return ( + + ); } diff --git a/src/app/(protected)/moderation/page.tsx b/src/app/(protected)/moderation/page.tsx index cd7b881..394019a 100644 --- a/src/app/(protected)/moderation/page.tsx +++ b/src/app/(protected)/moderation/page.tsx @@ -1,5 +1,20 @@ -import { ProtectedComingSoonRoute } from "@/components/organisms/protected-coming-soon-route/protected-coming-soon-route"; +import type { Metadata } from "next"; +import { requireProtectedRouteAccess } from "@/lib/protected-route"; +import { AdminModerationWorkspace } from "@/components/organisms/admin-moderation-workspace"; +import { getMessages } from "@/i18n/config"; +import { getServerLocale } from "@/i18n/server"; -export default function ModerationPage() { - return ; +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.dashboard.moderation, + }; +} + +export default async function ModerationPage() { + await requireProtectedRouteAccess("/moderation"); + + return ; } diff --git a/src/app/(protected)/notification/page.tsx b/src/app/(protected)/notification/page.tsx index fa75502..ee48f81 100644 --- a/src/app/(protected)/notification/page.tsx +++ b/src/app/(protected)/notification/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { cookies } from "next/headers"; import { NotificationTransferPage, @@ -5,6 +6,17 @@ import { } from "@/components/organisms/notification-transfer-page/notification-transfer-page"; import { fetchBrandById, fetchNotificationFeed } from "@/lib/brands-api"; import { requireProtectedRouteAccess } from "@/lib/protected-route"; +import { getMessages } from "@/i18n/config"; +import { getServerLocale } from "@/i18n/server"; + +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.dashboard.notifications, + }; +} async function buildTeamInvitationDetails( initialFeed: Awaited>, @@ -39,7 +51,7 @@ async function buildTeamInvitationDetails( brand_name: brand?.name ?? item.data.brand_name, brand_logo_url: brand?.logo_url ?? null, brand_gallery_url: brand?.gallery?.[0]?.url ?? null, - brand_categories: brand?.categories?.map((category) => category.name) ?? [], + brand_categories: brand?.categories?.map((category) => category.key) ?? [], brand_description: brand?.description ?? null, branch_name: branch?.name ?? item.data.branch_name, branch_cover_url: branch?.cover_url ?? null, diff --git a/src/app/(protected)/rezervations/page.tsx b/src/app/(protected)/rezervations/page.tsx index e6093b6..0546fd0 100644 --- a/src/app/(protected)/rezervations/page.tsx +++ b/src/app/(protected)/rezervations/page.tsx @@ -1,4 +1,16 @@ +import type { Metadata } from "next"; import { ProtectedComingSoonRoute } from "@/components/organisms/protected-coming-soon-route"; +import { getMessages } from "@/i18n/config"; +import { getServerLocale } from "@/i18n/server"; + +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.dashboard.reservations, + }; +} export default function RezervationsPage() { return ; diff --git a/src/app/(protected)/search/page.tsx b/src/app/(protected)/search/page.tsx index 64d5b3f..7c6d5ac 100644 --- a/src/app/(protected)/search/page.tsx +++ b/src/app/(protected)/search/page.tsx @@ -1,5 +1,50 @@ -import { ProtectedComingSoonRoute } from "@/components/organisms/protected-coming-soon-route"; +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { UcrSearchPage } from "@/components/organisms/ucr-search-page"; +import { getMessages } from "@/i18n/config"; +import { getServerLocale } from "@/i18n/server"; +import { fetchMarketplaceFacets } from "@/lib/marketplace-api"; +import { requireProtectedRouteAccess } from "@/lib/protected-route"; -export default function SearchPage() { - return ; +type SearchPageProps = { + searchParams?: Promise>; +}; + +function getStringParam( + params: Record, + key: string, +): string | undefined { + const value = params[key]; + return Array.isArray(value) ? value[0] : value; +} + +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.dashboard.search, + }; +} + +export const dynamic = "force-dynamic"; + +export default async function SearchPage({ searchParams }: SearchPageProps) { + const resolvedParams = await (searchParams ?? Promise.resolve({})); + await requireProtectedRouteAccess("/search", resolvedParams); + + const cookieStore = await cookies(); + const accessToken = cookieStore.get("rzp_at")?.value ?? ""; + const facets = await fetchMarketplaceFacets(accessToken).catch(() => ({ + service_categories: [], + brand_categories: [], + })); + + return ( + + ); } diff --git a/src/app/(protected)/services/page.tsx b/src/app/(protected)/services/page.tsx index 25200c5..3825357 100644 --- a/src/app/(protected)/services/page.tsx +++ b/src/app/(protected)/services/page.tsx @@ -1,28 +1,200 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; import { cookies } from "next/headers"; -import { ServicesStrategyPage } from "@/components/organisms/services-strategy-page"; -import { fetchBrandById, fetchMyBrands } from "@/lib/brands-api"; +import { getMessages } from "@/i18n/config"; +import { getServerLocale } from "@/i18n/server"; +import { buildPageTitle } from "@/lib/page-metadata"; +import { fetchMyServices, fetchPublicServicesPage, fetchServiceById, fetchServiceCategories } from "@/lib/services-api"; +import { fetchMyBrands, fetchBrandById, fetchActiveBrands } from "@/lib/brands-api"; +import { fetchMarketplaceFacets } from "@/lib/marketplace-api"; +import { fetchUserProfileById } from "@/lib/users-api"; +import { ServicesUsoPage } from "@/components/organisms/services-uso-page"; +import { UcrServicesPage } from "@/components/organisms/ucr-services-page"; +import { PublicServiceDetail } from "@/components/organisms/public-service-detail"; import { requireProtectedRouteAccess } from "@/lib/protected-route"; type ServicesPageProps = { searchParams?: Promise>; }; +function getStringParam( + params: Record, + key: string, +): string | undefined { + const val = params[key]; + return Array.isArray(val) ? val[0] : val; +} + +async function fetchDetailedBrandsForServices( + serviceOwnerId: string, + accessToken: string, +): Promise>> { + const brands = await fetchActiveBrands(accessToken).catch(() => []); + const relevantBrands = brands.filter((brand) => brand.owner_id === serviceOwnerId); + + return Promise.all( + relevantBrands.map(async (brand) => { + const detailed = await fetchBrandById(brand.id, accessToken).catch(() => null); + return detailed ?? brand; + }), + ); +} + +async function fetchServiceOwnersById( + services: Awaited>["services"], + accessToken: string, +) { + const ownerIds = [...new Set(services.map((service) => service.owner_id).filter(Boolean))]; + const ownerEntries = await Promise.all( + ownerIds.map(async (ownerId) => { + const owner = await fetchUserProfileById(ownerId, accessToken); + return owner ? ([ownerId, owner] as const) : null; + }), + ); + + return Object.fromEntries(ownerEntries.filter((entry): entry is NonNullable => entry !== null)); +} + +export async function generateMetadata({ + searchParams, +}: ServicesPageProps): Promise { + const [locale, resolvedParams, cookieStore] = await Promise.all([ + getServerLocale(), + searchParams ?? Promise.resolve({}), + cookies(), + ]); + const messages = getMessages(locale); + const serviceId = getStringParam(resolvedParams, "id"); + + if (serviceId) { + const accessToken = cookieStore.get("rzp_at")?.value; + const service = await fetchServiceById(serviceId, accessToken).catch(() => null); + + return { + title: buildPageTitle(messages.dashboard.services, service?.title), + }; + } + + return { + title: messages.dashboard.services, + }; +} + export default async function ServicesPage({ searchParams, }: ServicesPageProps) { - await requireProtectedRouteAccess("/services", searchParams); + const resolvedParams = await (searchParams ?? Promise.resolve({})); + const user = await requireProtectedRouteAccess("/services", resolvedParams); const cookieStore = await cookies(); const accessToken = cookieStore.get("rzp_at")?.value ?? ""; - const brands = await fetchMyBrands(accessToken).catch(() => []); + + if (user.type === "ucr") { + const serviceId = getStringParam(resolvedParams, "id"); + const activeServiceCategoryId = + getStringParam(resolvedParams, "category") ?? + getStringParam(resolvedParams, "service_category_id"); + + if (!serviceId) { + const [servicesPage, brands, marketplaceFacets] = await Promise.all([ + fetchPublicServicesPage( + { + page: 1, + limit: 24, + ...(activeServiceCategoryId && { service_category_id: activeServiceCategoryId }), + }, + accessToken, + ).catch(() => ({ + services: [], + meta: { page: 1, limit: 24, total_count: 0, has_more: false }, + })), + fetchActiveBrands(accessToken).catch(() => []), + fetchMarketplaceFacets(accessToken).catch(() => ({ + service_categories: [], + brand_categories: [], + })), + ]); + const ownersById = await fetchServiceOwnersById(servicesPage.services, accessToken); + + return ( + + ); + } + + const service = await fetchServiceById(serviceId, accessToken).catch(() => null); + + if (!service || service.status !== "ACTIVE") { + redirect("/home"); + } + + const [brands, owner] = await Promise.all([ + fetchDetailedBrandsForServices(service.owner_id, accessToken), + fetchUserProfileById(service.owner_id, accessToken), + ]); + + if (!owner) { + redirect("/home"); + } + + return ( + + ); + } + + if (user.type !== "uso") { + redirect("/brands"); + } + + const serviceId = getStringParam(resolvedParams, "id"); + + const [myServices, serviceFromUrl, brands, serviceCategories] = await Promise.all([ + fetchMyServices(accessToken).catch(() => []), + serviceId ? fetchServiceById(serviceId, accessToken).catch(() => null) : null, + fetchMyBrands(accessToken).catch(() => []), + fetchServiceCategories(accessToken).catch(() => []), + ]); + + const services = + serviceFromUrl && !myServices.some((service) => service.id === serviceFromUrl.id) + ? [serviceFromUrl, ...myServices] + : myServices; + + // Fetch detailed brand info (with branches) for the service form branch selector const detailedBrands = await Promise.all( brands.map(async (brand) => { - const detailedBrand = await fetchBrandById(brand.id, accessToken).catch( - () => null, - ); - return detailedBrand ?? brand; + const detailed = await fetchBrandById(brand.id, accessToken).catch(() => null); + return detailed ?? brand; }), ); - return ; + return ( + + ); } diff --git a/src/app/(protected)/settings/page.tsx b/src/app/(protected)/settings/page.tsx index dbcf34b..a310e0e 100644 --- a/src/app/(protected)/settings/page.tsx +++ b/src/app/(protected)/settings/page.tsx @@ -1,4 +1,16 @@ +import type { Metadata } from "next"; import { ProtectedComingSoonRoute } from "@/components/organisms/protected-coming-soon-route"; +import { getMessages } from "@/i18n/config"; +import { getServerLocale } from "@/i18n/server"; + +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.dashboard.settings, + }; +} export default function SettingsPage() { return ; diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index e42d33a..0e443bd 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,8 +1,18 @@ +import type { Metadata } from "next"; import { AuthLayoutTemplate, ComingSoonPanel } from "@/components"; import { LocaleProvider } from "@/components/providers/locale-provider"; import { getMessages } from "@/i18n/config"; import { getServerLocale } from "@/i18n/server"; +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.navigation.aboutUs, + }; +} + export default async function AboutPage() { const locale = await getServerLocale(); const messages = getMessages(locale); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 618a681..fc54db8 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -1,10 +1,17 @@ import type { Metadata } from "next"; import { AuthLoginPanel } from "@/components"; +import { getMessages } from "@/i18n/config"; +import { getServerLocale } from "@/i18n/server"; import { redirectAuthenticatedUserFromAuthRoute } from "@/lib/protected-route"; -export const metadata: Metadata = { - title: "Login", -}; +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.auth.login.title, + }; +} export default async function LoginPage() { await redirectAuthenticatedUserFromAuthRoute(); diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index df22258..149a419 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -1,10 +1,17 @@ import type { Metadata } from "next"; import { AuthRegisterPanel } from "@/components"; +import { getMessages } from "@/i18n/config"; +import { getServerLocale } from "@/i18n/server"; import { redirectAuthenticatedUserFromAuthRoute } from "@/lib/protected-route"; -export const metadata: Metadata = { - title: "Register", -}; +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.auth.register.title, + }; +} export default async function RegisterPage() { await redirectAuthenticatedUserFromAuthRoute(); diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index 4f7f127..3cb2e6a 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -1,8 +1,18 @@ +import type { Metadata } from "next"; import { AuthLayoutTemplate, ComingSoonPanel } from "@/components"; import { LocaleProvider } from "@/components/providers/locale-provider"; import { getMessages } from "@/i18n/config"; import { getServerLocale } from "@/i18n/server"; +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.navigation.contactUs, + }; +} + export default async function ContactPage() { const locale = await getServerLocale(); const messages = getMessages(locale); diff --git a/src/app/globals.css b/src/app/globals.css index 39f41fe..8079a40 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,7 +6,7 @@ :root { color-scheme: light; - --general-border-radius: 0.35rem; + --general-border-radius: 0.65rem; --general-border-radius-2xs: calc(var(--general-border-radius) * 0.286); --general-border-radius-xs: calc(var(--general-border-radius) * 0.357); --general-border-radius-sm: calc(var(--general-border-radius) * 0.571); @@ -311,7 +311,7 @@ pre { color: var(--app-text-strong); font-size: clamp(2.6rem, 6vw, 4.5rem); font-weight: 800; - line-height: 1.0; + line-height: 1; letter-spacing: -0.05em; } @@ -891,7 +891,9 @@ pre { font-size: 1.3rem; font-weight: 400; color: var(--app-text-muted); - transition: transform 200ms ease, color 160ms ease; + transition: + transform 200ms ease, + color 160ms ease; display: inline-block; } diff --git a/src/app/home/layout.tsx b/src/app/home/layout.tsx index ef056f6..14802ab 100644 --- a/src/app/home/layout.tsx +++ b/src/app/home/layout.tsx @@ -10,7 +10,7 @@ export default async function HomeLayout({ const locale = await getServerLocale(); return ( - + {children} ); diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx index 638534e..ff5746a 100644 --- a/src/app/home/page.tsx +++ b/src/app/home/page.tsx @@ -1,5 +1,149 @@ -import { ProtectedComingSoonRoute } from "@/components/organisms/protected-coming-soon-route/protected-coming-soon-route"; +import { redirect } from "next/navigation"; +import { cookies } from "next/headers"; +import { requireProtectedRouteAccess } from "@/lib/protected-route"; +import { + fetchMyServices, + fetchPublicServices, +} from "@/lib/services-api"; +import { fetchActiveBrands, fetchMyBrands } from "@/lib/brands-api"; +import { emptyFavorites, fetchFavorites } from "@/lib/favorites-api"; +import { EMPTY_MARKETPLACE_HOME, fetchMarketplaceFacets, fetchMarketplaceHome } from "@/lib/marketplace-api"; +import { fetchUserProfileById } from "@/lib/users-api"; +import { UsoCalendarPage } from "@/components/organisms/uso-calendar-page"; +import { UcrMarketplacePage } from "@/components/organisms/ucr-marketplace-page"; +import type { Brand, PublicUserProfile } from "@/types"; +import type { Service } from "@/types/service"; -export default function HomeDashboardPage() { - return ; +type HomePageProps = { + searchParams?: Promise>; +}; + +export const dynamic = "force-dynamic"; + +function getSingleParam( + params: Record, + key: string, +): string | undefined { + const value = params[key]; + return Array.isArray(value) ? value[0] : value; +} + +async function fetchMarketplaceOwnersById( + brands: Brand[], + services: Service[], + accessToken: string, +): Promise> { + const ownerIds = [ + ...new Set( + [ + ...brands.map((brand) => brand.owner_id), + ...services.map((service) => service.owner_id), + ].filter(Boolean), + ), + ]; + + if (ownerIds.length === 0) return {}; + + const ownerEntries = await Promise.all( + ownerIds.map(async (ownerId) => { + const owner = await fetchUserProfileById(ownerId, accessToken); + return owner ? ([ownerId, owner] as const) : null; + }), + ); + + return Object.fromEntries( + ownerEntries.filter( + (entry): entry is readonly [string, PublicUserProfile] => entry !== null, + ), + ); +} + +export default async function HomeDashboardPage({ searchParams }: HomePageProps) { + const resolvedParams = await (searchParams ?? Promise.resolve({})); + const user = await requireProtectedRouteAccess("/home", resolvedParams); + + const cookieStore = await cookies(); + const accessToken = cookieStore.get("rzp_at")?.value ?? ""; + + if (user.type === "ucr") { + const activeServiceCategoryId = getSingleParam(resolvedParams, "service_category_id"); + const activeBrandCategoryId = getSingleParam(resolvedParams, "brand_category_id"); + const serviceFilters = activeServiceCategoryId + ? { service_category_id: activeServiceCategoryId, limit: 120 } + : { limit: 120 }; + const brandFilters = activeBrandCategoryId + ? { brand_category_id: activeBrandCategoryId } + : {}; + + const [marketplaceHome, marketplaceFacets, favorites] = await Promise.all([ + fetchMarketplaceHome(accessToken).catch(() => EMPTY_MARKETPLACE_HOME), + fetchMarketplaceFacets(accessToken).catch(() => ({ + service_categories: [], + brand_categories: [], + })), + fetchFavorites(accessToken).catch(() => emptyFavorites()), + ]); + const services = activeServiceCategoryId + ? await fetchPublicServices(serviceFilters, accessToken).catch(() => []) + : marketplaceHome.random_services; + const brands = activeBrandCategoryId + ? await fetchActiveBrands(accessToken, brandFilters).catch(() => []) + : marketplaceHome.recent_brands; + const ownersById = await fetchMarketplaceOwnersById( + [ + ...brands, + ...marketplaceHome.recent_brands, + ...marketplaceHome.recommended_brands, + ...marketplaceHome.top_rated_brands, + ...favorites.brands, + ...favorites.service_brands, + ], + [ + ...services, + ...marketplaceHome.random_services, + ...marketplaceHome.smart_services, + ...marketplaceHome.recent_services, + ...marketplaceHome.recommended_services, + ...marketplaceHome.top_rated_services, + ...favorites.services, + ], + accessToken, + ); + + return ( + + ); + } + + if (user.type !== "uso") { + redirect("/dashboard"); + } + + const [services, brands] = await Promise.all([ + fetchMyServices(accessToken).catch(() => []), + fetchMyBrands(accessToken).catch(() => []), + ]); + + return ( + + ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 81842cb..ef63d91 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -10,6 +10,7 @@ import { fontLinks, typographyStyle } from "@/theme/typography"; import "./globals.css"; import { Geist } from "next/font/google"; import { cn } from "@/lib/utils"; +import { APP_TITLE } from "@/lib/page-metadata"; const FAVICON_URL = "/reziphay-logo-default.svg"; const geist = Geist({subsets:['latin'],variable:'--font-sans'}); @@ -20,10 +21,10 @@ export async function generateMetadata(): Promise { const messages = getMessages(locale); return { - applicationName: "Reziphay Next App", + applicationName: APP_TITLE, title: { - default: "Reziphay Next App", - template: "%s | Reziphay Next App", + default: APP_TITLE, + template: `${APP_TITLE} - %s`, }, description: messages.metadata.description, authors: [{ name: "Vugar Safarzada" }], diff --git a/src/app/lib/page.tsx b/src/app/lib/page.tsx index 6897ce2..5342704 100644 --- a/src/app/lib/page.tsx +++ b/src/app/lib/page.tsx @@ -1,8 +1,13 @@ +import type { Metadata } from "next"; import { LocaleProvider } from "@/components/providers/locale-provider"; import { notFound } from "next/navigation"; import { ComponentLibraryPage } from "@/components"; import { getServerLocale } from "@/i18n/server"; +export const metadata: Metadata = { + title: "Component Library", +}; + export default async function LibPage() { if (process.env.NODE_ENV !== "development") { notFound(); diff --git a/src/app/questions/page.tsx b/src/app/questions/page.tsx index 2573773..c8d03bc 100644 --- a/src/app/questions/page.tsx +++ b/src/app/questions/page.tsx @@ -1,8 +1,18 @@ +import type { Metadata } from "next"; import { AuthLayoutTemplate, ComingSoonPanel } from "@/components"; import { LocaleProvider } from "@/components/providers/locale-provider"; import { getMessages } from "@/i18n/config"; import { getServerLocale } from "@/i18n/server"; +export async function generateMetadata(): Promise { + const locale = await getServerLocale(); + const messages = getMessages(locale); + + return { + title: messages.navigation.questions, + }; +} + export default async function QuestionsPage() { const locale = await getServerLocale(); const messages = getMessages(locale); diff --git a/src/components/atoms/alert-dialog/alert-dialog.tsx b/src/components/atoms/alert-dialog/alert-dialog.tsx index d5b884f..26337cb 100644 --- a/src/components/atoms/alert-dialog/alert-dialog.tsx +++ b/src/components/atoms/alert-dialog/alert-dialog.tsx @@ -168,7 +168,8 @@ export function AlertDialogTrigger({ } return ( - + ); } diff --git a/src/components/atoms/button/button.tsx b/src/components/atoms/button/button.tsx index 4d4ba02..dd71fc2 100644 --- a/src/components/atoms/button/button.tsx +++ b/src/components/atoms/button/button.tsx @@ -10,7 +10,8 @@ type ButtonVariant = | "ghost" | "destructive" | "link" - | "icon"; + | "icon" + | "unstyled"; type ButtonSize = "small" | "medium" | "large"; @@ -62,6 +63,30 @@ export function Button({ const iconSize = buttonIconSizes[size]; const isDisabled = disabled || isLoading; + if (variant === "unstyled") { + return ( + + ); + } + return ( + ) : null} ))} @@ -377,7 +379,8 @@ export const Combobox = forwardRef( const isActive = effectiveHighlightedIndex === index; return ( - + ); }) ) : ( diff --git a/src/components/atoms/image-crop-modal/image-crop-modal.module.css b/src/components/atoms/image-crop-modal/image-crop-modal.module.css deleted file mode 100644 index 56b5fdc..0000000 --- a/src/components/atoms/image-crop-modal/image-crop-modal.module.css +++ /dev/null @@ -1,101 +0,0 @@ -.overlay { - position: fixed; - inset: 0; - z-index: 200; - display: grid; - place-items: center; - padding: 1.5rem; - background: rgb(0 0 0 / 0.72); - backdrop-filter: blur(10px); -} - -.modal { - width: min(100%, 420px); - display: flex; - flex-direction: column; - background: var(--app-bg-surface-strong); - border: 1px solid var(--app-border-soft); - border-radius: var(--general-border-radius-5xl); - overflow: hidden; - box-shadow: 0 2rem 4rem var(--app-shadow-color-strong); -} - -.header { - padding: 1.25rem 1.5rem 1rem; -} - -.title { - margin: 0; - font-size: var(--font-size-base); - font-weight: 700; - color: var(--app-text-strong); -} - -.subtitle { - margin: 0.25rem 0 0; - font-size: var(--font-size-extra-small); - color: var(--app-text-muted); -} - -.viewport { - position: relative; - overflow: hidden; - background: #000; - cursor: grab; - flex-shrink: 0; - align-self: center; - max-width: 100%; - touch-action: none; - user-select: none; -} - -.viewport:active { - cursor: grabbing; -} - -.image { - position: absolute; - top: 50%; - left: 50%; - max-width: none; - max-height: none; - transform-origin: center; - user-select: none; - pointer-events: none; - will-change: transform, width, height; -} - -.imageDragging { - cursor: grabbing; -} - -.zoomRow { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.875rem 1.5rem; - border-top: 1px solid var(--app-border-soft); -} - -.zoomLabel { - font-size: var(--font-size-extra-small); - color: var(--app-text-muted); - white-space: nowrap; - min-width: 2.5rem; -} - -.slider { - flex: 1; - accent-color: var(--app-primary); - cursor: pointer; -} - -.footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.75rem; - padding: 1rem 1.5rem; - border-top: 1px solid var(--app-border-soft); - background: var(--app-bg-surface); -} diff --git a/src/components/atoms/image-crop-modal/image-crop-modal.tsx b/src/components/atoms/image-crop-modal/image-crop-modal.tsx deleted file mode 100644 index 5f5bcbf..0000000 --- a/src/components/atoms/image-crop-modal/image-crop-modal.tsx +++ /dev/null @@ -1,328 +0,0 @@ -"use client"; - -import { - useEffect, - useMemo, - useRef, - useState, - type ChangeEvent, - type PointerEvent as ReactPointerEvent, -} from "react"; -import { createPortal } from "react-dom"; -import { Button } from "@/components/atoms/button"; -import styles from "./image-crop-modal.module.css"; - -type ImageCropModalProps = { - file: File; - aspectRatio: "1:1" | "16:9"; - onCrop: (croppedFile: File) => void; - onCancel: () => void; -}; - -type Offset = { - x: number; - y: number; -}; - -const VIEWPORT_WIDTH = 360; -const OUTPUT = { - "1:1": { width: 400, height: 400 }, - "16:9": { width: 1280, height: 720 }, -} as const; -const MIN_ZOOM = 1; -const MAX_ZOOM = 3; -const ZOOM_STEP = 0.01; - -function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); -} - -function getViewportHeight(aspectRatio: ImageCropModalProps["aspectRatio"]) { - return aspectRatio === "1:1" - ? VIEWPORT_WIDTH - : Math.round((VIEWPORT_WIDTH * 9) / 16); -} - -function getImageNaturalSize(image: HTMLImageElement) { - return { - width: image.naturalWidth || 1, - height: image.naturalHeight || 1, - }; -} - -function getDisplaySize( - viewportWidth: number, - viewportHeight: number, - imageWidth: number, - imageHeight: number, - zoom: number, -) { - const baseScale = Math.max( - viewportWidth / imageWidth, - viewportHeight / imageHeight, - ); - - return { - width: imageWidth * baseScale * zoom, - height: imageHeight * baseScale * zoom, - scale: baseScale * zoom, - }; -} - -function constrainOffset( - nextOffset: Offset, - viewportWidth: number, - viewportHeight: number, - imageWidth: number, - imageHeight: number, - zoom: number, -) { - const displaySize = getDisplaySize( - viewportWidth, - viewportHeight, - imageWidth, - imageHeight, - zoom, - ); - const maxX = Math.max(0, (displaySize.width - viewportWidth) / 2); - const maxY = Math.max(0, (displaySize.height - viewportHeight) / 2); - - return { - x: clamp(nextOffset.x, -maxX, maxX), - y: clamp(nextOffset.y, -maxY, maxY), - }; -} - -export function ImageCropModal({ - file, - aspectRatio, - onCrop, - onCancel, -}: ImageCropModalProps) { - const viewportHeight = getViewportHeight(aspectRatio); - const outputSize = OUTPUT[aspectRatio]; - const viewportRef = useRef(null); - const imageRef = useRef(null); - const canvasRef = useRef(null); - const pointerStartRef = useRef<{ - pointerId: number; - startX: number; - startY: number; - offset: Offset; - } | null>(null); - const [src] = useState(() => URL.createObjectURL(file)); - const [zoom, setZoom] = useState(MIN_ZOOM); - const [offset, setOffset] = useState({ x: 0, y: 0 }); - const [imageSize, setImageSize] = useState({ width: 1, height: 1 }); - const [isDragging, setIsDragging] = useState(false); - - useEffect(() => () => URL.revokeObjectURL(src), [src]); - - const displaySize = useMemo( - () => - getDisplaySize( - VIEWPORT_WIDTH, - viewportHeight, - imageSize.width, - imageSize.height, - zoom, - ), - [imageSize.height, imageSize.width, viewportHeight, zoom], - ); - - function handleImageLoad() { - if (!imageRef.current) { - return; - } - - setImageSize(getImageNaturalSize(imageRef.current)); - setOffset({ x: 0, y: 0 }); - setZoom(MIN_ZOOM); - } - - function handlePointerDown(event: ReactPointerEvent) { - pointerStartRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - offset, - }; - setIsDragging(true); - event.currentTarget.setPointerCapture(event.pointerId); - } - - function handlePointerMove(event: ReactPointerEvent) { - const state = pointerStartRef.current; - - if (!state || state.pointerId !== event.pointerId) { - return; - } - - const nextOffset = constrainOffset( - { - x: state.offset.x + (event.clientX - state.startX), - y: state.offset.y + (event.clientY - state.startY), - }, - VIEWPORT_WIDTH, - viewportHeight, - imageSize.width, - imageSize.height, - zoom, - ); - - setOffset(nextOffset); - } - - function handlePointerUp(event: ReactPointerEvent) { - if (pointerStartRef.current?.pointerId !== event.pointerId) { - return; - } - - pointerStartRef.current = null; - setIsDragging(false); - event.currentTarget.releasePointerCapture(event.pointerId); - } - - function handleZoomChange(nextZoom: number) { - const constrainedOffset = constrainOffset( - offset, - VIEWPORT_WIDTH, - viewportHeight, - imageSize.width, - imageSize.height, - nextZoom, - ); - - setZoom(nextZoom); - setOffset(constrainedOffset); - } - - function handleZoomInput(event: ChangeEvent) { - handleZoomChange(parseFloat(event.target.value)); - } - - function handleCrop() { - const image = imageRef.current; - const canvas = canvasRef.current; - - if (!image || !canvas) { - return; - } - - canvas.width = outputSize.width; - canvas.height = outputSize.height; - - const ctx = canvas.getContext("2d"); - if (!ctx) { - return; - } - - const left = (VIEWPORT_WIDTH - displaySize.width) / 2 + offset.x; - const top = (viewportHeight - displaySize.height) / 2 + offset.y; - const srcX = -left / displaySize.scale; - const srcY = -top / displaySize.scale; - const srcWidth = VIEWPORT_WIDTH / displaySize.scale; - const srcHeight = viewportHeight / displaySize.scale; - - ctx.drawImage( - image, - srcX, - srcY, - srcWidth, - srcHeight, - 0, - 0, - outputSize.width, - outputSize.height, - ); - - canvas.toBlob( - (blob) => { - if (!blob) { - return; - } - - const baseName = file.name.replace(/\.[^.]+$/, ""); - onCrop(new File([blob], `${baseName}.jpg`, { type: "image/jpeg" })); - }, - "image/jpeg", - 0.92, - ); - } - - const portal = typeof document !== "undefined" ? document.body : null; - if (!portal) { - return null; - } - - return createPortal( -
{ - if (event.target === event.currentTarget) { - onCancel(); - } - }} - > -
-
-

Crop Image

-

- {aspectRatio === "1:1" ? "Square 1:1" : "Widescreen 16:9"} - drag to - reposition, slider to zoom -

-
- -
- {/* eslint-disable-next-line @next/next/no-img-element */} - -
- -
- Zoom - -
- -
- - -
- - -
-
, - portal, - ); -} diff --git a/src/components/atoms/input/index.ts b/src/components/atoms/input/index.ts index f790fd0..05b9d3c 100644 --- a/src/components/atoms/input/index.ts +++ b/src/components/atoms/input/index.ts @@ -6,3 +6,6 @@ export { Input, PasswordInput, } from "./input"; +export type { + InputProps, +} from "./input"; diff --git a/src/components/atoms/input/input.tsx b/src/components/atoms/input/input.tsx index 2b3e5aa..b4b8d14 100644 --- a/src/components/atoms/input/input.tsx +++ b/src/components/atoms/input/input.tsx @@ -7,6 +7,7 @@ import { type InputHTMLAttributes, type LabelHTMLAttributes, } from "react"; +import { Button } from "@/components/atoms/button"; import { Eye, EyeOff } from "lucide-react"; import styles from "./input.module.css"; @@ -20,7 +21,7 @@ type FieldDescriptionProps = HTMLAttributes; type FieldContentProps = HTMLAttributes; -type InputProps = InputHTMLAttributes; +export type InputProps = InputHTMLAttributes; type PasswordInputProps = Omit & { showPasswordLabel: string; hidePasswordLabel: string; @@ -107,7 +108,8 @@ export const PasswordInput = forwardRef( disabled={disabled} {...props} /> - + ); }, diff --git a/src/components/atoms/social-icon/social-icon.tsx b/src/components/atoms/social-icon/social-icon.tsx new file mode 100644 index 0000000..f2640ba --- /dev/null +++ b/src/components/atoms/social-icon/social-icon.tsx @@ -0,0 +1,82 @@ +type SocialPlatform = + | "instagram" + | "facebook" + | "youtube" + | "whatsapp" + | "linkedin" + | "x" + | "website"; + +type SocialIconProps = { + platform: SocialPlatform; + size?: number; + className?: string; +}; + +export const SOCIAL_COLORS: Record = { + instagram: "#E1306C", + facebook: "#1877F2", + youtube: "#FF0000", + whatsapp: "#25D366", + linkedin: "#0A66C2", + x: "#000000", + website: "#6B7280", +}; + +export function SocialIcon({ platform, size = 20, className }: SocialIconProps) { + const shared = { + width: size, + height: size, + viewBox: "0 0 24 24", + fill: "currentColor", + className, + "aria-hidden": true as const, + }; + + switch (platform) { + case "instagram": + return ( + + + + ); + case "facebook": + return ( + + + + ); + case "youtube": + return ( + + + + ); + case "whatsapp": + return ( + + + + ); + case "linkedin": + return ( + + + + ); + case "x": + return ( + + + + ); + case "website": + return ( + + + + + + ); + } +} diff --git a/src/components/icon.tsx b/src/components/icon.tsx index edf9973..139dca2 100644 --- a/src/components/icon.tsx +++ b/src/components/icon.tsx @@ -1,4 +1,5 @@ import { + Archive, ArrowLeft, ArrowRight, BadgeCheck, @@ -6,7 +7,9 @@ import { Bookmark, CalendarCheck, Check, + Clock, ChevronDown, + ChevronLeft, ChevronRight, ChevronsUpDown, CircleAlert, @@ -15,24 +18,36 @@ import { CircleUser, ConciergeBell, Download, + Eye, Gavel, Heart, Home, + ImagePlus, + Info, LayoutDashboard, + ListChecks, + ListFilter, LoaderCircle, LogOut, + MapPin, Menu, Minus, + MoreHorizontal, PanelLeftOpen, + PanelRightOpen, + Pause, Pencil, + Play, Plus, RefreshCw, Save, Scale, Search, + Send, Settings, Share2, SquarePen, + Star, Store, Tag, Trash2, @@ -75,13 +90,19 @@ const iconMap: Record = { account_circle: CircleUser, account_tree: Store, add: Plus, + add_photo_alternate: ImagePlus, + archive: Archive, arrow_back: ArrowLeft, arrow_forward: ArrowRight, autorenew: RefreshCw, bookmark: Bookmark, check: Check, check_circle: CircleCheck, + chevron_left: ChevronLeft, chevron_right: ChevronRight, + design_services: ConciergeBell, + filter_list: ListFilter, + more_horiz: MoreHorizontal, close: X, dashboard: LayoutDashboard, delete: Trash2, @@ -95,23 +116,34 @@ const iconMap: Record = { gavel: Gavel, help: CircleHelp, home: Home, + info: Info, left_panel_open: PanelLeftOpen, + location_on: MapPin, + right_panel_open: PanelRightOpen, logout: LogOut, menu: Menu, notifications: Bell, + pause: Pause, person: User, + play_arrow: Play, + schedule: Clock, progress_activity: LoaderCircle, remove: Minus, room_service: ConciergeBell, save: Save, search: Search, sell: Tag, + send: Send, settings: Settings, share: Share2, + star: Star, + store: Store, unfold_more: ChevronsUpDown, verified: BadgeCheck, + visibility: Eye, warning: TriangleAlert, // aliases + rule: ListChecks, scale: Scale, }; @@ -119,6 +151,7 @@ export function Icon({ icon, size = 16, color = "black", + fill = false, className, }: IconProps) { const LucideIconComponent = iconMap[icon]; @@ -132,6 +165,7 @@ export function Icon({ aria-hidden="true" size={size} color={iconColorMap[color]} + fill={fill ? "currentColor" : "none"} className={className} /> ); diff --git a/src/components/logo.tsx b/src/components/logo.tsx index 616c51d..6cefb38 100644 --- a/src/components/logo.tsx +++ b/src/components/logo.tsx @@ -2,6 +2,8 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; +import { Button } from "@/components/atoms/button"; +import { useLocale } from "@/components/providers/locale-provider"; const DEFAULT_LOGO_SRC = "/reziphay-logo-default.svg"; const HOVER_LOGO_SRC = "/reziphay-logo-hover.svg"; @@ -13,13 +15,15 @@ type LogoProps = { export function Logo({ size = 56, priority = false }: LogoProps) { const router = useRouter(); + const { messages } = useLocale(); const defaultLoading = priority ? "eager" : "lazy"; return ( - + ); } diff --git a/src/components/molecules/avatar-crop-dialog/avatar-crop-dialog.module.css b/src/components/molecules/avatar-crop-dialog/avatar-crop-dialog.module.css index 5661139..24a1ab3 100644 --- a/src/components/molecules/avatar-crop-dialog/avatar-crop-dialog.module.css +++ b/src/components/molecules/avatar-crop-dialog/avatar-crop-dialog.module.css @@ -118,6 +118,11 @@ cursor: grabbing; } +.cropAreaWide { + width: min(100%, 28rem); + aspect-ratio: 16 / 9; +} + .cropGrid { position: absolute; inset: 0; diff --git a/src/components/molecules/avatar-crop-dialog/avatar-crop-dialog.tsx b/src/components/molecules/avatar-crop-dialog/avatar-crop-dialog.tsx index 67c9d8f..28e1f7f 100644 --- a/src/components/molecules/avatar-crop-dialog/avatar-crop-dialog.tsx +++ b/src/components/molecules/avatar-crop-dialog/avatar-crop-dialog.tsx @@ -19,20 +19,25 @@ import { useLocale } from "@/components/providers/locale-provider"; import styles from "./avatar-crop-dialog.module.css"; type AvatarCropDialogProps = { - imageName: string; - imageSrc: string | null; open: boolean; onClose: () => void; onConfirm: (file: File) => Promise | void; + aspectRatio?: "1:1" | "16:9"; + // Profile avatar variant — pass imageSrc + imageName + imageName?: string; + imageSrc?: string | null; onChooseDifferentPicture?: () => void; + // General variant — pass file directly (URL lifecycle managed internally) + file?: File; }; -type Offset = { - x: number; - y: number; -}; +type Offset = { x: number; y: number }; + +const OUTPUT = { + "1:1": { width: 512, height: 512 }, + "16:9": { width: 1280, height: 720 }, +} as const; -const outputSize = 512; const minimumZoom = 1; const maximumZoom = 3; const zoomStep = 0.01; @@ -49,13 +54,13 @@ function getImageNaturalSize(image: HTMLImageElement) { } function getDisplaySize( - frameSize: number, + frameWidth: number, + frameHeight: number, imageWidth: number, imageHeight: number, zoom: number, ) { - const baseScale = Math.max(frameSize / imageWidth, frameSize / imageHeight); - + const baseScale = Math.max(frameWidth / imageWidth, frameHeight / imageHeight); return { width: imageWidth * baseScale * zoom, height: imageHeight * baseScale * zoom, @@ -64,58 +69,40 @@ function getDisplaySize( function constrainOffset( nextOffset: Offset, - frameSize: number, + frameWidth: number, + frameHeight: number, imageWidth: number, imageHeight: number, zoom: number, -) { - const displaySize = getDisplaySize(frameSize, imageWidth, imageHeight, zoom); - const maxX = Math.max(0, (displaySize.width - frameSize) / 2); - const maxY = Math.max(0, (displaySize.height - frameSize) / 2); - +): Offset { + const displaySize = getDisplaySize(frameWidth, frameHeight, imageWidth, imageHeight, zoom); + const maxX = Math.max(0, (displaySize.width - frameWidth) / 2); + const maxY = Math.max(0, (displaySize.height - frameHeight) / 2); return { x: clamp(nextOffset.x, -maxX, maxX), y: clamp(nextOffset.y, -maxY, maxY), }; } -function makeAvatarFileName(imageName: string) { - const baseName = imageName.replace(/\.[^/.]+$/, "").trim() || "avatar"; - return `${baseName}-avatar.png`; -} - -function canvasToFile(canvas: HTMLCanvasElement, fileName: string) { - return new Promise((resolve, reject) => { - canvas.toBlob( - (blob) => { - if (!blob) { - reject(new Error("AVATAR_CROP_EXPORT_FAILED")); - return; - } - - resolve( - new File([blob], fileName, { - type: "image/png", - lastModified: Date.now(), - }), - ); - }, - "image/png", - 0.95, - ); - }); +function makeOutputFileName(name: string, aspectRatio: "1:1" | "16:9") { + const base = name.replace(/\.[^/.]+$/, "").trim() || "image"; + const ext = aspectRatio === "1:1" ? "png" : "jpg"; + return `${base}-crop.${ext}`; } export function AvatarCropDialog({ - imageName, - imageSrc, open, onClose, onConfirm, + aspectRatio = "1:1", + imageName, + imageSrc, onChooseDifferentPicture, + file, }: AvatarCropDialogProps) { const { messages } = useLocale(); const p = messages.profile; + const cropFrameRef = useRef(null); const imageRef = useRef(null); const pointerStartRef = useRef<{ @@ -124,12 +111,25 @@ export function AvatarCropDialog({ startY: number; offset: Offset; } | null>(null); + const [zoom, setZoom] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [imageSize, setImageSize] = useState({ width: 1, height: 1 }); const [isDragging, setIsDragging] = useState(false); const [isProcessing, setIsProcessing] = useState(false); + // Manage object URL when a File is passed directly + const [fileSrc, setFileSrc] = useState(null); + useEffect(() => { + if (!file) { setFileSrc(null); return; } + const url = URL.createObjectURL(file); + setFileSrc(url); + return () => URL.revokeObjectURL(url); + }, [file]); + + const effectiveSrc = file ? fileSrc : (imageSrc ?? null); + const effectiveName = file ? (file.name) : (imageName ?? "image"); + useEffect(() => { if (!open) { setZoom(1); @@ -138,29 +138,27 @@ export function AvatarCropDialog({ setIsProcessing(false); pointerStartRef.current = null; } - }, [open, imageSrc]); + }, [open, effectiveSrc]); const zoomLabel = useMemo(() => `${Math.round(zoom * 100)}%`, [zoom]); - function getFrameSize() { - return cropFrameRef.current?.clientWidth ?? outputSize; + function getFrameDimensions() { + const el = cropFrameRef.current; + return { + width: el?.clientWidth ?? OUTPUT[aspectRatio].width, + height: el?.clientHeight ?? OUTPUT[aspectRatio].height, + }; } function handleImageLoad() { - if (!imageRef.current) { - return; - } - + if (!imageRef.current) return; setImageSize(getImageNaturalSize(imageRef.current)); setOffset({ x: 0, y: 0 }); setZoom(1); } function handlePointerDown(event: ReactPointerEvent) { - if (!imageSrc || isProcessing) { - return; - } - + if (!effectiveSrc || isProcessing) return; pointerStartRef.current = { pointerId: event.pointerId, startX: event.clientX, @@ -173,46 +171,39 @@ export function AvatarCropDialog({ function handlePointerMove(event: ReactPointerEvent) { const state = pointerStartRef.current; - - if (!state || state.pointerId !== event.pointerId) { - return; - } - - const frameSize = getFrameSize(); + if (!state || state.pointerId !== event.pointerId) return; + const { width, height } = getFrameDimensions(); const nextOffset = constrainOffset( { x: state.offset.x + (event.clientX - state.startX), y: state.offset.y + (event.clientY - state.startY), }, - frameSize, + width, + height, imageSize.width, imageSize.height, zoom, ); - setOffset(nextOffset); } function handlePointerUp(event: ReactPointerEvent) { - if (pointerStartRef.current?.pointerId !== event.pointerId) { - return; - } - + if (pointerStartRef.current?.pointerId !== event.pointerId) return; pointerStartRef.current = null; setIsDragging(false); event.currentTarget.releasePointerCapture(event.pointerId); } function handleZoomChange(nextZoom: number) { - const frameSize = getFrameSize(); + const { width, height } = getFrameDimensions(); const constrainedOffset = constrainOffset( offset, - frameSize, + width, + height, imageSize.width, imageSize.height, nextZoom, ); - setZoom(nextZoom); setOffset(constrainedOffset); } @@ -223,67 +214,75 @@ export function AvatarCropDialog({ minimumZoom, maximumZoom, ); - handleZoomChange(nextZoom); } async function handleConfirm() { - if (!imageSrc || !imageRef.current || !cropFrameRef.current) { - return; - } - + if (!effectiveSrc || !imageRef.current || !cropFrameRef.current) return; setIsProcessing(true); - try { - const frameSize = cropFrameRef.current.clientWidth; - const displaySize = getDisplaySize( - frameSize, - imageSize.width, - imageSize.height, - zoom, - ); - const ratio = outputSize / frameSize; - const canvas = document.createElement("canvas"); - canvas.width = outputSize; - canvas.height = outputSize; + const { width: frameWidth, height: frameHeight } = getFrameDimensions(); + const output = OUTPUT[aspectRatio]; + const displaySize = getDisplaySize(frameWidth, frameHeight, imageSize.width, imageSize.height, zoom); + const ratioX = output.width / frameWidth; + const ratioY = output.height / frameHeight; - const context = canvas.getContext("2d"); + const canvas = document.createElement("canvas"); + canvas.width = output.width; + canvas.height = output.height; - if (!context) { - throw new Error("AVATAR_CROP_EXPORT_FAILED"); - } + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("CROP_EXPORT_FAILED"); - context.imageSmoothingEnabled = true; - context.imageSmoothingQuality = "high"; + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; - context.drawImage( + ctx.drawImage( imageRef.current, - ((outputSize - displaySize.width * ratio) / 2) + offset.x * ratio, - ((outputSize - displaySize.height * ratio) / 2) + offset.y * ratio, - displaySize.width * ratio, - displaySize.height * ratio, + ((output.width - displaySize.width * ratioX) / 2) + offset.x * ratioX, + ((output.height - displaySize.height * ratioY) / 2) + offset.y * ratioY, + displaySize.width * ratioX, + displaySize.height * ratioY, ); - const file = await canvasToFile(canvas, makeAvatarFileName(imageName)); - await onConfirm(file); + const mimeType = aspectRatio === "1:1" ? "image/png" : "image/jpeg"; + const quality = aspectRatio === "1:1" ? 0.95 : 0.92; + const fileName = makeOutputFileName(effectiveName, aspectRatio); + + await new Promise((resolve, reject) => { + canvas.toBlob( + async (blob) => { + if (!blob) { reject(new Error("CROP_EXPORT_FAILED")); return; } + const outFile = new File([blob], fileName, { type: mimeType, lastModified: Date.now() }); + try { await onConfirm(outFile); resolve(); } + catch (e) { reject(e); } + }, + mimeType, + quality, + ); + }); + onClose(); } finally { setIsProcessing(false); } } + const isWide = aspectRatio === "16:9"; + return ( !nextOpen && onClose()}>
- + {p.cropPhotoTitle} @@ -297,18 +296,18 @@ export function AvatarCropDialog({
- {imageSrc ? ( + {effectiveSrc ? ( // eslint-disable-next-line @next/next/no-img-element {p.photoAlt}
- + handleZoomChange(Number(event.target.value))} /> - +
- + {onChooseDifferentPicture && ( + + )}
@@ -379,7 +383,7 @@ export function AvatarCropDialog({ + {isOpen ? ( @@ -138,7 +141,8 @@ export function LanguageSwitcher({ className={joinClassNames(styles.compact, className)} data-open={isOpen ? "" : undefined} > - + {isOpen ? ( 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 ( + + ); + })} +
+ ) : ( + + )} +
+ ) : 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 ? ( +
+ {name} +
+ ) : ( + + )} +
+ +
+ {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 ? ( +
+ ); +} 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 ( +
+
+ + + + + + +
+ + + + + +
+ +
+ {COLORS.map((c) => ( +
+
+ + +
+ ); +} 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} + +
  • + ); + })} +
+ )} + +
+ { + setInput(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + autoComplete="off" + spellCheck={false} + /> + +
+ + {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 ( - + ); })}
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 ? ( - + ) : 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 ? ( + + ) : 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} +
+ )} + +
+ + +
+ + {queueLoading ? ( +
+ {[1, 2, 3].map((n) => ( +
+ ))} +
+ ) : queueError ? ( + {queueError} + ) : filteredQueue.length === 0 ? ( +
+

{t.queueEmpty}

+
+ ) : ( +
+ + + + + + {activeTab === "service" && } + + + + + + + + + {activeTab === "service" && } + + + + + + {filteredQueue.map((item) => ( + + + + + {activeTab === "service" && ( + + )} + + + + ))} + +
{t.colOwner}{t.colName}{t.categoryLabel}{addressLabel}{t.colSubmitted}{t.colAction}
{renderOwnerCell(item)}{item.title}{getCategoryLabel(item)}{item.address || "—"}{formatDate(item.created_at)} +
+ +
+
+
+ )} +
+ ); + } + + // ── Detail / Review View ────────────────────────────────────────────────── + + const entityTitle = + detail?.type === "brand" + ? detail.data.name + : detail?.type === "service" + ? detail.data.title + : "…"; + + const decisionAction = ( + + ); + + return ( +
+ {showDecisionModal && ( +
+
+

{decisionLabel}

+

{t.approveConfirmDescription}

+ + {checklist.length > 0 && ( +
+

{t.checklistTitle}

+ {checklist.map((item) => ( + + ))} +
+ )} + +