dev #21
1
global.d.ts
vendored
Normal file
1
global.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare module '*.css';
|
||||||
1759
package-lock.json
generated
1759
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -6,7 +6,9 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
@ -54,18 +56,22 @@
|
|||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@eslint/js": "^9.0.1",
|
"@eslint/js": "^9.0.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/node": "^25",
|
"@types/node": "^25",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.3.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postcss-preset-env": "^11.1.3",
|
"postcss-preset-env": "^11.1.3",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
scripts/check-translations.mjs
Normal file
25
scripts/check-translations.mjs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
|
const enRaw = readFileSync('src/app/i18n/translations/en.ts', 'utf8');
|
||||||
|
const deRaw = readFileSync('src/app/i18n/translations/de.ts', 'utf8');
|
||||||
|
|
||||||
|
// Extract all "key": "value" pairs
|
||||||
|
const kvRegex = /"(k[0-9a-f]+)": "([^"\\]*(\\.[^"\\]*)*)"/g;
|
||||||
|
|
||||||
|
const en = {};
|
||||||
|
const de = {};
|
||||||
|
let m;
|
||||||
|
while ((m = kvRegex.exec(enRaw)) !== null) en[m[1]] = m[2];
|
||||||
|
kvRegex.lastIndex = 0;
|
||||||
|
while ((m = kvRegex.exec(deRaw)) !== null) de[m[1]] = m[2];
|
||||||
|
|
||||||
|
// Find keys in both with identical values
|
||||||
|
const same = Object.keys(en).filter(k => de[k] !== undefined && en[k] === de[k]);
|
||||||
|
console.log(`Keys with identical en/de values (${same.length} total):`);
|
||||||
|
same.forEach(k => console.log(` ${k}: ${JSON.stringify(en[k])}`));
|
||||||
|
|
||||||
|
// Find keys in en that look German (contain typical German words or chars)
|
||||||
|
const germanPattern = /\b(den|die|das|der|und|nicht|Sie|Ihr|Bitte|werden|wurde|kein|eine|einem|ist|sind|können|bitte|beim|durch|für|mit|Keine|Alle|Alle|beim|oder)\b/;
|
||||||
|
const germanInEn = Object.keys(en).filter(k => germanPattern.test(en[k]));
|
||||||
|
console.log(`\nKeys in en.ts that look German (${germanInEn.length} total):`);
|
||||||
|
germanInEn.forEach(k => console.log(` ${k}: ${JSON.stringify(en[k])}`));
|
||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import {
|
import {
|
||||||
AcademicCapIcon,
|
AcademicCapIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@ -206,6 +209,7 @@ const footerNavigation = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AboutUsPage() {
|
export default function AboutUsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gray-900 pb-24 sm:pb-32">
|
<div className="bg-gray-900 pb-24 sm:pb-32">
|
||||||
@ -227,7 +231,7 @@ export default function AboutUsPage() {
|
|||||||
{/* Header section */}
|
{/* Header section */}
|
||||||
<div className="px-6 pt-14 lg:px-8">
|
<div className="px-6 pt-14 lg:px-8">
|
||||||
<div className="mx-auto max-w-2xl pt-24 text-center sm:pt-40">
|
<div className="mx-auto max-w-2xl pt-24 text-center sm:pt-40">
|
||||||
<h1 className="text-5xl font-semibold tracking-tight text-white sm:text-7xl">We are a community</h1>
|
<h1 className="text-5xl font-semibold tracking-tight text-white sm:text-7xl">{t('autofix.kbd979e13')}</h1>
|
||||||
<p className="mt-8 text-lg font-medium text-pretty text-gray-400 sm:text-xl/8">
|
<p className="mt-8 text-lg font-medium text-pretty text-gray-400 sm:text-xl/8">
|
||||||
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
|
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
|
||||||
fugiat veniam occaecat fugiat.
|
fugiat veniam occaecat fugiat.
|
||||||
@ -288,7 +292,7 @@ export default function AboutUsPage() {
|
|||||||
{/* Feature section */}
|
{/* Feature section */}
|
||||||
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
|
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
|
||||||
<div className="mx-auto max-w-2xl lg:mx-0">
|
<div className="mx-auto max-w-2xl lg:mx-0">
|
||||||
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">Our values</h2>
|
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">{t('autofix.kf0646f35')}</h2>
|
||||||
<p className="mt-6 text-lg/8 text-gray-300">
|
<p className="mt-6 text-lg/8 text-gray-300">
|
||||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste
|
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste
|
||||||
dolor cupiditate blanditiis.
|
dolor cupiditate blanditiis.
|
||||||
@ -310,7 +314,7 @@ export default function AboutUsPage() {
|
|||||||
{/* Team section */}
|
{/* Team section */}
|
||||||
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
|
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
|
||||||
<div className="mx-auto max-w-2xl lg:mx-0">
|
<div className="mx-auto max-w-2xl lg:mx-0">
|
||||||
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">Our team</h2>
|
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">{t('autofix.k3777e830')}</h2>
|
||||||
<p className="mt-6 text-lg/8 text-gray-400">
|
<p className="mt-6 text-lg/8 text-gray-400">
|
||||||
We’re a dynamic group of individuals who are passionate about what we do and dedicated to delivering the
|
We’re a dynamic group of individuals who are passionate about what we do and dedicated to delivering the
|
||||||
best results for our clients.
|
best results for our clients.
|
||||||
@ -345,9 +349,7 @@ export default function AboutUsPage() {
|
|||||||
className="h-96 w-full flex-none rounded-2xl object-cover shadow-xl lg:aspect-square lg:h-auto lg:max-w-sm"
|
className="h-96 w-full flex-none rounded-2xl object-cover shadow-xl lg:aspect-square lg:h-auto lg:max-w-sm"
|
||||||
/>
|
/>
|
||||||
<div className="w-full flex-auto">
|
<div className="w-full flex-auto">
|
||||||
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">
|
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">{t('autofix.k5ef19112')}</h2>
|
||||||
Join our team
|
|
||||||
</h2>
|
|
||||||
<p className="mt-6 text-lg/8 text-pretty text-gray-400">
|
<p className="mt-6 text-lg/8 text-pretty text-gray-400">
|
||||||
Lorem ipsum dolor sit amet consect adipisicing elit. Possimus magnam voluptatum cupiditate veritatis
|
Lorem ipsum dolor sit amet consect adipisicing elit. Possimus magnam voluptatum cupiditate veritatis
|
||||||
in accusamus quisquam.
|
in accusamus quisquam.
|
||||||
@ -364,9 +366,7 @@ export default function AboutUsPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-10 flex">
|
<div className="mt-10 flex">
|
||||||
<a href="#" className="text-sm/6 font-semibold text-indigo-400 hover:text-indigo-300">
|
<a href="#" className="text-sm/6 font-semibold text-indigo-400 hover:text-indigo-300">{t('autofix.k81b056f2')}<span aria-hidden="true">→</span>
|
||||||
See our job postings
|
|
||||||
<span aria-hidden="true">→</span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import Cropper from 'react-easy-crop'
|
import Cropper from 'react-easy-crop'
|
||||||
import { Point, Area } from 'react-easy-crop'
|
import { Point, Area } from 'react-easy-crop'
|
||||||
@ -11,6 +14,7 @@ interface AffiliateCropModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AffiliateCropModal({ isOpen, imageSrc, onClose, onCropComplete }: AffiliateCropModalProps) {
|
export default function AffiliateCropModal({ isOpen, imageSrc, onClose, onCropComplete }: AffiliateCropModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
||||||
@ -70,7 +74,7 @@ export default function AffiliateCropModal({ isOpen, imageSrc, onClose, onCropCo
|
|||||||
<div className="relative w-full max-w-4xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
|
<div className="relative w-full max-w-4xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-white">
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-white">
|
||||||
<h2 className="text-xl font-semibold text-blue-900">Crop Affiliate Logo</h2>
|
<h2 className="text-xl font-semibold text-blue-900">{t('autofix.kcf4ba87d')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-500 hover:text-gray-700 transition"
|
className="text-gray-500 hover:text-gray-700 transition"
|
||||||
@ -120,9 +124,7 @@ export default function AffiliateCropModal({ isOpen, imageSrc, onClose, onCropCo
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
||||||
>
|
>{t('autofix.kef1656df')}</button>
|
||||||
Apply Crop
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import Header from '../../components/nav/Header'
|
import Header from '../../components/nav/Header'
|
||||||
import Footer from '../../components/Footer'
|
import Footer from '../../components/Footer'
|
||||||
@ -40,6 +43,7 @@ const AFFILIATE_CATEGORIES = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export default function AffiliateManagementPage() {
|
export default function AffiliateManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const isAdmin = !!user && (
|
const isAdmin = !!user && (
|
||||||
@ -133,9 +137,7 @@ export default function AffiliateManagementPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
className="mt-2 text-sm text-red-600 hover:text-red-800 font-medium"
|
className="mt-2 text-sm text-red-600 hover:text-red-800 font-medium"
|
||||||
>
|
>{t('autofix.k3b7dd87a')}</button>
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -143,20 +145,14 @@ export default function AffiliateManagementPage() {
|
|||||||
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-6 md:py-10 px-4 md:px-8 rounded-2xl shadow-lg mb-6 md:mb-8">
|
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-6 md:py-10 px-4 md:px-8 rounded-2xl shadow-lg mb-6 md:mb-8">
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 md:gap-6 mb-4 md:mb-6">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 md:gap-6 mb-4 md:mb-6">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl md:text-4xl font-extrabold text-blue-900 tracking-tight">
|
<h1 className="text-2xl md:text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.k0fe28e0b')}</h1>
|
||||||
Affiliate Management
|
<p className="text-sm md:text-lg text-blue-700 mt-1 md:mt-2">{t('autofix.k49568342')}</p>
|
||||||
</h1>
|
|
||||||
<p className="text-sm md:text-lg text-blue-700 mt-1 md:mt-2">
|
|
||||||
Manage your affiliate partners and tracking links
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
className="w-full md:w-auto inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
className="w-full md:w-auto inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-5 w-5" />
|
<PlusIcon className="h-5 w-5" />{t('autofix.ke1abc7d9')}</button>
|
||||||
Add Affiliate
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter */}
|
{/* Search and Filter */}
|
||||||
@ -165,7 +161,7 @@ export default function AffiliateManagementPage() {
|
|||||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search affiliates..."
|
placeholder={t('autofix.k832a032b')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
@ -177,9 +173,7 @@ export default function AffiliateManagementPage() {
|
|||||||
className="w-full sm:w-auto px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full sm:w-auto px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
>
|
>
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
<option key={cat} value={cat}>
|
<option key={cat} value={cat}>{cat === 'all' ? t('autofix.k32a13592') : cat}</option>
|
||||||
{cat === 'all' ? 'All Categories' : cat}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -193,7 +187,7 @@ export default function AffiliateManagementPage() {
|
|||||||
<LinkIcon className="h-6 w-6 text-blue-900" />
|
<LinkIcon className="h-6 w-6 text-blue-900" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Total Affiliates</p>
|
<p className="text-sm text-gray-600">{t('autofix.k410ff9a9')}</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">{affiliates.length}</p>
|
<p className="text-2xl font-bold text-gray-900">{affiliates.length}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -231,7 +225,7 @@ export default function AffiliateManagementPage() {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<div className="col-span-full text-center py-12">
|
<div className="col-span-full text-center py-12">
|
||||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
|
||||||
<p className="mt-4 text-sm text-gray-600">Loading affiliates...</p>
|
<p className="mt-4 text-sm text-gray-600">{t('autofix.ka991f523')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -276,7 +270,7 @@ export default function AffiliateManagementPage() {
|
|||||||
|
|
||||||
{affiliate.commissionRate && (
|
{affiliate.commissionRate && (
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-500">Commission:</span>
|
<span className="text-xs text-gray-500">{t('autofix.k03cd9b72')}</span>
|
||||||
<span className="text-sm font-semibold text-blue-900">{affiliate.commissionRate}</span>
|
<span className="text-sm font-semibold text-blue-900">{affiliate.commissionRate}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -350,12 +344,10 @@ export default function AffiliateManagementPage() {
|
|||||||
{!loading && filteredAffiliates.length === 0 && (
|
{!loading && filteredAffiliates.length === 0 && (
|
||||||
<div className="col-span-full text-center py-12">
|
<div className="col-span-full text-center py-12">
|
||||||
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
|
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No affiliates found</h3>
|
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('autofix.k19f2c5dc')}</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">{searchQuery || categoryFilter !== 'all'
|
||||||
{searchQuery || categoryFilter !== 'all'
|
? t('autofix.k6c341c65')
|
||||||
? 'Try adjusting your search or filter'
|
: t('autofix.kfd0ee006')}</p>
|
||||||
: 'Get started by adding a new affiliate partner'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -453,6 +445,7 @@ export default function AffiliateManagementPage() {
|
|||||||
|
|
||||||
// Create Modal Component
|
// Create Modal Component
|
||||||
function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (affiliate: Omit<Affiliate, 'id' | 'createdAt'> & { logoFile?: File }) => void }) {
|
function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (affiliate: Omit<Affiliate, 'id' | 'createdAt'> & { logoFile?: File }) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
@ -551,7 +544,7 @@ function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCr
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
<div className="relative w-full max-w-2xl mx-0 sm:mx-4 bg-white rounded-none sm:rounded-2xl shadow-2xl max-h-[100vh] sm:max-h-[90vh] overflow-y-auto">
|
<div className="relative w-full max-w-2xl mx-0 sm:mx-4 bg-white rounded-none sm:rounded-2xl shadow-2xl max-h-[100vh] sm:max-h-[90vh] overflow-y-auto">
|
||||||
<div className="sticky top-0 bg-white border-b border-gray-200 px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between rounded-none sm:rounded-t-2xl">
|
<div className="sticky top-0 bg-white border-b border-gray-200 px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between rounded-none sm:rounded-t-2xl">
|
||||||
<h2 className="text-xl sm:text-2xl font-bold text-blue-900">Add New Affiliate</h2>
|
<h2 className="text-xl sm:text-2xl font-bold text-blue-900">{t('autofix.k8d84b4c5')}</h2>
|
||||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 transition">
|
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 transition">
|
||||||
<XMarkIcon className="h-6 w-6" />
|
<XMarkIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@ -559,30 +552,30 @@ function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCr
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-5 sm:space-y-6">
|
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-5 sm:space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Partner Name *</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.k31cadca6')}</label>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
placeholder="e.g., Coffee Equipment Co."
|
placeholder={t('autofix.k890ff52f')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Description *</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.kfb92efe9')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
required
|
required
|
||||||
value={description}
|
value={description}
|
||||||
onChange={e => setDescription(e.target.value)}
|
onChange={e => setDescription(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
placeholder="Brief description of the affiliate partner..."
|
placeholder={t('autofix.k2a37c394')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Affiliate URL *</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.k6828cdd9')}</label>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
type="url"
|
type="url"
|
||||||
@ -594,7 +587,7 @@ function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Logo Image</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.kde1c3c69')}</label>
|
||||||
<div
|
<div
|
||||||
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
|
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
|
||||||
style={{ minHeight: '200px' }}
|
style={{ minHeight: '200px' }}
|
||||||
@ -608,16 +601,16 @@ function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCr
|
|||||||
<div className="text-center w-full px-6 py-10">
|
<div className="text-center w-full px-6 py-10">
|
||||||
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-gray-400" />
|
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
<div className="mt-4 text-sm font-medium text-gray-700">
|
<div className="mt-4 text-sm font-medium text-gray-700">
|
||||||
<span>Click to upload logo</span>
|
<span>{t('autofix.k05626798')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP, SVG up to 5MB</p>
|
<p className="text-xs text-gray-500 mt-2">{t('autofix.k578dcc0b')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
|
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt="Logo preview"
|
alt={t('autofix.k1af107a4')}
|
||||||
className="max-h-[180px] max-w-full object-contain"
|
className="max-h-[180px] max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -644,7 +637,7 @@ function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCr
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Category *</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.k3def5ebf')}</label>
|
||||||
<select
|
<select
|
||||||
value={category}
|
value={category}
|
||||||
onChange={e => setCategory(e.target.value)}
|
onChange={e => setCategory(e.target.value)}
|
||||||
@ -657,12 +650,12 @@ function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Commission Rate</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.k1e62338a')}</label>
|
||||||
<input
|
<input
|
||||||
value={commissionRate}
|
value={commissionRate}
|
||||||
onChange={e => setCommissionRate(e.target.value)}
|
onChange={e => setCommissionRate(e.target.value)}
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
placeholder="e.g., 10%"
|
placeholder={t('autofix.k7c19388f')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -691,9 +684,7 @@ function CreateAffiliateModal({ onClose, onCreate }: { onClose: () => void; onCr
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
||||||
>
|
>{t('autofix.ke1abc7d9')}</button>
|
||||||
Add Affiliate
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -708,6 +699,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdate: (affiliate: Affiliate & { logoFile?: File; removeLogo?: boolean }) => void
|
onUpdate: (affiliate: Affiliate & { logoFile?: File; removeLogo?: boolean }) => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState(affiliate.name)
|
const [name, setName] = useState(affiliate.name)
|
||||||
const [description, setDescription] = useState(affiliate.description)
|
const [description, setDescription] = useState(affiliate.description)
|
||||||
const [url, setUrl] = useState(affiliate.url)
|
const [url, setUrl] = useState(affiliate.url)
|
||||||
@ -815,7 +807,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
<div className="relative w-full max-w-2xl mx-0 sm:mx-4 bg-white rounded-none sm:rounded-2xl shadow-2xl max-h-[100vh] sm:max-h-[90vh] overflow-y-auto">
|
<div className="relative w-full max-w-2xl mx-0 sm:mx-4 bg-white rounded-none sm:rounded-2xl shadow-2xl max-h-[100vh] sm:max-h-[90vh] overflow-y-auto">
|
||||||
<div className="sticky top-0 bg-white border-b border-gray-200 px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between rounded-none sm:rounded-t-2xl">
|
<div className="sticky top-0 bg-white border-b border-gray-200 px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between rounded-none sm:rounded-t-2xl">
|
||||||
<h2 className="text-xl sm:text-2xl font-bold text-blue-900">Edit Affiliate</h2>
|
<h2 className="text-xl sm:text-2xl font-bold text-blue-900">{t('autofix.k09def344')}</h2>
|
||||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 transition">
|
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 transition">
|
||||||
<XMarkIcon className="h-6 w-6" />
|
<XMarkIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@ -823,7 +815,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-5 sm:space-y-6">
|
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-5 sm:space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Partner Name *</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.k31cadca6')}</label>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
value={name}
|
value={name}
|
||||||
@ -833,7 +825,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Description *</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.kfb92efe9')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
required
|
required
|
||||||
value={description}
|
value={description}
|
||||||
@ -844,7 +836,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Affiliate URL *</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.k6828cdd9')}</label>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
type="url"
|
type="url"
|
||||||
@ -855,7 +847,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Logo Image</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.kde1c3c69')}</label>
|
||||||
<div
|
<div
|
||||||
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
|
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
|
||||||
style={{ minHeight: '200px' }}
|
style={{ minHeight: '200px' }}
|
||||||
@ -869,9 +861,9 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
<div className="text-center w-full px-6 py-10">
|
<div className="text-center w-full px-6 py-10">
|
||||||
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-gray-400" />
|
<PhotoIcon aria-hidden="true" className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
<div className="mt-4 text-sm font-medium text-gray-700">
|
<div className="mt-4 text-sm font-medium text-gray-700">
|
||||||
<span>Click to upload logo</span>
|
<span>{t('autofix.k05626798')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP, SVG up to 5MB</p>
|
<p className="text-xs text-gray-500 mt-2">{t('autofix.k578dcc0b')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{displayLogoUrl && (
|
{displayLogoUrl && (
|
||||||
@ -881,9 +873,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
className="max-h-[180px] max-w-full object-contain"
|
className="max-h-[180px] max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-xs text-gray-600">
|
<p className="mt-2 text-xs text-gray-600">{logoFile ? t('autofix.k55d88592') : t('autofix.k86d84b6d')}</p>
|
||||||
{logoFile ? '🆕 New logo selected' : '📷 Current logo'}
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -908,7 +898,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Category *</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.k3def5ebf')}</label>
|
||||||
<select
|
<select
|
||||||
value={category}
|
value={category}
|
||||||
onChange={e => setCategory(e.target.value)}
|
onChange={e => setCategory(e.target.value)}
|
||||||
@ -921,7 +911,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Commission Rate</label>
|
<label className="block text-sm font-medium text-blue-900 mb-2">{t('autofix.k1e62338a')}</label>
|
||||||
<input
|
<input
|
||||||
value={commissionRate}
|
value={commissionRate}
|
||||||
onChange={e => setCommissionRate(e.target.value)}
|
onChange={e => setCommissionRate(e.target.value)}
|
||||||
@ -954,9 +944,7 @@ function EditAffiliateModal({ affiliate, onClose, onUpdate }: {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
||||||
>
|
>{t('autofix.k5a489751')}</button>
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -972,6 +960,7 @@ function DeleteConfirmModal({ affiliateName, onClose, onConfirm, isDeleting }: {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
<div className="relative w-full max-w-md mx-0 sm:mx-4 bg-white rounded-none sm:rounded-2xl shadow-2xl p-4 sm:p-6">
|
<div className="relative w-full max-w-md mx-0 sm:mx-4 bg-white rounded-none sm:rounded-2xl shadow-2xl p-4 sm:p-6">
|
||||||
@ -979,10 +968,8 @@ function DeleteConfirmModal({ affiliateName, onClose, onConfirm, isDeleting }: {
|
|||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||||
<TrashIcon className="h-6 w-6 text-red-600" />
|
<TrashIcon className="h-6 w-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-4 text-lg font-semibold text-center text-gray-900">Delete Affiliate</h3>
|
<h3 className="mt-4 text-lg font-semibold text-center text-gray-900">{t('autofix.kccbc54c1')}</h3>
|
||||||
<p className="mt-2 text-sm text-center text-gray-600">
|
<p className="mt-2 text-sm text-center text-gray-600">{t('autofix.k055bba0c')}<span className="font-semibold">{affiliateName}</span>{t('autofix.kd5cca6e9')}</p>
|
||||||
Are you sure you want to delete <span className="font-semibold">{affiliateName}</span>? This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||||
|
|||||||
@ -1,67 +1,68 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import useContractManagement from '../hooks/useContractManagement'
|
import useContractManagement from '../hooks/useContractManagement'
|
||||||
|
|
||||||
function fileToDataUrl(file: File): Promise<string> {
|
const LOGO_ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
|
||||||
return new Promise((resolve, reject) => {
|
const MAX_LOGO_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
type CompanySettingsForm = {
|
||||||
|
company_name: string
|
||||||
|
company_street: string
|
||||||
|
company_postal_city: string
|
||||||
|
company_country: string
|
||||||
|
company_logo_base64: string | null
|
||||||
|
company_logo_mime_type: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToBase64Payload(file: File) {
|
||||||
|
return new Promise<{ base64: string; mimeType: string }>((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onerror = () => reject(new Error('Failed to read file'))
|
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const result = reader.result
|
const result = typeof reader.result === 'string' ? reader.result : ''
|
||||||
if (typeof result === 'string') resolve(result)
|
const match = result.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/)
|
||||||
else reject(new Error('Invalid file result'))
|
if (!match) {
|
||||||
|
reject(new Error('invalid_data_url'))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
resolve({ mimeType: match[1], base64: match[2] })
|
||||||
|
}
|
||||||
|
reader.onerror = () => reject(new Error('read_failed'))
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeForLog(payload: Record<string, any>) {
|
|
||||||
const out: Record<string, any> = {}
|
|
||||||
for (const [k, v] of Object.entries(payload)) {
|
|
||||||
if (typeof v === 'string' && (k.toLowerCase().includes('base64') || k.toLowerCase().includes('qr_code'))) {
|
|
||||||
out[k] = { kind: 'base64', len: v.length, head: v.slice(0, 32) }
|
|
||||||
} else if (typeof v === 'string' && v.length > 200) {
|
|
||||||
out[k] = { kind: 'string', len: v.length, head: v.slice(0, 32) }
|
|
||||||
} else {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CompanySettingsPanel() {
|
export default function CompanySettingsPanel() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { getCompanySettings, updateCompanySettings } = useContractManagement()
|
const { getCompanySettings, updateCompanySettings } = useContractManagement()
|
||||||
|
const logoInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState<CompanySettingsForm>({
|
||||||
company_name: '',
|
company_name: '',
|
||||||
company_street: '',
|
company_street: '',
|
||||||
company_postal_city: '',
|
company_postal_city: '',
|
||||||
company_country: '',
|
company_country: '',
|
||||||
|
company_logo_base64: null,
|
||||||
|
company_logo_mime_type: null,
|
||||||
})
|
})
|
||||||
const [hasQr60, setHasQr60] = useState(false)
|
|
||||||
const [hasQr120, setHasQr120] = useState(false)
|
|
||||||
const [qr60DataUrl, setQr60DataUrl] = useState<string>('')
|
|
||||||
const [qr120DataUrl, setQr120DataUrl] = useState<string>('')
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [saveError, setSaveError] = useState<string>('')
|
const [saveError, setSaveError] = useState<string>('')
|
||||||
|
const [logoError, setLogoError] = useState<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCompanySettings()
|
getCompanySettings()
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
setForm({
|
setForm({
|
||||||
company_name: data.company_name || '',
|
company_name: data.company_name || '',
|
||||||
company_street: data.company_street || '',
|
company_street: data.company_street || '',
|
||||||
company_postal_city: data.company_postal_city || '',
|
company_postal_city: data.company_postal_city || '',
|
||||||
company_country: data.company_country || '',
|
company_country: data.company_country || '',
|
||||||
|
company_logo_base64: data.company_logo_base64 || null,
|
||||||
|
company_logo_mime_type: data.company_logo_mime_type || null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const qr60 = (data as any)?.qr_code_60_base64 ?? (data as any)?.qrCode60Base64
|
|
||||||
const qr120 = (data as any)?.qr_code_120_base64 ?? (data as any)?.qrCode120Base64
|
|
||||||
setHasQr60(!!qr60)
|
|
||||||
setHasQr120(!!qr120)
|
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
@ -69,204 +70,203 @@ export default function CompanySettingsPanel() {
|
|||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target
|
const { name, value } = e.target
|
||||||
setForm(prev => ({ ...prev, [name]: value }))
|
setForm((prev) => ({ ...prev, [name]: value }))
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
setLogoError('')
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (!LOGO_ACCEPTED_TYPES.includes(file.type)) {
|
||||||
|
setLogoError(t('autofix.k2bd38d5e'))
|
||||||
|
if (logoInputRef.current) logoInputRef.current.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_LOGO_BYTES) {
|
||||||
|
setLogoError(t('autofix.k394b7f42'))
|
||||||
|
if (logoInputRef.current) logoInputRef.current.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { base64, mimeType } = await fileToBase64Payload(file)
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
company_logo_base64: base64,
|
||||||
|
company_logo_mime_type: mimeType,
|
||||||
|
}))
|
||||||
|
setSaved(false)
|
||||||
|
} catch {
|
||||||
|
setLogoError(t('autofix.k8a1d4c20'))
|
||||||
|
} finally {
|
||||||
|
if (logoInputRef.current) logoInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveLogo = () => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
company_logo_base64: null,
|
||||||
|
company_logo_mime_type: null,
|
||||||
|
}))
|
||||||
|
setLogoError('')
|
||||||
|
setSaved(false)
|
||||||
|
if (logoInputRef.current) logoInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
setSaveError('')
|
setSaveError('')
|
||||||
try {
|
try {
|
||||||
// IMPORTANT: send `payload` (full strings), not the redacted log view.
|
await updateCompanySettings(form)
|
||||||
const payload: any = { ...form }
|
|
||||||
if (qr60DataUrl) payload.qr_code_60_base64 = qr60DataUrl
|
|
||||||
if (qr120DataUrl) payload.qr_code_120_base64 = qr120DataUrl
|
|
||||||
|
|
||||||
// For logging only (redacted); never send this object.
|
|
||||||
const logPayload: any = summarizeForLog(payload)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const qr60 = payload.qr_code_60_base64
|
|
||||||
const qr120 = payload.qr_code_120_base64
|
|
||||||
console.info('[CompanySettingsPanel] updateCompanySettings payload', {
|
|
||||||
logPayload,
|
|
||||||
keys: Object.keys(payload),
|
|
||||||
jsonLength: JSON.stringify(payload).length,
|
|
||||||
qrFieldTypes: {
|
|
||||||
qr_code_60_base64: qr60 ? typeof qr60 : null,
|
|
||||||
qr_code_120_base64: qr120 ? typeof qr120 : null,
|
|
||||||
},
|
|
||||||
qrFieldLengths: {
|
|
||||||
qr_code_60_base64: typeof qr60 === 'string' ? qr60.length : null,
|
|
||||||
qr_code_120_base64: typeof qr120 === 'string' ? qr120.length : null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (qr60 && typeof qr60 !== 'string') console.warn('[CompanySettingsPanel] qr_code_60_base64 is not a string!', qr60)
|
|
||||||
if (qr120 && typeof qr120 !== 'string') console.warn('[CompanySettingsPanel] qr_code_120_base64 is not a string!', qr120)
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
await updateCompanySettings(payload)
|
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 3000)
|
setTimeout(() => setSaved(false), 3000)
|
||||||
} catch {
|
} catch {
|
||||||
setSaveError('Could not save settings.')
|
setSaveError(t('autofix.k95a16b2b'))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleQrUpload = async (which: '60' | '120', file: File | null) => {
|
const logoPreviewSrc = form.company_logo_base64
|
||||||
setSaved(false)
|
? `data:${form.company_logo_mime_type || 'image/png'};base64,${form.company_logo_base64}`
|
||||||
setSaveError('')
|
: null
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
// Backend accepts 10MB JSON, but base64 expands the payload.
|
|
||||||
// Keep a conservative limit to avoid 413 Payload Too Large.
|
|
||||||
const MAX_FILE_BYTES = 7_000_000
|
|
||||||
if (file.size > MAX_FILE_BYTES) {
|
|
||||||
setSaveError('QR image is too large. Please upload a smaller PNG.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (file.type && file.type !== 'image/png') {
|
|
||||||
setSaveError('Please upload a PNG file for the QR code.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dataUrl = await fileToDataUrl(file)
|
|
||||||
// Normalize to raw base64, to match other endpoints (e.g. company stamp upload)
|
|
||||||
const m = dataUrl.match(/^data:(.+?);base64,(.*)$/)
|
|
||||||
const base64 = m ? m[2] : dataUrl
|
|
||||||
if (which === '60') {
|
|
||||||
setQr60DataUrl(base64)
|
|
||||||
setHasQr60(true)
|
|
||||||
} else {
|
|
||||||
setQr120DataUrl(base64)
|
|
||||||
setHasQr120(true)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setSaveError('Could not read QR image file.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500 py-4">
|
<div className="flex items-center gap-2 py-4 text-sm text-gray-500">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-900" />
|
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-900" />
|
||||||
Loading settings…
|
{t('autofix.k81a1b900')}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900">{t('autofix.k0198ce13')}</h3>
|
||||||
|
<p className="text-sm text-slate-500">{t('autofix.k03d7361d')}</p>
|
||||||
|
<p className="text-xs text-slate-400">{t('autofix.k1c2b0975')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={logoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={LOGO_ACCEPTED_TYPES.join(',')}
|
||||||
|
onChange={handleLogoChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => logoInputRef.current?.click()}
|
||||||
|
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
{logoPreviewSrc ? t('autofix.k7d3f0e11') : t('autofix.k089f42a1')}
|
||||||
|
</button>
|
||||||
|
{logoPreviewSrc && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveLogo}
|
||||||
|
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-semibold text-red-700 transition hover:bg-red-100"
|
||||||
|
>
|
||||||
|
{t('autofix.k0d8e2d01')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-2xl border border-dashed border-slate-300 bg-white p-4">
|
||||||
|
{logoPreviewSrc ? (
|
||||||
|
<img
|
||||||
|
src={logoPreviewSrc}
|
||||||
|
alt={t('autofix.k0198ce13')}
|
||||||
|
className="max-h-24 max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-slate-500">{t('autofix.k432b8a12')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logoError && <div className="mt-3 text-sm font-medium text-red-600">{logoError}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="company_name" className="block text-sm font-medium text-gray-700 mb-1">
|
<h3 className="mb-3 flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||||
Company Name
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
|
||||||
</label>
|
{t('autofix.kaa8bbc8e')}
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4 text-sm text-slate-500">{t('autofix.k15bea9bb')}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="company_name" className="mb-1 block text-sm font-medium text-gray-700">{t('autofix.k33918465')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="company_name"
|
id="company_name"
|
||||||
name="company_name"
|
name="company_name"
|
||||||
value={form.company_name}
|
value={form.company_name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="ProfitPlanet GmbH"
|
placeholder={t('autofix.k91e69df1')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="company_street" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="company_street" className="mb-1 block text-sm font-medium text-gray-700">Street</label>
|
||||||
Street
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="company_street"
|
id="company_street"
|
||||||
name="company_street"
|
name="company_street"
|
||||||
value={form.company_street}
|
value={form.company_street}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="Musterstraße 1"
|
placeholder={t('autofix.k81c7c2f2')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="company_postal_city" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="company_postal_city" className="mb-1 block text-sm font-medium text-gray-700">Postal Code & City</label>
|
||||||
Postal Code & City
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="company_postal_city"
|
id="company_postal_city"
|
||||||
name="company_postal_city"
|
name="company_postal_city"
|
||||||
value={form.company_postal_city}
|
value={form.company_postal_city}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="12345 Berlin"
|
placeholder={t('autofix.k93165aea')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="company_country" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="company_country" className="mb-1 block text-sm font-medium text-gray-700">Country</label>
|
||||||
Country
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="company_country"
|
id="company_country"
|
||||||
name="company_country"
|
name="company_country"
|
||||||
value={form.company_country}
|
value={form.company_country}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="Germany"
|
placeholder="Germany"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Invoice QR Code (60 pcs)</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/png"
|
|
||||||
onChange={e => handleQrUpload('60', e.target.files?.[0] || null)}
|
|
||||||
className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100"
|
|
||||||
/>
|
|
||||||
<div className="mt-1 text-xs text-gray-500">
|
|
||||||
{qr60DataUrl ? 'Selected (will be saved on Save)' : hasQr60 ? 'Already uploaded' : 'Not uploaded'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{saveError && <div className="text-sm font-medium text-red-600">{saveError}</div>}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Invoice QR Code (120 pcs)</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/png"
|
|
||||||
onChange={e => handleQrUpload('120', e.target.files?.[0] || null)}
|
|
||||||
className="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-900 hover:file:bg-blue-100"
|
|
||||||
/>
|
|
||||||
<div className="mt-1 text-xs text-gray-500">
|
|
||||||
{qr120DataUrl ? 'Selected (will be saved on Save)' : hasQr120 ? 'Already uploaded' : 'Not uploaded'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{saveError && (
|
|
||||||
<div className="text-sm text-red-600 font-medium">{saveError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className={`px-5 py-2 rounded-lg text-sm font-semibold text-white transition-colors ${
|
className={`rounded-lg px-5 py-2 text-sm font-semibold text-white transition-colors ${saving ? 'cursor-not-allowed bg-gray-400' : 'bg-blue-900 hover:bg-blue-800'}`}
|
||||||
saving ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-900 hover:bg-blue-800'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{saving ? 'Saving…' : 'Save'}
|
{saving ? t('autofix.kac6cedc7') : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
{saved && (
|
{saved && <span className="text-sm font-medium text-green-600">{t('autofix.ka29ac729')}</span>}
|
||||||
<span className="text-sm text-green-600 font-medium">Saved successfully</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import useContractManagement from '../hooks/useContractManagement';
|
import useContractManagement from '../hooks/useContractManagement';
|
||||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editingTemplateId?: string | null;
|
editingTemplateId?: string | null;
|
||||||
onCancelEdit?: () => void;
|
onCancelEdit?: () => void;
|
||||||
@ -11,6 +13,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdit }: Props) {
|
export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdit }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [htmlCode, setHtmlCode] = useState('');
|
const [htmlCode, setHtmlCode] = useState('');
|
||||||
const [isPreview, setIsPreview] = useState(false);
|
const [isPreview, setIsPreview] = useState(false);
|
||||||
@ -251,7 +254,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
{editingMeta && (
|
{editingMeta && (
|
||||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-3 flex items-center justify-between gap-3">
|
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-3 flex items-center justify-between gap-3">
|
||||||
<div className="text-sm text-indigo-900">
|
<div className="text-sm text-indigo-900">
|
||||||
<span className="font-semibold">Editing:</span> {name || 'Untitled'} (v{editingMeta.version}) • state: {editingMeta.state}
|
<span className="font-semibold">{t('autofix.k41afd863')}</span> {name || 'Untitled'} (v{editingMeta.version}) • state: {editingMeta.state}
|
||||||
</div>
|
</div>
|
||||||
{onCancelEdit && (
|
{onCancelEdit && (
|
||||||
<button
|
<button
|
||||||
@ -261,9 +264,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
onCancelEdit();
|
onCancelEdit();
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center rounded-lg bg-white hover:bg-gray-50 text-gray-900 px-3 py-1.5 text-sm font-medium shadow border border-gray-200 transition"
|
className="inline-flex items-center rounded-lg bg-white hover:bg-gray-50 text-gray-900 px-3 py-1.5 text-sm font-medium shadow border border-gray-200 transition"
|
||||||
>
|
>{t('autofix.k06d4487f')}</button>
|
||||||
Cancel editing
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -271,7 +272,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Template name"
|
placeholder={t('autofix.k2fac9ff2')}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -282,9 +283,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsPreview((v) => !v)}
|
onClick={() => setIsPreview((v) => !v)}
|
||||||
className="inline-flex items-center rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-900 px-4 py-2 text-sm font-medium shadow transition"
|
className="inline-flex items-center rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-900 px-4 py-2 text-sm font-medium shadow transition"
|
||||||
>
|
>{isPreview ? t('autofix.k49165061') : t('autofix.k95d19932')}</button>
|
||||||
{isPreview ? 'Switch to Code' : 'Preview HTML'}
|
|
||||||
</button>
|
|
||||||
{isPreview && (
|
{isPreview && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -360,9 +359,9 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{type === 'invoice' && (
|
{type === 'invoice' && (
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900">
|
||||||
<p className="font-semibold">Invoice template variables</p>
|
<p className="font-semibold">{t('autofix.k221fa311')}</p>
|
||||||
<p className="mt-1">Use these placeholders in your HTML: invoiceNumber, customerName, issuedAt, totalNet, totalTax, totalGross, itemsHtml.</p>
|
<p className="mt-1">{t('autofix.kb791958e')}</p>
|
||||||
<p className="mt-1">Important: include <span className="font-semibold">itemsHtml</span> to render invoice line items.</p>
|
<p className="mt-1">Important: include <span className="font-semibold">itemsHtml</span>{t('autofix.k7a3a6ea3')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
@ -379,7 +378,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
<div className="rounded-lg border border-gray-300 bg-white shadow">
|
<div className="rounded-lg border border-gray-300 bg-white shadow">
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
title="Contract Preview"
|
title={t('autofix.kd9e4bcbd')}
|
||||||
className="w-full rounded-lg"
|
className="w-full rounded-lg"
|
||||||
style={{ height: 1200, background: 'transparent' }}
|
style={{ height: 1200, background: 'transparent' }}
|
||||||
/>
|
/>
|
||||||
@ -398,19 +397,17 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
onClick={() => save(true)}
|
onClick={() => save(true)}
|
||||||
disabled={saving || !canSave}
|
disabled={saving || !canSave}
|
||||||
className="inline-flex items-center rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow disabled:opacity-60 transition"
|
className="inline-flex items-center rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow disabled:opacity-60 transition"
|
||||||
>
|
>{t('autofix.k0af6c6be')}</button>
|
||||||
Create & Activate
|
|
||||||
</button>
|
|
||||||
{/* NEW: helper text */}
|
{/* NEW: helper text */}
|
||||||
{!canSave && <span className="text-xs text-red-600">Fill all fields to proceed.</span>}
|
{!canSave && <span className="text-xs text-red-600">{t('autofix.k99bffb65')}</span>}
|
||||||
{saving && <span className="text-xs text-gray-500">Saving…</span>}
|
{saving && <span className="text-xs text-gray-500">{t('autofix.kac6cedc7')}</span>}
|
||||||
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
|
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmActionModal
|
<ConfirmActionModal
|
||||||
open={publishConfirmOpen}
|
open={publishConfirmOpen}
|
||||||
pending={saving}
|
pending={saving}
|
||||||
title="Activate template now?"
|
title={t('autofix.k0c51fa85')}
|
||||||
description={publishConfirmMessage || 'This will activate this template.'}
|
description={publishConfirmMessage || 'This will activate this template.'}
|
||||||
confirmText="Activate"
|
confirmText="Activate"
|
||||||
onClose={() => !saving && setPublishConfirmOpen(false)}
|
onClose={() => !saving && setPublishConfirmOpen(false)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -4,11 +4,14 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import useContractManagement, { CompanyStamp } from '../hooks/useContractManagement';
|
import useContractManagement, { CompanyStamp } from '../hooks/useContractManagement';
|
||||||
import DeleteConfirmationModal from '../../../components/delete/deleteConfirmationModal';
|
import DeleteConfirmationModal from '../../../components/delete/deleteConfirmationModal';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onUploaded?: () => void;
|
onUploaded?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [label, setLabel] = useState<string>('');
|
const [label, setLabel] = useState<string>('');
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@ -215,15 +218,13 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header with Add New Stamp modal trigger */}
|
{/* Header with Add New Stamp modal trigger */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-700">Manage your company stamps. One active at a time.</p>
|
<p className="text-sm text-gray-700">{t('autofix.k096f4013')}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openModal}
|
onClick={openModal}
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-semibold shadow transition"
|
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-semibold shadow transition"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" fill="currentColor" className="opacity-90"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>
|
<svg width="18" height="18" fill="currentColor" className="opacity-90"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>{t('autofix.k6070f6e3')}</button>
|
||||||
Add New Stamp
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Emphasized Active stamp */}
|
{/* Emphasized Active stamp */}
|
||||||
@ -234,13 +235,11 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
|||||||
{activeStamp.base64 ? (
|
{activeStamp.base64 ? (
|
||||||
<img
|
<img
|
||||||
src={toImgSrc(activeStamp)}
|
src={toImgSrc(activeStamp)}
|
||||||
alt="Active stamp"
|
alt={t('autofix.k134e3932')}
|
||||||
className="h-24 w-24 object-contain rounded-xl ring-2 ring-indigo-200 shadow"
|
className="h-24 w-24 object-contain rounded-xl ring-2 ring-indigo-200 shadow"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-24 w-24 flex items-center justify-center rounded-xl ring-2 ring-gray-200 bg-gray-50 text-xs text-gray-500">
|
<div className="h-24 w-24 flex items-center justify-center rounded-xl ring-2 ring-gray-200 bg-gray-50 text-xs text-gray-500">{t('autofix.k56717603')}</div>
|
||||||
no image
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<span className="absolute -top-2 -right-2 rounded-full bg-green-600 text-white text-xs px-3 py-1 shadow font-bold">
|
<span className="absolute -top-2 -right-2 rounded-full bg-green-600 text-white text-xs px-3 py-1 shadow font-bold">
|
||||||
Active
|
Active
|
||||||
@ -248,7 +247,7 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-base font-semibold text-gray-900 truncate">{activeStamp.label || activeStamp.id}</p>
|
<p className="text-base font-semibold text-gray-900 truncate">{activeStamp.label || activeStamp.id}</p>
|
||||||
<p className="text-xs text-gray-500">Auto-applied to documents where applicable.</p>
|
<p className="text-xs text-gray-500">{t('autofix.kf1a9384b')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@ -265,7 +264,7 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
|||||||
{/* Stamps list */}
|
{/* Stamps list */}
|
||||||
{!!stamps.length && (
|
{!!stamps.length && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm font-medium text-gray-900 mb-2">Your Company Stamps</p>
|
<p className="text-sm font-medium text-gray-900 mb-2">{t('autofix.k7775eddb')}</p>
|
||||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{stamps.map((s) => {
|
{stamps.map((s) => {
|
||||||
const src = toImgSrc(s);
|
const src = toImgSrc(s);
|
||||||
@ -285,9 +284,7 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
|||||||
className="h-14 w-14 object-contain rounded-lg ring-1 ring-gray-200 bg-white"
|
className="h-14 w-14 object-contain rounded-lg ring-1 ring-gray-200 bg-white"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-14 w-14 flex items-center justify-center rounded-lg ring-1 ring-gray-200 bg-white text-xs text-gray-500">
|
<div className="h-14 w-14 flex items-center justify-center rounded-lg ring-1 ring-gray-200 bg-white text-xs text-gray-500">{t('autofix.k56717603')}</div>
|
||||||
no image
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm text-gray-900">{s.label || s.id}</span>
|
<span className="text-sm text-gray-900">{s.label || s.id}</span>
|
||||||
@ -335,10 +332,8 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
|||||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5">
|
<div className="w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5">
|
||||||
<div className="p-6 border-b border-gray-100">
|
<div className="p-6 border-b border-gray-100">
|
||||||
<h3 className="text-lg font-bold text-indigo-700">Add New Stamp</h3>
|
<h3 className="text-lg font-bold text-indigo-700">{t('autofix.k6070f6e3')}</h3>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">{t('autofix.k825359ab')}</p>
|
||||||
Accepted types: PNG, JPEG, WebP, SVG. Max size: 5MB.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@ -347,7 +342,7 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={modalLabel}
|
value={modalLabel}
|
||||||
onChange={(e) => setModalLabel(e.target.value)}
|
onChange={(e) => setModalLabel(e.target.value)}
|
||||||
placeholder="e.g., Company Seal 2025"
|
placeholder={t('autofix.kcb65c692')}
|
||||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -365,13 +360,11 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
|||||||
className="h-20 w-20 object-contain rounded-lg ring-1 ring-gray-200 bg-white"
|
className="h-20 w-20 object-contain rounded-lg ring-1 ring-gray-200 bg-white"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-20 w-20 flex items-center justify-center rounded-lg ring-1 ring-gray-200 bg-white text-xs text-gray-500">
|
<div className="h-20 w-20 flex items-center justify-center rounded-lg ring-1 ring-gray-200 bg-white text-xs text-gray-500">{t('autofix.k18872b63')}</div>
|
||||||
No image
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-gray-900">Drag and drop your stamp here</p>
|
<p className="text-sm text-gray-900">{t('autofix.ke58b7627')}</p>
|
||||||
<p className="text-xs text-gray-500">or click to browse</p>
|
<p className="text-xs text-gray-500">{t('autofix.kba6bd6f3')}</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<label className="inline-block">
|
<label className="inline-block">
|
||||||
<input
|
<input
|
||||||
@ -381,9 +374,7 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<span className="cursor-pointer text-xs px-3 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 inline-flex items-center gap-1">
|
<span className="cursor-pointer text-xs px-3 py-2 rounded-lg bg-white hover:bg-gray-50 text-gray-800 border border-gray-200 inline-flex items-center gap-1">
|
||||||
<svg width="14" height="14" fill="currentColor"><path d="M12 10v2H2v-2H0v4h14v-4h-2zM7 0l4 4H9v5H5V4H3l4-4z"/></svg>
|
<svg width="14" height="14" fill="currentColor"><path d="M12 10v2H2v-2H0v4h14v-4h-2zM7 0l4 4H9v5H5V4H3l4-4z"/></svg>{t('autofix.kfeac3f7e')}</span>
|
||||||
Choose file
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -406,9 +397,7 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
|||||||
onClick={confirmUpload}
|
onClick={confirmUpload}
|
||||||
disabled={modalUploading || !modalFile}
|
disabled={modalUploading || !modalFile}
|
||||||
className="text-sm px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-60 transition"
|
className="text-sm px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-60 transition"
|
||||||
>
|
>{modalUploading ? t('autofix.ka3076020') : 'Upload'}</button>
|
||||||
{modalUploading ? 'Uploading…' : 'Upload'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -418,7 +407,7 @@ export default function ContractUploadCompanyStamp({ onUploaded }: Props) {
|
|||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
<DeleteConfirmationModal
|
<DeleteConfirmationModal
|
||||||
open={deleteModal.open}
|
open={deleteModal.open}
|
||||||
title="Delete Company Stamp"
|
title={t('autofix.ka8f53660')}
|
||||||
description={
|
description={
|
||||||
deleteModal.active
|
deleteModal.active
|
||||||
? `This stamp (${deleteModal.label || deleteModal.id}) is currently active. Are you sure you want to delete it? This action cannot be undone.`
|
? `This stamp (${deleteModal.label || deleteModal.id}) is currently active. Are you sure you want to delete it? This action cannot be undone.`
|
||||||
|
|||||||
@ -0,0 +1,632 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { authFetch } from '../../../utils/authFetch';
|
||||||
|
import { useToast } from '../../../components/toast/toastComponent';
|
||||||
|
|
||||||
|
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
|
||||||
|
const MAIL_TEMPLATES_BASE = `${API_BASE}/api/admin/mail-templates`;
|
||||||
|
|
||||||
|
/** Convert kebab-case to camelCase for use as i18n key segment */
|
||||||
|
function toCamelCase(str: string): string {
|
||||||
|
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive the i18n translation key for a mail template's subject */
|
||||||
|
function subjectI18nKey(templateType: string): string {
|
||||||
|
return `mailTemplates.${toCamelCase(templateType)}.subject`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive the i18n translation key for a mail template's HTML content */
|
||||||
|
function htmlI18nKey(templateType: string): string {
|
||||||
|
return `mailTemplates.${toCamelCase(templateType)}.htmlContent`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync mail template content to the i18n translation files so it appears
|
||||||
|
* in Language Management and can be translated to other languages.
|
||||||
|
*/
|
||||||
|
async function syncToI18n(templateType: string, subject: string, htmlContent: string): Promise<void> {
|
||||||
|
const res = await fetch('/api/i18n/translations', { cache: 'no-store' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data?.ok) throw new Error(data?.message || 'Failed to load translations');
|
||||||
|
|
||||||
|
const translations: Record<string, Record<string, string>> = data.translations ?? {};
|
||||||
|
const sKey = subjectI18nKey(templateType);
|
||||||
|
const hKey = htmlI18nKey(templateType);
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...translations,
|
||||||
|
en: { ...(translations.en ?? {}), [sKey]: subject, [hKey]: htmlContent },
|
||||||
|
};
|
||||||
|
|
||||||
|
const putRes = await fetch('/api/i18n/translations', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ translations: updated }),
|
||||||
|
});
|
||||||
|
const putData = await putRes.json();
|
||||||
|
if (!putRes.ok || !putData?.ok) throw new Error(putData?.message || 'Failed to sync to Language Management');
|
||||||
|
}
|
||||||
|
|
||||||
|
type MailTemplate = {
|
||||||
|
id: number;
|
||||||
|
template_type: string;
|
||||||
|
name: string;
|
||||||
|
subject: string | null;
|
||||||
|
html_content: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_archived: boolean;
|
||||||
|
archived_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorState = {
|
||||||
|
template_type: string;
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
html_content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createBlankEditor(t: (key: string) => string): EditorState {
|
||||||
|
return {
|
||||||
|
template_type: '',
|
||||||
|
name: '',
|
||||||
|
subject: '',
|
||||||
|
html_content: `<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>${t('autofix.k88f0d12a')}</h2><p>${t('autofix.k4f530782')}</p></div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MailTemplatesManager() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const blankEditor = useMemo(() => createBlankEditor(t), [t]);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<'active' | 'archived'>('active');
|
||||||
|
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [i18nSyncStatus, setI18nSyncStatus] = useState<'idle' | 'syncing' | 'synced' | 'error'>('idle');
|
||||||
|
const [editor, setEditor] = useState<EditorState>(blankEditor);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const fetchTemplates = useCallback(async (includeArchived: boolean) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setFetchError(null);
|
||||||
|
try {
|
||||||
|
const url = `${MAIL_TEMPLATES_BASE}${includeArchived ? '?includeArchived=true' : ''}`;
|
||||||
|
const res = await authFetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
||||||
|
}
|
||||||
|
const raw = await res.json();
|
||||||
|
// API may return a bare array or a wrapper object { data: [...] } / { templates: [...] }
|
||||||
|
const data: MailTemplate[] = Array.isArray(raw)
|
||||||
|
? raw
|
||||||
|
: Array.isArray(raw?.data)
|
||||||
|
? raw.data
|
||||||
|
: Array.isArray(raw?.templates)
|
||||||
|
? raw.templates
|
||||||
|
: [];
|
||||||
|
setTemplates(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
setFetchError(e?.message || 'Failed to load templates');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTemplates(tab === 'archived');
|
||||||
|
setSelectedId(null);
|
||||||
|
setIsCreating(false);
|
||||||
|
setEditor(blankEditor);
|
||||||
|
}, [tab, fetchTemplates, blankEditor]);
|
||||||
|
|
||||||
|
const visibleTemplates = tab === 'archived'
|
||||||
|
? templates.filter((tmpl) => tmpl.is_archived)
|
||||||
|
: templates.filter((tmpl) => !tmpl.is_archived);
|
||||||
|
|
||||||
|
const selectedTemplate = templates.find((tmpl) => tmpl.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
const openEditor = (template: MailTemplate) => {
|
||||||
|
setIsCreating(false);
|
||||||
|
setSelectedId(template.id);
|
||||||
|
setI18nSyncStatus('idle');
|
||||||
|
setEditor({
|
||||||
|
template_type: template.template_type,
|
||||||
|
name: template.name,
|
||||||
|
subject: template.subject ?? '',
|
||||||
|
html_content: template.html_content,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCreate = () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
setSelectedId(null);
|
||||||
|
setI18nSyncStatus('idle');
|
||||||
|
setEditor(blankEditor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (isSaving) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
setI18nSyncStatus('idle');
|
||||||
|
let savedTemplateType = editor.template_type;
|
||||||
|
let savedSubject = editor.subject;
|
||||||
|
let savedHtmlContent = editor.html_content;
|
||||||
|
try {
|
||||||
|
if (isCreating) {
|
||||||
|
const res = await authFetch(MAIL_TEMPLATES_BASE, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
template_type: editor.template_type,
|
||||||
|
name: editor.name,
|
||||||
|
subject: editor.subject || undefined,
|
||||||
|
html_content: editor.html_content,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
||||||
|
}
|
||||||
|
const created: MailTemplate = await res.json();
|
||||||
|
savedTemplateType = created.template_type;
|
||||||
|
savedSubject = created.subject ?? editor.subject;
|
||||||
|
savedHtmlContent = created.html_content;
|
||||||
|
showToast({ variant: 'success', message: t('autofix.ke8a3bd92') });
|
||||||
|
await fetchTemplates(false);
|
||||||
|
setTab('active');
|
||||||
|
setIsCreating(false);
|
||||||
|
setSelectedId(created.id);
|
||||||
|
setEditor({
|
||||||
|
template_type: created.template_type,
|
||||||
|
name: created.name,
|
||||||
|
subject: created.subject ?? '',
|
||||||
|
html_content: created.html_content,
|
||||||
|
});
|
||||||
|
} else if (selectedId !== null) {
|
||||||
|
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${selectedId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
template_type: editor.template_type,
|
||||||
|
name: editor.name,
|
||||||
|
subject: editor.subject || undefined,
|
||||||
|
html_content: editor.html_content,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
||||||
|
}
|
||||||
|
showToast({ variant: 'success', message: t('autofix.k9535ed27') });
|
||||||
|
await fetchTemplates(tab === 'archived');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync content to i18n system so it's translatable in Language Management
|
||||||
|
if (savedTemplateType?.trim()) {
|
||||||
|
setI18nSyncStatus('syncing');
|
||||||
|
syncToI18n(savedTemplateType, savedSubject, savedHtmlContent)
|
||||||
|
.then(() => setI18nSyncStatus('synced'))
|
||||||
|
.catch(() => setI18nSyncStatus('error'));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast({ variant: 'error', message: e?.message || t('autofix.kb743b7c2') });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivate = async (id: number) => {
|
||||||
|
setActionLoadingId(id);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/activate`, { method: 'PATCH' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
||||||
|
}
|
||||||
|
showToast({ variant: 'success', message: t('autofix.ke1a18ce6') });
|
||||||
|
await fetchTemplates(tab === 'archived');
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast({ variant: 'error', message: e?.message || t('autofix.k1c5f641a') });
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async (id: number) => {
|
||||||
|
setActionLoadingId(id);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/archive`, { method: 'PATCH' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
||||||
|
}
|
||||||
|
showToast({ variant: 'success', message: t('autofix.k2f162f5d') });
|
||||||
|
if (selectedId === id) {
|
||||||
|
setSelectedId(null);
|
||||||
|
setEditor(blankEditor);
|
||||||
|
}
|
||||||
|
await fetchTemplates(tab === 'archived');
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast({ variant: 'error', message: e?.message || t('autofix.kff7b3b21') });
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnarchive = async (id: number) => {
|
||||||
|
setActionLoadingId(id);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}/unarchive`, { method: 'PATCH' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any)?.message || (err as any)?.error || `Error ${res.status}`);
|
||||||
|
}
|
||||||
|
showToast({ variant: 'success', message: t('autofix.kc3a71e92') });
|
||||||
|
setSelectedId(null);
|
||||||
|
setEditor(blankEditor);
|
||||||
|
setTab('active');
|
||||||
|
await fetchTemplates(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast({ variant: 'error', message: e?.message || t('autofix.ka91f3c05') });
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeI18nKeys = async (templateType: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/i18n/translations', { cache: 'no-store' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data?.ok) return;
|
||||||
|
const translations: Record<string, Record<string, string>> = data.translations ?? {};
|
||||||
|
const sKey = subjectI18nKey(templateType);
|
||||||
|
const hKey = htmlI18nKey(templateType);
|
||||||
|
const updated: Record<string, Record<string, string>> = {};
|
||||||
|
for (const lang of Object.keys(translations)) {
|
||||||
|
const copy = { ...translations[lang] };
|
||||||
|
delete copy[sKey];
|
||||||
|
delete copy[hKey];
|
||||||
|
updated[lang] = copy;
|
||||||
|
}
|
||||||
|
await fetch('/api/i18n/translations', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ translations: updated }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort — don't block the delete flow
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!window.confirm(t('autofix.ka63bb731'))) return;
|
||||||
|
const templateType = templates.find((tmpl) => tmpl.id === id)?.template_type ?? '';
|
||||||
|
setActionLoadingId(id);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${MAIL_TEMPLATES_BASE}/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as any)?.message || `Error ${res.status}`);
|
||||||
|
}
|
||||||
|
showToast({ variant: 'success', message: t('autofix.kf6b83106') });
|
||||||
|
if (selectedId === id) {
|
||||||
|
setSelectedId(null);
|
||||||
|
setEditor(blankEditor);
|
||||||
|
}
|
||||||
|
await fetchTemplates(tab === 'archived');
|
||||||
|
if (templateType.trim()) {
|
||||||
|
await removeI18nKeys(templateType);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast({ variant: 'error', message: e?.message || t('autofix.kccf6593a') });
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editorOpen = isCreating || selectedId !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/85 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:p-6 xl:p-7">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="flex items-center gap-2 text-xl font-semibold text-slate-900">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7l9 6 9-6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{t('autofix.kd93a60af')}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tab === 'active' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startCreate}
|
||||||
|
className="inline-flex items-center rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
+ New Mail Template
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editorOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-50 transition disabled:opacity-50"
|
||||||
|
>{isSaving ? t('autofix.kac6cedc7') : (isCreating ? t('autofix.k987f2b90') : t('autofix.k9f7c3d1e'))}</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fetchTemplates(tab === 'archived')}
|
||||||
|
disabled={isLoading}
|
||||||
|
title="Refresh"
|
||||||
|
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 hover:bg-slate-50 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="mb-5 flex gap-1 rounded-2xl border border-slate-200 bg-slate-100/60 p-1 w-fit">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('active')}
|
||||||
|
className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${
|
||||||
|
tab === 'active' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
|
>{t('autofix.kbdcb654a')}</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('archived')}
|
||||||
|
className={`rounded-xl px-4 py-2 text-sm font-semibold transition ${
|
||||||
|
tab === 'archived' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Archived
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fetchError && (
|
||||||
|
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||||
|
{fetchError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`grid gap-4 ${editorOpen ? 'lg:grid-cols-[290px_minmax(0,1fr)]' : ''}`}>
|
||||||
|
{/* Template List */}
|
||||||
|
<aside className="rounded-2xl border border-slate-200 bg-white/80 p-3">
|
||||||
|
<h3 className="px-2 pb-2 text-sm font-semibold text-slate-800">{tab === 'archived' ? t('autofix.kc097ece0') : t('autofix.k2fbe0857')}</h3>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="px-2 py-4 text-sm text-slate-400 text-center">{t('autofix.k832387c5')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && visibleTemplates.length === 0 && !isCreating && (
|
||||||
|
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">{tab === 'archived' ? t('autofix.k245ba4af') : t('autofix.k247b74e1')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCreating && (
|
||||||
|
<div className="mb-2 rounded-xl border border-slate-900 bg-slate-900 px-3 py-2 text-white">
|
||||||
|
<div className="text-sm font-semibold truncate">{t('autofix.k2f343849')}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-slate-300">Draft</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{visibleTemplates.map((template) => {
|
||||||
|
const isSelected = !isCreating && selectedId === template.id;
|
||||||
|
const isBusy = actionLoadingId === template.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
className={`rounded-xl border transition ${
|
||||||
|
isSelected ? 'border-slate-900 bg-slate-900' : 'border-slate-200 bg-white hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditor(template)}
|
||||||
|
className="w-full px-3 pt-2 pb-1 text-left"
|
||||||
|
>
|
||||||
|
<div className={`text-sm font-semibold truncate ${isSelected ? 'text-white' : 'text-slate-800'}`}>
|
||||||
|
{template.name}
|
||||||
|
</div>
|
||||||
|
<div className={`mt-0.5 text-[11px] flex items-center gap-1.5 ${isSelected ? 'text-slate-300' : 'text-slate-500'}`}>
|
||||||
|
<span className="truncate">{template.template_type}</span>
|
||||||
|
{template.is_active && !template.is_archived && (
|
||||||
|
<span className={`shrink-0 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${isSelected ? 'bg-emerald-500 text-white' : 'bg-emerald-100 text-emerald-700'}`}>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1 px-3 pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Edit"
|
||||||
|
onClick={() => openEditor(template)}
|
||||||
|
className={`rounded-lg px-2 py-1 text-[11px] font-semibold transition ${
|
||||||
|
isSelected ? 'bg-slate-700 text-slate-200 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!template.is_archived && !template.is_active && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Activate"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => handleActivate(template.id)}
|
||||||
|
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-emerald-50 text-emerald-700 hover:bg-emerald-100 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Activate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!template.is_archived && template.is_active && (
|
||||||
|
<span className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-emerald-100 text-emerald-700 cursor-default select-none">{t('autofix.k26404a1a')}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!template.is_archived && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Archive"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => handleArchive(template.id)}
|
||||||
|
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-amber-50 text-amber-700 hover:bg-amber-100 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{template.is_archived && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Unarchive"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => handleUnarchive(template.id)}
|
||||||
|
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-sky-50 text-sky-700 hover:bg-sky-100 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Unarchive
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Delete"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => handleDelete(template.id)}
|
||||||
|
className="rounded-lg px-2 py-1 text-[11px] font-semibold bg-rose-50 text-rose-700 hover:bg-rose-100 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isBusy && (
|
||||||
|
<svg className="ml-1 w-3.5 h-3.5 animate-spin text-slate-400" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Editor + Preview */}
|
||||||
|
{editorOpen && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k2fb166ad')}</div>
|
||||||
|
<input
|
||||||
|
value={editor.template_type}
|
||||||
|
onChange={(e) => { setEditor((s) => ({ ...s, template_type: e.target.value })); setI18nSyncStatus('idle'); }}
|
||||||
|
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
placeholder={t('autofix.k1a7aa84d')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k2fc164d2')}</div>
|
||||||
|
<input
|
||||||
|
value={editor.name}
|
||||||
|
onChange={(e) => setEditor((s) => ({ ...s, name: e.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
placeholder={t('autofix.k32764a91')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* i18n key info + sync status */}
|
||||||
|
{editor.template_type?.trim() && (
|
||||||
|
<div className="md:col-span-2 rounded-xl border border-sky-200 bg-sky-50/60 px-4 py-3 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-sky-700">
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||||
|
</svg>{t('autofix.k48b366e4')}</div>
|
||||||
|
{i18nSyncStatus === 'syncing' && (
|
||||||
|
<span className="text-[11px] text-sky-500 flex items-center gap-1">
|
||||||
|
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>{t('autofix.ke4326584')}</span>
|
||||||
|
)}
|
||||||
|
{i18nSyncStatus === 'synced' && (
|
||||||
|
<span className="text-[11px] text-emerald-600 font-semibold">{t('autofix.kf5fec72a')}</span>
|
||||||
|
)}
|
||||||
|
{i18nSyncStatus === 'error' && (
|
||||||
|
<span className="text-[11px] text-rose-600 font-semibold">{t('autofix.k5321f8f0')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 text-[11px] font-mono text-sky-800">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sky-400 select-none">{t('autofix.k8aea9103')}</span>
|
||||||
|
<span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{subjectI18nKey(editor.template_type)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sky-400 select-none">{t('autofix.k0aa53382')}</span>
|
||||||
|
<span className="select-all bg-sky-100 rounded px-1.5 py-0.5">{htmlI18nKey(editor.template_type)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Subject</div>
|
||||||
|
<input
|
||||||
|
value={editor.subject}
|
||||||
|
onChange={(e) => setEditor((s) => ({ ...s, subject: e.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
placeholder="Subject with placeholders like {{firstName}}"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.kb56e3ea8')}</div>
|
||||||
|
<textarea
|
||||||
|
value={editor.html_content}
|
||||||
|
onChange={(e) => setEditor((s) => ({ ...s, html_content: e.target.value }))}
|
||||||
|
className="min-h-[220px] w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
placeholder={t('autofix.k8735e9a4')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
|
||||||
|
<div className="mb-2 text-sm font-semibold text-slate-900">{t('autofix.kefc3a3f9')}</div>
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
|
<div className="mb-3 text-xs text-slate-500">
|
||||||
|
{t('autofix.k64efb463')}
|
||||||
|
<span className="font-medium text-slate-800">{editor.subject || t('autofix.k1b76fc38')}</span>
|
||||||
|
{selectedTemplate && !isCreating && (
|
||||||
|
<span className="ml-3">Last update: {new Date(selectedTemplate.updated_at).toLocaleString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none text-slate-800"
|
||||||
|
dangerouslySetInnerHTML={{ __html: editor.html_content || `<p class="text-slate-500">${t('autofix.k5201934d')}</p>` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -545,12 +545,8 @@ export default function useContractManagement() {
|
|||||||
company_street?: string
|
company_street?: string
|
||||||
company_postal_city?: string
|
company_postal_city?: string
|
||||||
company_country?: string
|
company_country?: string
|
||||||
// NEW: QR codes for invoices (base64 or data URL)
|
company_logo_base64?: string | null
|
||||||
qr_code_60_base64?: string | null
|
company_logo_mime_type?: string | null
|
||||||
qr_code_120_base64?: string | null
|
|
||||||
// NEW: allow camelCase too (backend supports both)
|
|
||||||
qrCode60Base64?: string | null
|
|
||||||
qrCode120Base64?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCompanySettings = useCallback(async () => {
|
const getCompanySettings = useCallback(async () => {
|
||||||
@ -558,35 +554,6 @@ export default function useContractManagement() {
|
|||||||
}, [authorizedFetch]);
|
}, [authorizedFetch]);
|
||||||
|
|
||||||
const updateCompanySettings = useCallback(async (data: Partial<CompanySettings>) => {
|
const updateCompanySettings = useCallback(async (data: Partial<CompanySettings>) => {
|
||||||
// Debug request body in browser console (redacts base64 values)
|
|
||||||
try {
|
|
||||||
// IMPORTANT: `data` is the real payload object; `redacted` is for logs only.
|
|
||||||
const json = JSON.stringify(data);
|
|
||||||
const redacted = redactJsonForLogs(data);
|
|
||||||
const qr60 = (data as any)?.qr_code_60_base64 ?? (data as any)?.qrCode60Base64;
|
|
||||||
const qr120 = (data as any)?.qr_code_120_base64 ?? (data as any)?.qrCode120Base64;
|
|
||||||
console.info('[CM][company-settings] PUT body', {
|
|
||||||
redacted,
|
|
||||||
jsonLength: json.length,
|
|
||||||
keys: Object.keys(data || {}),
|
|
||||||
qrFieldTypes: {
|
|
||||||
qr_code_60_base64: qr60 ? typeof qr60 : null,
|
|
||||||
qr_code_120_base64: qr120 ? typeof qr120 : null,
|
|
||||||
},
|
|
||||||
qrFieldLengths: {
|
|
||||||
qr_code_60_base64: typeof qr60 === 'string' ? qr60.length : null,
|
|
||||||
qr_code_120_base64: typeof qr120 === 'string' ? qr120.length : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (qr60 && typeof qr60 !== 'string') {
|
|
||||||
console.warn('[CM][company-settings] qr_code_60_base64 is not a string!', qr60);
|
|
||||||
}
|
|
||||||
if (qr120 && typeof qr120 !== 'string') {
|
|
||||||
console.warn('[CM][company-settings] qr_code_120_base64 is not a string!', qr120);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return authorizedFetch<CompanySettings>('/api/admin/company-settings', {
|
return authorizedFetch<CompanySettings>('/api/admin/company-settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|||||||
@ -1,31 +1,38 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useState, useSyncExternalStore } from 'react';
|
||||||
import PageLayout from '../../components/PageLayout';
|
import PageLayout from '../../components/PageLayout';
|
||||||
import ContractEditor from './components/contractEditor';
|
import ContractEditor from './components/contractEditor';
|
||||||
import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp';
|
import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp';
|
||||||
import CompanySettingsPanel from './components/companySettingsPanel';
|
import CompanySettingsPanel from './components/companySettingsPanel';
|
||||||
import ContractTemplateList from './components/contractTemplateList';
|
import ContractTemplateList from './components/contractTemplateList';
|
||||||
|
import MailTemplatesManager from './components/mailTemplatesManager';
|
||||||
import useAuthStore from '../../store/authStore';
|
import useAuthStore from '../../store/authStore';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ key: 'stamp', label: 'Company Stamp', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg> },
|
{ key: 'stamp', label: 'Company Details', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg> },
|
||||||
|
{ key: 'mailTemplates', label: 'Mail Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7l9 6 9-6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> },
|
||||||
{ key: 'templates', label: 'Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg> },
|
{ key: 'templates', label: 'Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg> },
|
||||||
{ key: 'editor', label: 'Create Template', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> },
|
{ key: 'editor', label: 'Create Template', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ContractManagementPage() {
|
export default function ContractManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const [mounted, setMounted] = useState(false);
|
const mounted = useSyncExternalStore(
|
||||||
|
() => () => {},
|
||||||
|
() => true,
|
||||||
|
() => false
|
||||||
|
);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [section, setSection] = useState('templates');
|
const [section, setSection] = useState('templates');
|
||||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||||||
const [editorKey, setEditorKey] = useState(0);
|
const [editorKey, setEditorKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => { setMounted(true); }, []);
|
|
||||||
|
|
||||||
// Only allow admin
|
// Only allow admin
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
!!user &&
|
!!user &&
|
||||||
@ -49,12 +56,10 @@ export default function ContractManagementPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_24%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.14),transparent_26%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_52%,#eef2ff_100%)]">
|
||||||
{/* tighter horizontal padding on mobile */}
|
<div className="mx-auto flex max-w-420 flex-col gap-6 px-4 py-6 sm:px-6 md:flex-row md:gap-10 md:py-10 lg:px-10">
|
||||||
<div className="flex flex-col md:flex-row max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 md:py-8 gap-6 md:gap-8">
|
<nav className="w-full md:sticky md:top-6 md:w-64 md:self-start">
|
||||||
{/* Sidebar Navigation (mobile = horizontal scroll tabs, desktop = vertical) */}
|
<div className="flex md:flex-col flex-row gap-2 md:gap-3 overflow-x-auto md:overflow-visible -mx-4 rounded-[28px] border border-white/70 bg-white/80 px-4 py-3 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.38)] backdrop-blur md:mx-0 md:px-3 md:py-3 pb-3 md:pb-3">
|
||||||
<nav className="md:w-56 w-full md:self-start md:sticky md:top-6">
|
|
||||||
<div className="flex md:flex-col flex-row gap-2 md:gap-3 overflow-x-auto md:overflow-visible -mx-4 px-4 md:mx-0 md:px-0 pb-2 md:pb-0">
|
|
||||||
{NAV.map((item) => (
|
{NAV.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
@ -69,10 +74,10 @@ export default function ContractManagementPage() {
|
|||||||
}
|
}
|
||||||
setSection(item.key);
|
setSection(item.key);
|
||||||
}}
|
}}
|
||||||
className={`flex flex-shrink-0 items-center gap-2 px-4 py-2 rounded-lg font-medium transition whitespace-nowrap text-sm md:text-base
|
className={`flex shrink-0 items-center gap-2 px-4 py-3 rounded-2xl font-medium transition whitespace-nowrap text-sm md:text-base
|
||||||
${section === item.key
|
${section === item.key
|
||||||
? 'bg-blue-900 text-blue-50 shadow'
|
? 'bg-slate-900 text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)]'
|
||||||
: 'bg-white text-blue-900 hover:bg-blue-50 hover:text-blue-900 border border-blue-200'}`}
|
: 'bg-transparent text-slate-700 hover:bg-slate-100 hover:text-slate-900 border border-transparent hover:border-slate-200'}`}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
@ -82,40 +87,41 @@ export default function ContractManagementPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 space-y-6 md:space-y-8">
|
<main className="flex-1 space-y-6 md:space-y-8 xl:space-y-9">
|
||||||
{/* sticky only on md+; smaller padding/title on mobile */}
|
<header className="rounded-[30px] border border-white/80 bg-white/85 px-5 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:px-8 md:py-8 xl:px-10">
|
||||||
<header className="md:sticky md:top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-5 px-4 md:py-10 md:px-8 rounded-2xl shadow-lg flex flex-col gap-3 md:gap-4 mb-2 md:mb-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-2xl md:text-4xl font-extrabold text-blue-900 tracking-tight">Contract Management</h1>
|
<div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">{t('autofix.k8351e02f')}</div>
|
||||||
<p className="text-sm md:text-lg text-blue-700">
|
<div>
|
||||||
Manage contract templates, company stamp, and create new templates.
|
<h1 className="text-3xl font-black tracking-tight text-slate-950 md:text-5xl">{t('autofix.k67cb36a4')}</h1>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-600 md:text-base">
|
||||||
|
{t('autofix.k39791457')}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Section Panels (compact padding on mobile) */}
|
|
||||||
{section === 'stamp' && (
|
{section === 'stamp' && (
|
||||||
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6">
|
<section className="rounded-[28px] border border-white/80 bg-white/85 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:p-6 xl:p-7">
|
||||||
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
<h2 className="mb-4 flex items-center gap-2 text-xl font-semibold text-slate-900">
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
<svg className="w-6 h-6" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
||||||
Company Stamp
|
{t('autofix.ka5f38d19')}
|
||||||
</h2>
|
</h2>
|
||||||
<ContractUploadCompanyStamp onUploaded={bumpRefresh} />
|
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold text-blue-900 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
|
|
||||||
Company Information
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500 mb-4">Address details used on invoices.</p>
|
|
||||||
<CompanySettingsPanel />
|
<CompanySettingsPanel />
|
||||||
|
|
||||||
|
<div className="mt-8 border-t border-slate-200 pt-6">
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
||||||
|
{t('autofix.k54d7cbef')}
|
||||||
|
</h3>
|
||||||
|
<ContractUploadCompanyStamp onUploaded={bumpRefresh} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{section === 'mailTemplates' && (
|
||||||
|
<MailTemplatesManager />
|
||||||
|
)}
|
||||||
{section === 'templates' && (
|
{section === 'templates' && (
|
||||||
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6">
|
<section className="rounded-[28px] border border-white/80 bg-white/60 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur md:p-5 xl:p-6">
|
||||||
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
|
||||||
Templates
|
|
||||||
</h2>
|
|
||||||
<ContractTemplateList
|
<ContractTemplateList
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
onEdit={(id) => {
|
onEdit={(id) => {
|
||||||
@ -127,10 +133,10 @@ export default function ContractManagementPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{section === 'editor' && (
|
{section === 'editor' && (
|
||||||
<section className="rounded-2xl bg-white shadow-lg border border-gray-100 p-4 md:p-6">
|
<section className="rounded-[28px] border border-white/80 bg-white/85 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:p-6 xl:p-7">
|
||||||
<h2 className="text-xl font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
<h2 className="mb-4 flex items-center gap-2 text-xl font-semibold text-slate-900">
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
<svg className="w-6 h-6" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
||||||
Create Template
|
{t('autofix.k22c8f7f1')}
|
||||||
</h2>
|
</h2>
|
||||||
<ContractEditor
|
<ContractEditor
|
||||||
key={`${editorKey}-${editingTemplateId ?? 'new'}`}
|
key={`${editorKey}-${editingTemplateId ?? 'new'}`}
|
||||||
|
|||||||
@ -12,8 +12,10 @@ import {
|
|||||||
type BackendPlatform = {
|
type BackendPlatform = {
|
||||||
id: string | number
|
id: string | number
|
||||||
title: string
|
title: string
|
||||||
|
titleKey?: string | null
|
||||||
href: string
|
href: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
descriptionKey?: string | null
|
||||||
icon?: DashboardPlatformIconName | null
|
icon?: DashboardPlatformIconName | null
|
||||||
color?: DashboardPlatformColorClass | null
|
color?: DashboardPlatformColorClass | null
|
||||||
state?: boolean
|
state?: boolean
|
||||||
@ -46,7 +48,9 @@ function toRow(p: BackendPlatform): PlatformRow {
|
|||||||
return {
|
return {
|
||||||
id: String(p.id),
|
id: String(p.id),
|
||||||
title: typeof p.title === 'string' ? p.title : '',
|
title: typeof p.title === 'string' ? p.title : '',
|
||||||
|
titleKey: typeof p.titleKey === 'string' ? p.titleKey : undefined,
|
||||||
description: typeof p.description === 'string' ? p.description : '',
|
description: typeof p.description === 'string' ? p.description : '',
|
||||||
|
descriptionKey: typeof p.descriptionKey === 'string' ? p.descriptionKey : undefined,
|
||||||
href: typeof p.href === 'string' ? p.href : '',
|
href: typeof p.href === 'string' ? p.href : '',
|
||||||
icon: FIXED_ICON,
|
icon: FIXED_ICON,
|
||||||
color: (p.color as DashboardPlatformColorClass) || ('bg-blue-500' as DashboardPlatformColorClass),
|
color: (p.color as DashboardPlatformColorClass) || ('bg-blue-500' as DashboardPlatformColorClass),
|
||||||
@ -59,8 +63,10 @@ function toRow(p: BackendPlatform): PlatformRow {
|
|||||||
function toPayload(p: PlatformRow) {
|
function toPayload(p: PlatformRow) {
|
||||||
return {
|
return {
|
||||||
title: p.title,
|
title: p.title,
|
||||||
|
titleKey: p.titleKey ?? '',
|
||||||
href: p.href,
|
href: p.href,
|
||||||
description: p.description ?? '',
|
description: p.description ?? '',
|
||||||
|
descriptionKey: p.descriptionKey ?? '',
|
||||||
icon: FIXED_ICON,
|
icon: FIXED_ICON,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
state: Boolean(p.isActive),
|
state: Boolean(p.isActive),
|
||||||
@ -72,8 +78,10 @@ function toPayload(p: PlatformRow) {
|
|||||||
function normalizeForCompare(p: PlatformRow) {
|
function normalizeForCompare(p: PlatformRow) {
|
||||||
return {
|
return {
|
||||||
title: (p.title || '').trim(),
|
title: (p.title || '').trim(),
|
||||||
|
titleKey: (p.titleKey || '').trim(),
|
||||||
href: (p.href || '').trim(),
|
href: (p.href || '').trim(),
|
||||||
description: (p.description || '').trim(),
|
description: (p.description || '').trim(),
|
||||||
|
descriptionKey: (p.descriptionKey || '').trim(),
|
||||||
icon: FIXED_ICON,
|
icon: FIXED_ICON,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
isActive: Boolean(p.isActive),
|
isActive: Boolean(p.isActive),
|
||||||
@ -90,8 +98,10 @@ function isChanged(p: PlatformRow, baselineById: Record<string, PlatformRow>): b
|
|||||||
const b = normalizeForCompare(baseline)
|
const b = normalizeForCompare(baseline)
|
||||||
return (
|
return (
|
||||||
a.title !== b.title ||
|
a.title !== b.title ||
|
||||||
|
a.titleKey !== b.titleKey ||
|
||||||
a.href !== b.href ||
|
a.href !== b.href ||
|
||||||
a.description !== b.description ||
|
a.description !== b.description ||
|
||||||
|
a.descriptionKey !== b.descriptionKey ||
|
||||||
a.color !== b.color ||
|
a.color !== b.color ||
|
||||||
a.isActive !== b.isActive ||
|
a.isActive !== b.isActive ||
|
||||||
a.disabled !== b.disabled ||
|
a.disabled !== b.disabled ||
|
||||||
@ -112,7 +122,10 @@ export function useAdminDashboardPlatforms() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const hasValidationErrors = useMemo(() => {
|
const hasValidationErrors = useMemo(() => {
|
||||||
return platforms.some(p => !p.title.trim() || !p.href.trim() || !isValidHref(p.href))
|
return platforms.some(p => {
|
||||||
|
const hasTitle = Boolean(p.title?.trim()) || Boolean(p.titleKey?.trim())
|
||||||
|
return !hasTitle || !p.href.trim() || !isValidHref(p.href)
|
||||||
|
})
|
||||||
}, [platforms])
|
}, [platforms])
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
@ -152,7 +165,9 @@ export function useAdminDashboardPlatforms() {
|
|||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
title: 'New Platform',
|
title: 'New Platform',
|
||||||
|
titleKey: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
descriptionKey: '',
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: FIXED_ICON,
|
icon: FIXED_ICON,
|
||||||
color: 'bg-blue-500' as DashboardPlatformColorClass,
|
color: 'bg-blue-500' as DashboardPlatformColorClass,
|
||||||
|
|||||||
@ -1,16 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import PageLayout from '../../components/PageLayout'
|
import PageLayout from '../../components/PageLayout'
|
||||||
import {
|
import {
|
||||||
DASHBOARD_PLATFORMS_COLOR_OPTIONS,
|
DASHBOARD_PLATFORMS_COLOR_OPTIONS,
|
||||||
type DashboardPlatform,
|
|
||||||
type DashboardPlatformColorClass
|
type DashboardPlatformColorClass
|
||||||
} from '../../utils/dashboardPlatforms'
|
} from '../../utils/dashboardPlatforms'
|
||||||
import { PlusIcon, TrashIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'
|
import { PlusIcon, TrashIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'
|
||||||
import { useAdminDashboardPlatforms, type PlatformRow } from './hooks/useAdminDashboardPlatforms'
|
import { useAdminDashboardPlatforms, type PlatformRow } from './hooks/useAdminDashboardPlatforms'
|
||||||
|
|
||||||
export default function AdminDashboardManagementPage() {
|
export default function AdminDashboardManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
platforms,
|
platforms,
|
||||||
loading,
|
loading,
|
||||||
@ -39,17 +42,24 @@ export default function AdminDashboardManagementPage() {
|
|||||||
|
|
||||||
const isOpen = (p: PlatformRow) => Boolean(openById[p.id] ?? p._isNew)
|
const isOpen = (p: PlatformRow) => Boolean(openById[p.id] ?? p._isNew)
|
||||||
|
|
||||||
|
const translatePreview = (key: string | undefined, fallback: string) => {
|
||||||
|
const trimmedKey = (key || '').trim()
|
||||||
|
if (!trimmedKey) return fallback
|
||||||
|
const translated = t(trimmedKey)
|
||||||
|
return translated === trimmedKey ? (fallback || trimmedKey) : translated
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="relative min-h-screen overflow-hidden bg-gradient-to-br from-[#f6f8ff] via-[#f3f7ff] to-[#eef4ff]">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 md:py-10">
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_12%_18%,rgba(59,130,246,0.16),transparent_38%),radial-gradient(circle_at_88%_0%,rgba(13,148,136,0.14),transparent_36%),radial-gradient(circle_at_76%_82%,rgba(245,158,11,0.12),transparent_30%)]" />
|
||||||
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
|
<div className="relative max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-6 md:py-10">
|
||||||
|
<div className="rounded-[30px] border border-white/80 bg-white/85 p-5 shadow-[0_30px_80px_-42px_rgba(15,23,42,0.35)] backdrop-blur-md sm:p-8">
|
||||||
<header className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
<header className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-blue-900 tracking-tight">Dashboard Management</h1>
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">{t('autofix.k09546aee')}</div>
|
||||||
<p className="text-sm sm:text-base text-blue-700 mt-2">
|
<h1 className="mt-3 text-3xl sm:text-4xl font-extrabold text-slate-900 tracking-tight break-words">{t('autofix.kc4315932')}</h1>
|
||||||
Manage the “Platforms” cards shown on the user dashboard.
|
<p className="text-sm sm:text-base text-slate-600 mt-2 break-words">{t('autofix.k098ec0b9')}</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
|
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||||
@ -57,66 +67,60 @@ export default function AdminDashboardManagementPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={addAndOpen}
|
onClick={addAndOpen}
|
||||||
disabled={loading || saving}
|
disabled={loading || saving}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 text-white px-4 py-2 text-sm font-semibold hover:bg-blue-800"
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-slate-900 text-white px-4 py-2 text-sm font-semibold hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-5 w-5" />
|
<PlusIcon className="h-5 w-5" />{t('autofix.k39e2c5db')}</button>
|
||||||
Add Platform
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={hasValidationErrors || loading || saving}
|
disabled={hasValidationErrors || loading || saving}
|
||||||
className={
|
className={
|
||||||
hasValidationErrors || loading || saving
|
hasValidationErrors || loading || saving
|
||||||
? 'inline-flex items-center justify-center gap-2 rounded-lg bg-gray-300 text-gray-600 px-4 py-2 text-sm font-semibold cursor-not-allowed'
|
? 'inline-flex items-center justify-center gap-2 rounded-2xl bg-slate-200 text-slate-500 px-4 py-2 text-sm font-semibold cursor-not-allowed'
|
||||||
: 'inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 text-white px-4 py-2 text-sm font-semibold hover:bg-emerald-500'
|
: 'inline-flex items-center justify-center gap-2 rounded-2xl bg-[#8D6B1D] text-white px-4 py-2 text-sm font-semibold hover:bg-[#7A5E1A]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CheckIcon className="h-5 w-5" />
|
<CheckIcon className="h-5 w-5" />{saving ? t('autofix.kac6cedc7') : 'Save'}</button>
|
||||||
{saving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50/90 px-4 py-3 text-sm text-red-800 backdrop-blur-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{savedAt && (
|
{savedAt && (
|
||||||
<div className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50/90 px-4 py-3 text-sm text-emerald-800 backdrop-blur-sm">
|
||||||
Saved at {new Date(savedAt).toLocaleTimeString('de-DE')}
|
Saved at {new Date(savedAt).toLocaleTimeString('de-DE')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasValidationErrors && (
|
{hasValidationErrors && (
|
||||||
<div className="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50/90 px-4 py-3 text-sm text-amber-900 backdrop-blur-sm">
|
||||||
Please ensure every platform has a title and a valid link (must start with “/” or “http(s)://”).
|
Please ensure every platform has a title or title key and a valid link (must start with “/” or “http(s)://”).
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 text-sm text-gray-600">
|
<div className="rounded-[24px] border border-white/80 bg-white/85 p-6 text-sm text-slate-600 shadow-[0_20px_50px_-34px_rgba(15,23,42,0.3)]">{t('autofix.k832387c5')}</div>
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && platforms.map(platform => (
|
{!loading && platforms.map(platform => (
|
||||||
<div key={platform.id} className="rounded-2xl bg-white border border-gray-100 shadow p-4 sm:p-5">
|
<div key={platform.id} className="rounded-[24px] border border-white/80 bg-white/85 p-4 shadow-[0_20px_50px_-34px_rgba(15,23,42,0.3)] backdrop-blur-md sm:p-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-base font-semibold text-gray-900 truncate">{platform.title}</div>
|
<div className="text-base font-semibold text-slate-900 break-words">{translatePreview(platform.titleKey, platform.title) || t('autofix.k39e2c5db')}</div>
|
||||||
<div className="text-xs text-gray-500 truncate">{platform.href}</div>
|
<div className="text-xs text-slate-500 break-all">{platform.href}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleOpen(platform.id)}
|
onClick={() => toggleOpen(platform.id)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white text-gray-800 px-3 py-2 text-xs font-semibold hover:bg-gray-50"
|
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white text-slate-800 px-3 py-2 text-xs font-semibold hover:bg-slate-50 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isOpen(platform) ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
|
{isOpen(platform) ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
|
||||||
{isOpen(platform) ? 'Close' : 'Edit'}
|
{isOpen(platform) ? 'Close' : 'Edit'}
|
||||||
@ -132,7 +136,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
await setPlatformState(platform, false)
|
await setPlatformState(platform, false)
|
||||||
}}
|
}}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-xs font-semibold hover:bg-red-100"
|
className="inline-flex items-center gap-2 rounded-xl border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-xs font-semibold hover:bg-red-100 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-4 w-4" />
|
<TrashIcon className="h-4 w-4" />
|
||||||
{platform._isNew ? 'Remove' : 'Deactivate'}
|
{platform._isNew ? 'Remove' : 'Deactivate'}
|
||||||
@ -141,63 +145,87 @@ export default function AdminDashboardManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOpen(platform) && (
|
{isOpen(platform) && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 rounded-2xl border border-white/80 bg-white/80 p-4">
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.kd96b6952')}</div>
|
||||||
|
<input
|
||||||
|
value={platform.titleKey || ''}
|
||||||
|
onChange={e => updatePlatform(platform.id, { titleKey: e.target.value })}
|
||||||
|
disabled={saving}
|
||||||
|
placeholder="dashboard.platformCards.custom.title"
|
||||||
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-xs text-slate-500 break-words">Preview: {translatePreview(platform.titleKey, platform.title)}</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<div className="text-xs font-semibold text-gray-700">Title</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k339260c9')}</div>
|
||||||
<input
|
<input
|
||||||
value={platform.title}
|
value={platform.title}
|
||||||
onChange={e => updatePlatform(platform.id, { title: e.target.value })}
|
onChange={e => updatePlatform(platform.id, { title: e.target.value })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="block">
|
|
||||||
<div className="text-xs font-semibold text-gray-700">Description</div>
|
|
||||||
<input
|
|
||||||
value={platform.description}
|
|
||||||
onChange={e => updatePlatform(platform.id, { description: e.target.value })}
|
|
||||||
disabled={saving}
|
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block md:col-span-2">
|
<label className="block md:col-span-2">
|
||||||
<div className="text-xs font-semibold text-gray-700">Link</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k2c0cfef4')}</div>
|
||||||
|
<input
|
||||||
|
value={platform.descriptionKey || ''}
|
||||||
|
onChange={e => updatePlatform(platform.id, { descriptionKey: e.target.value })}
|
||||||
|
disabled={saving}
|
||||||
|
placeholder="dashboard.platformCards.custom.description"
|
||||||
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-xs text-slate-500 break-words">Preview: {translatePreview(platform.descriptionKey, platform.description)}</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k4a292bef')}</div>
|
||||||
|
<input
|
||||||
|
value={platform.description}
|
||||||
|
onChange={e => updatePlatform(platform.id, { description: e.target.value })}
|
||||||
|
disabled={saving}
|
||||||
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">Link</div>
|
||||||
<input
|
<input
|
||||||
value={platform.href}
|
value={platform.href}
|
||||||
onChange={e => updatePlatform(platform.id, { href: e.target.value })}
|
onChange={e => updatePlatform(platform.id, { href: e.target.value })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
placeholder="Example: /shop or https://example.com"
|
placeholder={t('autofix.k17f65c37')}
|
||||||
className={
|
className={
|
||||||
'mt-1 w-full rounded-lg border px-3 py-2 text-sm ' +
|
'mt-1 w-full rounded-2xl border bg-white px-3 py-2 text-sm text-slate-900 ' +
|
||||||
(isValidHref(platform.href) ? 'border-gray-200' : 'border-red-300')
|
(isValidHref(platform.href) ? 'border-slate-200' : 'border-red-300')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!isValidHref(platform.href) && (
|
{!isValidHref(platform.href) && (
|
||||||
<div className="mt-1 text-xs text-red-600">Must start with “/” or “http(s)://”.</div>
|
<div className="mt-1 text-xs text-red-600">Must start with “/” or “http(s)://”.</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1 text-xs text-gray-500">
|
<div className="mt-1 text-xs text-slate-500">
|
||||||
Use a relative path (starts with “/”) for internal pages, or a full URL for external pages.
|
Use a relative path (starts with “/”) for internal pages, or a full URL for external pages.
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<div className="text-xs font-semibold text-gray-700">Icon</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">Icon</div>
|
||||||
<input
|
<input
|
||||||
value={'Link'}
|
value={'Link'}
|
||||||
disabled
|
disabled
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700"
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<div className="text-xs font-semibold text-gray-700">Color</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">Color</div>
|
||||||
<select
|
<select
|
||||||
value={platform.color}
|
value={platform.color}
|
||||||
onChange={e => updatePlatform(platform.id, { color: e.target.value as DashboardPlatformColorClass })}
|
onChange={e => updatePlatform(platform.id, { color: e.target.value as DashboardPlatformColorClass })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
>
|
>
|
||||||
{DASHBOARD_PLATFORMS_COLOR_OPTIONS.map(opt => (
|
{DASHBOARD_PLATFORMS_COLOR_OPTIONS.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
@ -206,7 +234,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 md:col-span-2">
|
<div className="flex flex-wrap gap-4 md:col-span-2">
|
||||||
<label className="inline-flex items-center gap-2 text-sm">
|
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={platform.isActive}
|
checked={platform.isActive}
|
||||||
@ -216,7 +244,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
Active (visible on dashboard)
|
Active (visible on dashboard)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm">
|
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={Boolean(platform.disabled)}
|
checked={Boolean(platform.disabled)}
|
||||||
@ -229,12 +257,12 @@ export default function AdminDashboardManagementPage() {
|
|||||||
|
|
||||||
{platform.disabled && (
|
{platform.disabled && (
|
||||||
<label className="block md:col-span-2">
|
<label className="block md:col-span-2">
|
||||||
<div className="text-xs font-semibold text-gray-700">Disabled message</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.kab99811e')}</div>
|
||||||
<input
|
<input
|
||||||
value={platform.disabledText || ''}
|
value={platform.disabledText || ''}
|
||||||
onChange={e => updatePlatform(platform.id, { disabledText: e.target.value })}
|
onChange={e => updatePlatform(platform.id, { disabledText: e.target.value })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@ -246,9 +274,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{!loading && platforms.length === 0 && (
|
{!loading && platforms.length === 0 && (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-8 text-center text-sm text-gray-600">
|
<div className="rounded-[24px] border border-white/80 bg-white/85 p-8 text-center text-sm text-slate-600 shadow-[0_20px_50px_-34px_rgba(15,23,42,0.3)]">{t('autofix.kbce9fbea')}</div>
|
||||||
No platforms configured.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Header from '../../components/nav/Header'
|
import Header from '../../components/nav/Header'
|
||||||
import Footer from '../../components/Footer'
|
import Footer from '../../components/Footer'
|
||||||
@ -11,6 +14,7 @@ import { importSqlDump, SqlExecutionData, SqlExecutionMeta } from './hooks/execu
|
|||||||
import { createFolderStructure, listFolderStructureIssues, listLooseFiles, listGhostDirectories, moveLooseFilesToContract, FixResult, FolderStructureIssueUser, GhostDirectory, LooseFileUser } from './hooks/exoscaleMaintenance'
|
import { createFolderStructure, listFolderStructureIssues, listLooseFiles, listGhostDirectories, moveLooseFilesToContract, FixResult, FolderStructureIssueUser, GhostDirectory, LooseFileUser } from './hooks/exoscaleMaintenance'
|
||||||
|
|
||||||
export default function DevManagementPage() {
|
export default function DevManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
|
|
||||||
@ -264,18 +268,16 @@ export default function DevManagementPage() {
|
|||||||
<CommandLineIcon className="h-6 w-6 text-blue-700" />
|
<CommandLineIcon className="h-6 w-6 text-blue-700" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-blue-900">Dev Management</h1>
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-blue-900">{t('autofix.k664072a1')}</h1>
|
||||||
<p className="text-sm sm:text-base text-blue-700">
|
<p className="text-sm sm:text-base text-blue-700">{t('autofix.k6e4a6069')}</p>
|
||||||
Import SQL dump files to run database migrations.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 flex items-start gap-2">
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 flex items-start gap-2">
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
<ExclamationTriangleIcon className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">Use with caution</div>
|
<div className="font-semibold">{t('autofix.k6c6e5c0f')}</div>
|
||||||
<div>SQL dumps run immediately and can modify production data.</div>
|
<div>{t('autofix.k8a35cc53')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -288,32 +290,28 @@ export default function DevManagementPage() {
|
|||||||
activeTab === 'sql' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
activeTab === 'sql' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CommandLineIcon className="h-4 w-4" /> SQL Import
|
<CommandLineIcon className="h-4 w-4" />{t('autofix.k4db68c96')}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('structure')}
|
onClick={() => setActiveTab('structure')}
|
||||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||||
activeTab === 'structure' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
activeTab === 'structure' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<WrenchScrewdriverIcon className="h-4 w-4" /> Folder Structure
|
<WrenchScrewdriverIcon className="h-4 w-4" />{t('autofix.kcb491706')}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('loose')}
|
onClick={() => setActiveTab('loose')}
|
||||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||||
activeTab === 'loose' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
activeTab === 'loose' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FolderOpenIcon className="h-4 w-4" /> Loose Files
|
<FolderOpenIcon className="h-4 w-4" />{t('autofix.k04b5cbca')}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('ghost')}
|
onClick={() => setActiveTab('ghost')}
|
||||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||||
activeTab === 'ghost' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
activeTab === 'ghost' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FolderOpenIcon className="h-4 w-4" /> Ghost Directories
|
<FolderOpenIcon className="h-4 w-4" />{t('autofix.k6838438d')}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -322,7 +320,7 @@ export default function DevManagementPage() {
|
|||||||
<section className="mt-6 md:mt-8 grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
|
<section className="mt-6 md:mt-8 grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
|
||||||
<div className="lg:col-span-2 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
<div className="lg:col-span-2 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||||
<h2 className="text-lg font-semibold text-blue-900">SQL Dump Import</h2>
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k981b1f1a')}</h2>
|
||||||
|
|
||||||
{/* actions: stack on mobile, full width */}
|
{/* actions: stack on mobile, full width */}
|
||||||
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-2 sm:gap-3">
|
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-2 sm:gap-3">
|
||||||
@ -330,8 +328,7 @@ export default function DevManagementPage() {
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<ArrowUpTrayIcon className="h-4 w-4" /> Import SQL
|
<ArrowUpTrayIcon className="h-4 w-4" />{t('autofix.k8a59b156')}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={clearResults}
|
onClick={clearResults}
|
||||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
@ -349,8 +346,8 @@ export default function DevManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-dashed border-gray-200 bg-slate-50 px-4 sm:px-6 py-8 sm:py-10 text-center">
|
<div className="rounded-xl border border-dashed border-gray-200 bg-slate-50 px-4 sm:px-6 py-8 sm:py-10 text-center">
|
||||||
<div className="text-sm text-gray-600">Select a .sql dump file using Import SQL.</div>
|
<div className="text-sm text-gray-600">{t('autofix.kb6eacc9d')}</div>
|
||||||
<div className="mt-2 text-xs text-gray-500">Only SQL dump files are supported.</div>
|
<div className="mt-2 text-xs text-gray-500">{t('autofix.k3ac8ca10')}</div>
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
<div className="mt-4 text-sm text-blue-900 font-semibold break-words">
|
<div className="mt-4 text-sm text-blue-900 font-semibold break-words">
|
||||||
Selected: {selectedFile.name}
|
Selected: {selectedFile.name}
|
||||||
@ -374,10 +371,10 @@ export default function DevManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||||
<h3 className="text-lg font-semibold text-blue-900 mb-3">Result Summary</h3>
|
<h3 className="text-lg font-semibold text-blue-900 mb-3">{t('autofix.kde2b4fa0')}</h3>
|
||||||
<div className="space-y-2 text-sm text-gray-700">
|
<div className="space-y-2 text-sm text-gray-700">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Result Sets</span>
|
<span>{t('autofix.k7938d4fd')}</span>
|
||||||
<span className="font-semibold text-blue-900">
|
<span className="font-semibold text-blue-900">
|
||||||
{Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0}
|
{Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0}
|
||||||
</span>
|
</span>
|
||||||
@ -391,19 +388,17 @@ export default function DevManagementPage() {
|
|||||||
<span className="font-semibold text-blue-900">{meta?.durationMs ? `${meta.durationMs} ms` : '-'}</span>
|
<span className="font-semibold text-blue-900">{meta?.durationMs ? `${meta.durationMs} ms` : '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 text-xs text-gray-500">
|
<div className="mt-6 text-xs text-gray-500">{t('autofix.k0f0395ca')}</div>
|
||||||
Multi-statement SQL and dump files are supported. Use with caution.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-blue-900">Import Results</h2>
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.kb4675362')}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!result && (
|
{!result && (
|
||||||
<div className="text-sm text-gray-500">No results yet. Import a SQL dump to see output.</div>
|
<div className="text-sm text-gray-500">{t('autofix.k23c9f0ff')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result?.result && (
|
{result?.result && (
|
||||||
@ -419,10 +414,8 @@ export default function DevManagementPage() {
|
|||||||
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-lg font-semibold text-blue-900">Exoscale Folder Structure</h2>
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.kd51f320c')}</h2>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">{t('autofix.kb1341138')}</p>
|
||||||
Ensures both contract and gdpr folders exist for each user.
|
|
||||||
</p>
|
|
||||||
{structureStatus && (
|
{structureStatus && (
|
||||||
<div className="text-xs text-slate-500">{structureStatus}</div>
|
<div className="text-xs text-slate-500">{structureStatus}</div>
|
||||||
)}
|
)}
|
||||||
@ -440,8 +433,7 @@ export default function DevManagementPage() {
|
|||||||
disabled={fixingAll || exoscaleLoading || structureUsers.length === 0}
|
disabled={fixingAll || exoscaleLoading || structureUsers.length === 0}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingAll ? 'Creating...' : 'Create All'}
|
<WrenchScrewdriverIcon className="h-4 w-4" />{fixingAll ? 'Creating...' : t('autofix.k1db77fc0')}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -462,9 +454,9 @@ export default function DevManagementPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{exoscaleLoading ? (
|
{exoscaleLoading ? (
|
||||||
<div className="text-sm text-gray-500">Loading folder issues...</div>
|
<div className="text-sm text-gray-500">{t('autofix.k8358f1d1')}</div>
|
||||||
) : structureUsers.length === 0 ? (
|
) : structureUsers.length === 0 ? (
|
||||||
<div className="text-sm text-gray-500">No missing folders found. Run Refresh to scan again.</div>
|
<div className="text-sm text-gray-500">{t('autofix.k9e609523')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
||||||
{structureUsers.map(user => {
|
{structureUsers.map(user => {
|
||||||
@ -478,7 +470,7 @@ export default function DevManagementPage() {
|
|||||||
<div className="text-xs text-gray-500 truncate">
|
<div className="text-xs text-gray-500 truncate">
|
||||||
#{user.userId} • {user.email} • {user.userType} • {user.contractCategory}
|
#{user.userId} • {user.email} • {user.userType} • {user.contractCategory}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">Missing: <span className="font-semibold text-blue-900">{missing || 'none'}</span></div>
|
<div className="mt-1 text-xs text-gray-600">{t('autofix.kd058bb7b')}<span className="font-semibold text-blue-900">{missing || 'none'}</span></div>
|
||||||
{fix && (
|
{fix && (
|
||||||
<div className="mt-2 text-xs text-emerald-700">
|
<div className="mt-2 text-xs text-emerald-700">
|
||||||
Created {fix.created || 0}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}.
|
Created {fix.created || 0}{fix.errors && fix.errors.length > 0 ? `, errors ${fix.errors.length}` : ''}.
|
||||||
@ -502,7 +494,7 @@ export default function DevManagementPage() {
|
|||||||
|
|
||||||
{(structureActionMeta || structureActionResults.length > 0) && (
|
{(structureActionMeta || structureActionResults.length > 0) && (
|
||||||
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||||
<div className="text-sm font-semibold text-blue-900 mb-2">Last Folder Structure Action</div>
|
<div className="text-sm font-semibold text-blue-900 mb-2">{t('autofix.k941fd092')}</div>
|
||||||
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
|
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
|
||||||
<span>Processed: {structureActionMeta?.processedUsers ?? '-'}</span>
|
<span>Processed: {structureActionMeta?.processedUsers ?? '-'}</span>
|
||||||
<span>Created: {structureActionMeta?.createdTotal ?? '-'}</span>
|
<span>Created: {structureActionMeta?.createdTotal ?? '-'}</span>
|
||||||
@ -533,10 +525,8 @@ export default function DevManagementPage() {
|
|||||||
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-lg font-semibold text-blue-900">Loose Files</h2>
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k04b5cbca')}</h2>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">{t('autofix.kbff01823')}</p>
|
||||||
Shows files directly under the user folder that are not in contract or gdpr.
|
|
||||||
</p>
|
|
||||||
{looseStatus && (
|
{looseStatus && (
|
||||||
<div className="text-xs text-slate-500">{looseStatus}</div>
|
<div className="text-xs text-slate-500">{looseStatus}</div>
|
||||||
)}
|
)}
|
||||||
@ -554,8 +544,7 @@ export default function DevManagementPage() {
|
|||||||
disabled={fixingAll || exoscaleLoading || looseUsers.length === 0}
|
disabled={fixingAll || exoscaleLoading || looseUsers.length === 0}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-800 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<WrenchScrewdriverIcon className="h-4 w-4" /> {fixingAll ? 'Moving...' : 'Move All to Contract'}
|
<WrenchScrewdriverIcon className="h-4 w-4" />{fixingAll ? 'Moving...' : t('autofix.k0188c7bc')}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -576,9 +565,9 @@ export default function DevManagementPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{exoscaleLoading ? (
|
{exoscaleLoading ? (
|
||||||
<div className="text-sm text-gray-500">Loading loose files...</div>
|
<div className="text-sm text-gray-500">{t('autofix.k8193b7a2')}</div>
|
||||||
) : looseUsers.length === 0 ? (
|
) : looseUsers.length === 0 ? (
|
||||||
<div className="text-sm text-gray-500">No loose files found. Run Refresh to scan again.</div>
|
<div className="text-sm text-gray-500">{t('autofix.k1db0c7cd')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
||||||
{looseUsers.map(user => {
|
{looseUsers.map(user => {
|
||||||
@ -591,8 +580,7 @@ export default function DevManagementPage() {
|
|||||||
<div className="text-xs text-gray-500 truncate">
|
<div className="text-xs text-gray-500 truncate">
|
||||||
#{user.userId} • {user.email} • {user.userType} • {user.contractCategory}
|
#{user.userId} • {user.email} • {user.userType} • {user.contractCategory}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">
|
<div className="mt-1 text-xs text-gray-600">{t('autofix.kf340aa10')}<span className="font-semibold text-blue-900">{user.looseObjects}</span>
|
||||||
Loose files: <span className="font-semibold text-blue-900">{user.looseObjects}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{user.sampleKeys && user.sampleKeys.length > 0 && (
|
{user.sampleKeys && user.sampleKeys.length > 0 && (
|
||||||
<div className="mt-2 text-[11px] text-gray-400 break-all">
|
<div className="mt-2 text-[11px] text-gray-400 break-all">
|
||||||
@ -622,7 +610,7 @@ export default function DevManagementPage() {
|
|||||||
|
|
||||||
{(looseActionMeta || looseActionResults.length > 0) && (
|
{(looseActionMeta || looseActionResults.length > 0) && (
|
||||||
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||||
<div className="text-sm font-semibold text-blue-900 mb-2">Last Loose Files Action</div>
|
<div className="text-sm font-semibold text-blue-900 mb-2">{t('autofix.kcf61fc9e')}</div>
|
||||||
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
|
<div className="text-xs text-gray-600 flex flex-wrap gap-3">
|
||||||
<span>Processed: {looseActionMeta?.processedUsers ?? '-'}</span>
|
<span>Processed: {looseActionMeta?.processedUsers ?? '-'}</span>
|
||||||
<span>Moved: {looseActionMeta?.movedTotal ?? '-'}</span>
|
<span>Moved: {looseActionMeta?.movedTotal ?? '-'}</span>
|
||||||
@ -654,10 +642,8 @@ export default function DevManagementPage() {
|
|||||||
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-lg font-semibold text-blue-900">Ghost Directories</h2>
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k6838438d')}</h2>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">{t('autofix.k77444d5b')}</p>
|
||||||
Exoscale directories that do not have a matching user in the database.
|
|
||||||
</p>
|
|
||||||
{ghostStatus && (
|
{ghostStatus && (
|
||||||
<div className="text-xs text-slate-500">{ghostStatus}</div>
|
<div className="text-xs text-slate-500">{ghostStatus}</div>
|
||||||
)}
|
)}
|
||||||
@ -682,9 +668,9 @@ export default function DevManagementPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{exoscaleLoading ? (
|
{exoscaleLoading ? (
|
||||||
<div className="text-sm text-gray-500">Loading ghost directories...</div>
|
<div className="text-sm text-gray-500">{t('autofix.k883ea8c5')}</div>
|
||||||
) : ghostDirs.length === 0 ? (
|
) : ghostDirs.length === 0 ? (
|
||||||
<div className="text-sm text-gray-500">No ghost directories found. Run Refresh to scan again.</div>
|
<div className="text-sm text-gray-500">{t('autofix.k12a7170a')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
<div className="divide-y divide-gray-100 rounded-xl border border-gray-100">
|
||||||
{ghostDirs.map(dir => (
|
{ghostDirs.map(dir => (
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import useAuthStore from '../../../store/authStore'
|
import useAuthStore from '../../../store/authStore'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
|
|
||||||
/* ---------- types ---------- */
|
/* ---------- types ---------- */
|
||||||
export type AdminInvoice = {
|
export type AdminInvoice = {
|
||||||
@ -85,11 +86,11 @@ const STATUSES = ['draft', 'issued', 'paid', 'overdue', 'canceled'] as const
|
|||||||
type InvoiceStatus = (typeof STATUSES)[number]
|
type InvoiceStatus = (typeof STATUSES)[number]
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<InvoiceStatus, { label: string; bg: string; text: string; icon: React.ElementType }> = {
|
const STATUS_CONFIG: Record<InvoiceStatus, { label: string; bg: string; text: string; icon: React.ElementType }> = {
|
||||||
draft: { label: 'Draft', bg: 'bg-gray-100', text: 'text-gray-700', icon: PencilSquareIcon },
|
draft: { label: 'draft', bg: 'bg-gray-100', text: 'text-gray-700', icon: PencilSquareIcon },
|
||||||
issued: { label: 'Issued', bg: 'bg-indigo-100', text: 'text-indigo-700', icon: DocumentTextIcon },
|
issued: { label: 'issued', bg: 'bg-indigo-100', text: 'text-indigo-700', icon: DocumentTextIcon },
|
||||||
paid: { label: 'Paid', bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircleIcon },
|
paid: { label: 'paid', bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircleIcon },
|
||||||
overdue: { label: 'Overdue', bg: 'bg-red-100', text: 'text-red-700', icon: ExclamationCircleIcon },
|
overdue: { label: 'overdue', bg: 'bg-red-100', text: 'text-red-700', icon: ExclamationCircleIcon },
|
||||||
canceled: { label: 'Canceled', bg: 'bg-yellow-100', text: 'text-yellow-700', icon: NoSymbolIcon },
|
canceled: { label: 'canceled', bg: 'bg-yellow-100', text: 'text-yellow-700', icon: NoSymbolIcon },
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDate(d?: string | null) {
|
function fmtDate(d?: string | null) {
|
||||||
@ -116,6 +117,7 @@ export default function InvoiceDetailModal({
|
|||||||
onExport,
|
onExport,
|
||||||
}: InvoiceDetailModalProps) {
|
}: InvoiceDetailModalProps) {
|
||||||
const token = useAuthStore((s) => s.accessToken)
|
const token = useAuthStore((s) => s.accessToken)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
// detail data
|
// detail data
|
||||||
const [items, setItems] = useState<InvoiceItem[]>([])
|
const [items, setItems] = useState<InvoiceItem[]>([])
|
||||||
@ -274,10 +276,10 @@ export default function InvoiceDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title className="text-lg font-bold text-white">
|
<Dialog.Title className="text-lg font-bold text-white">
|
||||||
Invoice {invoice.invoice_number ?? `#${invoice.id}`}
|
{t('invoiceDetailModal.invoiceTitle')} {invoice.invoice_number ?? `#${invoice.id}`}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<p className="text-sm text-blue-200/80">
|
<p className="text-sm text-blue-200/80">
|
||||||
Created {fmtDateTime(invoice.created_at)}
|
{t('invoiceDetailModal.created')} {fmtDateTime(invoice.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -297,12 +299,12 @@ export default function InvoiceDetailModal({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold ${statusConf.bg} ${statusConf.text}`}>
|
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold ${statusConf.bg} ${statusConf.text}`}>
|
||||||
<StatusIcon className="h-4 w-4" />
|
<StatusIcon className="h-4 w-4" />
|
||||||
{statusConf.label}
|
{t(`invoiceDetailModal.status${statusConf.label.charAt(0).toUpperCase() + statusConf.label.slice(1)}` as any)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs text-gray-500 mr-1">Change status:</span>
|
<span className="text-xs text-gray-500 mr-1">{t('invoiceDetailModal.changeStatus')}</span>
|
||||||
{STATUSES.map((s) => {
|
{STATUSES.map((s) => {
|
||||||
const sc = STATUS_CONFIG[s]
|
const sc = STATUS_CONFIG[s]
|
||||||
const active = s === currentStatus
|
const active = s === currentStatus
|
||||||
@ -317,7 +319,7 @@ export default function InvoiceDetailModal({
|
|||||||
: 'border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-40'
|
: 'border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{sc.label}
|
{t(`invoiceDetailModal.status${sc.label.charAt(0).toUpperCase() + sc.label.slice(1)}` as any)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -327,7 +329,7 @@ export default function InvoiceDetailModal({
|
|||||||
{/* status feedback */}
|
{/* status feedback */}
|
||||||
{changingStatus && (
|
{changingStatus && (
|
||||||
<div className="rounded-lg bg-blue-50 border border-blue-100 px-3 py-2 text-sm text-blue-700 flex items-center gap-2">
|
<div className="rounded-lg bg-blue-50 border border-blue-100 px-3 py-2 text-sm text-blue-700 flex items-center gap-2">
|
||||||
<ArrowPathIcon className="h-4 w-4 animate-spin" /> Updating status…
|
<ArrowPathIcon className="h-4 w-4 animate-spin" /> {t('invoiceDetailModal.updatingStatus')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{statusMsg && (
|
{statusMsg && (
|
||||||
@ -346,46 +348,46 @@ export default function InvoiceDetailModal({
|
|||||||
{/* Customer info */}
|
{/* Customer info */}
|
||||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-1">
|
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-1">
|
||||||
<UserIcon className="h-4 w-4" /> Customer
|
<UserIcon className="h-4 w-4" /> {t('invoiceDetailModal.customer')}
|
||||||
</div>
|
</div>
|
||||||
<InfoRow label="Name" value={invoice.buyer_name} />
|
<InfoRow label={t('invoiceDetailModal.name')} value={invoice.buyer_name} />
|
||||||
<InfoRow label="Email" value={invoice.buyer_email} />
|
<InfoRow label={t('invoiceDetailModal.email')} value={invoice.buyer_email} />
|
||||||
<InfoRow label="Street" value={invoice.buyer_street} />
|
<InfoRow label={t('invoiceDetailModal.street')} value={invoice.buyer_street} />
|
||||||
<InfoRow label="City" value={[invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')} />
|
<InfoRow label={t('invoiceDetailModal.city')} value={[invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')} />
|
||||||
<InfoRow label="Country" value={invoice.buyer_country} />
|
<InfoRow label={t('invoiceDetailModal.country')} value={invoice.buyer_country} />
|
||||||
<InfoRow label="User ID" value={invoice.user_id != null ? String(invoice.user_id) : null} />
|
<InfoRow label={t('invoiceDetailModal.userId')} value={invoice.user_id != null ? String(invoice.user_id) : null} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Financial info */}
|
{/* Financial info */}
|
||||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-1">
|
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-1">
|
||||||
<BanknotesIcon className="h-4 w-4" /> Financials
|
<BanknotesIcon className="h-4 w-4" /> {t('invoiceDetailModal.financials')}
|
||||||
</div>
|
</div>
|
||||||
<InfoRow label="Net" value={fmtMoney(invoice.total_net, invoice.currency ?? 'EUR')} />
|
<InfoRow label={t('invoiceDetailModal.net')} value={fmtMoney(invoice.total_net, invoice.currency ?? 'EUR')} />
|
||||||
<InfoRow label="Tax" value={fmtMoney(invoice.total_tax, invoice.currency ?? 'EUR')} />
|
<InfoRow label={t('invoiceDetailModal.tax')} value={fmtMoney(invoice.total_tax, invoice.currency ?? 'EUR')} />
|
||||||
<InfoRow label="Gross" value={fmtMoney(invoice.total_gross, invoice.currency ?? 'EUR')} highlight />
|
<InfoRow label={t('invoiceDetailModal.gross')} value={fmtMoney(invoice.total_gross, invoice.currency ?? 'EUR')} highlight />
|
||||||
<InfoRow label="VAT Rate" value={invoice.vat_rate != null ? `${invoice.vat_rate}%` : '—'} />
|
<InfoRow label={t('invoiceDetailModal.vatRate')} value={invoice.vat_rate != null ? `${invoice.vat_rate}%` : '—'} />
|
||||||
<InfoRow label="Currency" value={invoice.currency ?? 'EUR'} />
|
<InfoRow label={t('invoiceDetailModal.currency')} value={invoice.currency ?? 'EUR'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dates */}
|
{/* Dates */}
|
||||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-2">
|
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-2">
|
||||||
<CalendarDaysIcon className="h-4 w-4" /> Dates
|
<CalendarDaysIcon className="h-4 w-4" /> {t('invoiceDetailModal.dates')}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||||
<DateChip label="Issued" value={invoice.issued_at} />
|
<DateChip label={t('invoiceDetailModal.issued')} value={invoice.issued_at} />
|
||||||
<DateChip label="Due" value={invoice.due_at} />
|
<DateChip label={t('invoiceDetailModal.due')} value={invoice.due_at} />
|
||||||
<DateChip label="Created" value={invoice.created_at} />
|
<DateChip label={t('invoiceDetailModal.created')} value={invoice.created_at} />
|
||||||
<DateChip label="Updated" value={invoice.updated_at} />
|
<DateChip label={t('invoiceDetailModal.updated')} value={invoice.updated_at} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line items */}
|
{/* Line items */}
|
||||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-3">
|
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-3">
|
||||||
<DocumentTextIcon className="h-4 w-4" /> Line Items
|
<DocumentTextIcon className="h-4 w-4" /> {t('invoiceDetailModal.lineItems')}
|
||||||
</div>
|
</div>
|
||||||
{detailLoading ? (
|
{detailLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -395,17 +397,17 @@ export default function InvoiceDetailModal({
|
|||||||
) : detailError ? (
|
) : detailError ? (
|
||||||
<div className="text-sm text-red-600">{detailError}</div>
|
<div className="text-sm text-red-600">{detailError}</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="text-sm text-gray-500">No line items found.</div>
|
<div className="text-sm text-gray-500">{t('invoiceDetailModal.noLineItems')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
|
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
|
||||||
<th className="pb-2 pr-4 font-medium">Description</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.description')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Qty</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.qty')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Unit Price</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.unitPrice')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Tax</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.tax')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium text-right">Gross</th>
|
<th className="pb-2 pr-4 font-medium text-right">{t('invoiceDetailModal.gross')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
@ -421,7 +423,7 @@ export default function InvoiceDetailModal({
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t border-gray-200">
|
<tr className="border-t border-gray-200">
|
||||||
<td colSpan={4} className="pt-2 text-right font-semibold text-gray-700">Total</td>
|
<td colSpan={4} className="pt-2 text-right font-semibold text-gray-700">{t('invoiceDetailModal.total')}</td>
|
||||||
<td className="pt-2 text-right font-bold text-[#1C2B4A]">{fmtMoney(invoice.total_gross)}</td>
|
<td className="pt-2 text-right font-bold text-[#1C2B4A]">{fmtMoney(invoice.total_gross)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@ -434,17 +436,17 @@ export default function InvoiceDetailModal({
|
|||||||
{payments.length > 0 && (
|
{payments.length > 0 && (
|
||||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-3">
|
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-3">
|
||||||
<ShieldCheckIcon className="h-4 w-4" /> Payments
|
<ShieldCheckIcon className="h-4 w-4" /> {t('invoiceDetailModal.payments')}
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
|
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
|
||||||
<th className="pb-2 pr-4 font-medium">Method</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.method')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Transaction</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.transaction')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Amount</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.amount')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Paid At</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.paidAt')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Status</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.status')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
@ -471,8 +473,8 @@ export default function InvoiceDetailModal({
|
|||||||
{invoice.context && (
|
{invoice.context && (
|
||||||
<details className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 group">
|
<details className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 group">
|
||||||
<summary className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] cursor-pointer select-none">
|
<summary className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] cursor-pointer select-none">
|
||||||
<ClockIcon className="h-4 w-4" /> Context / Metadata
|
<ClockIcon className="h-4 w-4" /> {t('invoiceDetailModal.contextMetadata')}
|
||||||
<span className="text-xs font-normal text-gray-400 ml-1">(click to expand)</span>
|
<span className="text-xs font-normal text-gray-400 ml-1">{t('invoiceDetailModal.clickToExpand')}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-3 text-xs text-gray-600 overflow-x-auto whitespace-pre-wrap break-all max-h-48">
|
<pre className="mt-3 text-xs text-gray-600 overflow-x-auto whitespace-pre-wrap break-all max-h-48">
|
||||||
{typeof invoice.context === 'string' ? invoice.context : JSON.stringify(invoice.context, null, 2)}
|
{typeof invoice.context === 'string' ? invoice.context : JSON.stringify(invoice.context, null, 2)}
|
||||||
@ -488,20 +490,20 @@ export default function InvoiceDetailModal({
|
|||||||
onClick={() => onExport?.(invoice)}
|
onClick={() => onExport?.(invoice)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
|
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
|
||||||
>
|
>
|
||||||
<ArrowDownTrayIcon className="h-4 w-4" /> Export JSON
|
<ArrowDownTrayIcon className="h-4 w-4" /> {t('invoiceDetailModal.exportJson')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRunPoolCheck?.(invoice.id)}
|
onClick={() => onRunPoolCheck?.(invoice.id)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
|
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
|
||||||
>
|
>
|
||||||
<ArrowPathIcon className="h-4 w-4" /> Pool Check
|
<ArrowPathIcon className="h-4 w-4" /> {t('invoiceDetailModal.poolCheck')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="inline-flex items-center rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white hover:bg-[#1C2B4A]/90 transition"
|
className="inline-flex items-center rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white hover:bg-[#1C2B4A]/90 transition"
|
||||||
>
|
>
|
||||||
Close
|
{t('invoiceDetailModal.close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|||||||
@ -27,6 +27,12 @@ export type AdminInvoice = {
|
|||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminInvoiceRevenueSummary = {
|
||||||
|
totalPaidAllTime: number;
|
||||||
|
currency?: string | null;
|
||||||
|
paidInvoiceCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function useAdminInvoices(params?: { status?: string; limit?: number; offset?: number }) {
|
export function useAdminInvoices(params?: { status?: string; limit?: number; offset?: number }) {
|
||||||
const accessToken = useAuthStore(s => s.accessToken);
|
const accessToken = useAuthStore(s => s.accessToken);
|
||||||
const [invoices, setInvoices] = useState<AdminInvoice[]>([]);
|
const [invoices, setInvoices] = useState<AdminInvoice[]>([]);
|
||||||
@ -91,3 +97,57 @@ export function useAdminInvoices(params?: { status?: string; limit?: number; off
|
|||||||
|
|
||||||
return { invoices, loading, error, reload: fetchInvoices };
|
return { invoices, loading, error, reload: fetchInvoices };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAdminInvoiceRevenueSummary() {
|
||||||
|
const accessToken = useAuthStore(s => s.accessToken);
|
||||||
|
const [summary, setSummary] = useState<AdminInvoiceRevenueSummary | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const inFlight = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const fetchSummary = useCallback(async () => {
|
||||||
|
setError('');
|
||||||
|
inFlight.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
inFlight.current = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
|
||||||
|
const url = `${base}/api/admin/invoices/revenue-summary`;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || body?.success === false) {
|
||||||
|
setSummary(null);
|
||||||
|
setError(body?.message || `Failed to load revenue summary (${res.status})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSummary(body?.data || { totalPaidAllTime: 0, currency: 'EUR', paidInvoiceCount: 0 });
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === 'AbortError') return;
|
||||||
|
setError(e?.message || 'Network error');
|
||||||
|
setSummary(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
if (inFlight.current === controller) inFlight.current = null;
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accessToken) fetchSummary();
|
||||||
|
return () => inFlight.current?.abort();
|
||||||
|
}, [accessToken, fetchSummary]);
|
||||||
|
|
||||||
|
return { summary, loading, error, reload: fetchSummary };
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,360 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useVatRates } from './getTaxes'
|
||||||
|
import { useAdminInvoiceRevenueSummary, useAdminInvoices, type AdminInvoice } from './getInvoices'
|
||||||
|
import useAuthStore from '../../../store/authStore'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
|
|
||||||
|
type BillFilter = {
|
||||||
|
query: string
|
||||||
|
status: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFinanceManagementPageState() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const router = useRouter()
|
||||||
|
const accessToken = useAuthStore((state) => state.accessToken)
|
||||||
|
|
||||||
|
const { rates, loading: vatLoading, error: vatError } = useVatRates()
|
||||||
|
|
||||||
|
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
|
||||||
|
const [billFilter, setBillFilter] = useState<BillFilter>({ query: '', status: 'issued', from: '', to: '' })
|
||||||
|
|
||||||
|
const [diagLoading, setDiagLoading] = useState(false)
|
||||||
|
const [diagError, setDiagError] = useState('')
|
||||||
|
const [diagData, setDiagData] = useState<any | null>(null)
|
||||||
|
|
||||||
|
const [selectedInvoice, setSelectedInvoice] = useState<AdminInvoice | null>(null)
|
||||||
|
const [detailModalOpen, setDetailModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
|
||||||
|
const [reportEmail, setReportEmail] = useState('')
|
||||||
|
const [sendingReport, setSendingReport] = useState(false)
|
||||||
|
|
||||||
|
const [reportMsg, setReportMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
|
||||||
|
const [pdfLoading, setPdfLoading] = useState<string | number | null>(null)
|
||||||
|
const [uploadModalOpen, setUploadModalOpen] = useState(false)
|
||||||
|
const [uploadForm, setUploadForm] = useState({
|
||||||
|
buyer_name: '',
|
||||||
|
buyer_email: '',
|
||||||
|
buyer_street: '',
|
||||||
|
buyer_postal_code: '',
|
||||||
|
buyer_city: '',
|
||||||
|
buyer_country: '',
|
||||||
|
currency: 'EUR',
|
||||||
|
total_gross: '',
|
||||||
|
vat_rate: '20',
|
||||||
|
status: 'issued',
|
||||||
|
issued_at: '',
|
||||||
|
due_at: '',
|
||||||
|
})
|
||||||
|
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { invoices, loading: invLoading, error: invError, reload } = useAdminInvoices({
|
||||||
|
status: billFilter.status !== 'all' ? billFilter.status : undefined,
|
||||||
|
limit: 200,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
summary: revenueSummary,
|
||||||
|
loading: revenueLoading,
|
||||||
|
error: revenueError,
|
||||||
|
reload: reloadRevenueSummary,
|
||||||
|
} = useAdminInvoiceRevenueSummary()
|
||||||
|
|
||||||
|
const combinedInvoiceError = invError || (revenueError ? `Revenue summary: ${revenueError}` : '')
|
||||||
|
const reloadFinanceData = useCallback(async () => {
|
||||||
|
await Promise.all([reload(), reloadRevenueSummary()])
|
||||||
|
}, [reload, reloadRevenueSummary])
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const inRange = (d: Date) => {
|
||||||
|
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
if (timeframe === '7d') return diff <= 7
|
||||||
|
if (timeframe === '30d') return diff <= 30
|
||||||
|
if (timeframe === '90d') return diff <= 90
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = invoices.filter((invoice) => {
|
||||||
|
const dateValue = invoice.issued_at ?? invoice.created_at
|
||||||
|
if (!dateValue) return false
|
||||||
|
return inRange(new Date(dateValue))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAll: Number(revenueSummary?.totalPaidAllTime ?? 0),
|
||||||
|
totalRange: range.reduce((sum, invoice) => sum + Number(invoice.total_gross ?? 0), 0),
|
||||||
|
}
|
||||||
|
}, [invoices, revenueSummary, timeframe])
|
||||||
|
|
||||||
|
const filteredBills = useMemo(() => {
|
||||||
|
const query = billFilter.query.trim().toLowerCase()
|
||||||
|
const from = billFilter.from ? new Date(billFilter.from) : null
|
||||||
|
const to = billFilter.to ? new Date(billFilter.to) : null
|
||||||
|
|
||||||
|
return invoices.filter((invoice) => {
|
||||||
|
const byQuery =
|
||||||
|
!query ||
|
||||||
|
String(invoice.invoice_number ?? invoice.id).toLowerCase().includes(query) ||
|
||||||
|
String(invoice.buyer_name ?? '').toLowerCase().includes(query)
|
||||||
|
|
||||||
|
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at) : invoice.created_at ? new Date(invoice.created_at) : null
|
||||||
|
const byFrom = from ? (issuedAt ? issuedAt >= from : false) : true
|
||||||
|
const byTo = to ? (issuedAt ? issuedAt <= to : false) : true
|
||||||
|
|
||||||
|
return byQuery && byFrom && byTo
|
||||||
|
})
|
||||||
|
}, [invoices, billFilter])
|
||||||
|
|
||||||
|
const exportBills = (format: 'csv' | 'pdf') => {
|
||||||
|
console.log('[export]', format, { filters: billFilter, invoices: filteredBills })
|
||||||
|
alert(t('autofix.k6430ec9d').replace('{format}', format.toUpperCase()).replace('{count}', String(filteredBills.length)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const runPoolCheck = async (invoiceId: string | number) => {
|
||||||
|
setDiagLoading(true)
|
||||||
|
setDiagError('')
|
||||||
|
setDiagData(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||||
|
const url = `${base}/api/admin/pools/inflow-diagnostics?invoiceId=${encodeURIComponent(String(invoiceId))}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok || body?.success === false) {
|
||||||
|
setDiagError(body?.message || t('autofix.k4d551f20').replace('{status}', String(response.status)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDiagData(body?.data || null)
|
||||||
|
} catch (error: any) {
|
||||||
|
setDiagError(error?.message || t('autofix.k84447f0f'))
|
||||||
|
} finally {
|
||||||
|
setDiagLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportInvoice = (invoice: AdminInvoice) => {
|
||||||
|
const pretty = JSON.stringify(invoice, null, 2)
|
||||||
|
const blob = new Blob([pretty], { type: 'application/json;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = `invoice-${invoice.invoice_number || invoice.id}.json`
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
anchor.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewInvoicePdf = async (invoice: AdminInvoice) => {
|
||||||
|
setPdfLoading(invoice.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||||
|
const response = await fetch(`${base}/api/invoices/${invoice.id}/pdf`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(body?.message || t('autofix.k01a04b9d').replace('{status}', String(response.status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
|
window.open(blobUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
} catch (error: any) {
|
||||||
|
setReportMsg({ type: 'error', text: error?.message || t('autofix.k6d4dfb53') })
|
||||||
|
} finally {
|
||||||
|
setPdfLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetUploadForm = () => {
|
||||||
|
setUploadForm({
|
||||||
|
buyer_name: '',
|
||||||
|
buyer_email: '',
|
||||||
|
buyer_street: '',
|
||||||
|
buyer_postal_code: '',
|
||||||
|
buyer_city: '',
|
||||||
|
buyer_country: '',
|
||||||
|
currency: 'EUR',
|
||||||
|
total_gross: '',
|
||||||
|
vat_rate: '20',
|
||||||
|
status: 'issued',
|
||||||
|
issued_at: '',
|
||||||
|
due_at: '',
|
||||||
|
})
|
||||||
|
setUploadFile(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitUploadInvoice = async () => {
|
||||||
|
if (!uploadForm.total_gross || isNaN(Number(uploadForm.total_gross))) {
|
||||||
|
setUploadError(t('autofix.k0f7cd409'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadError(null)
|
||||||
|
setUploading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||||
|
const gross = parseFloat(uploadForm.total_gross)
|
||||||
|
const rate = parseFloat(uploadForm.vat_rate) || 0
|
||||||
|
const net = rate > 0 ? +(gross / (1 + rate / 100)).toFixed(2) : gross
|
||||||
|
const tax = +(gross - net).toFixed(2)
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
const { total_gross, vat_rate, ...rest } = uploadForm
|
||||||
|
Object.entries(rest).forEach(([key, value]) => {
|
||||||
|
if (value !== '') formData.append(key, value)
|
||||||
|
})
|
||||||
|
formData.append('total_gross', gross.toFixed(2))
|
||||||
|
formData.append('total_net', net.toFixed(2))
|
||||||
|
formData.append('total_tax', tax.toFixed(2))
|
||||||
|
formData.append('vat_rate', String(rate))
|
||||||
|
if (uploadFile) formData.append('pdf', uploadFile)
|
||||||
|
|
||||||
|
const response = await fetch(`${base}/api/admin/invoices`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok || body?.success === false) {
|
||||||
|
throw new Error(body?.message || t('autofix.kecf550b9').replace('{status}', String(response.status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadModalOpen(false)
|
||||||
|
resetUploadForm()
|
||||||
|
reload()
|
||||||
|
setReportMsg({ type: 'success', text: t('autofix.k28165f23').replace('{invoice}', String(body.data?.invoice_number ?? '')) })
|
||||||
|
} catch (error: any) {
|
||||||
|
setUploadError(error?.message || t('autofix.k1e7317ac'))
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendEmailReport = async () => {
|
||||||
|
if (!reportEmail.trim()) return
|
||||||
|
|
||||||
|
setReportMsg(null)
|
||||||
|
setSendingReport(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||||
|
const response = await fetch(`${base}/api/admin/invoices/email-report`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: reportEmail.trim(),
|
||||||
|
from: billFilter.from || undefined,
|
||||||
|
to: billFilter.to || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok || body?.success === false) {
|
||||||
|
throw new Error(body?.message || t('autofix.k5a2f88b8').replace('{status}', String(response.status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
setReportMsg({
|
||||||
|
type: 'success',
|
||||||
|
text: t('autofix.k5f4036ad')
|
||||||
|
.replace('{email}', reportEmail.trim())
|
||||||
|
.replace('{count}', String(body.data?.sentCount ?? 0)),
|
||||||
|
})
|
||||||
|
setEmailDialogOpen(false)
|
||||||
|
setReportEmail('')
|
||||||
|
} catch (error: any) {
|
||||||
|
setReportMsg({ type: 'error', text: error?.message || t('autofix.k3c6499f9') })
|
||||||
|
} finally {
|
||||||
|
setSendingReport(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadPreview = useMemo(() => {
|
||||||
|
const gross = parseFloat(uploadForm.total_gross) || 0
|
||||||
|
const rate = parseFloat(uploadForm.vat_rate) || 0
|
||||||
|
const net = rate > 0 ? +(gross / (1 + rate / 100)).toFixed(2) : gross
|
||||||
|
const tax = +(gross - net).toFixed(2)
|
||||||
|
return { net, tax }
|
||||||
|
}, [uploadForm.total_gross, uploadForm.vat_rate])
|
||||||
|
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
router,
|
||||||
|
rates,
|
||||||
|
vatLoading,
|
||||||
|
vatError,
|
||||||
|
timeframe,
|
||||||
|
setTimeframe,
|
||||||
|
billFilter,
|
||||||
|
setBillFilter,
|
||||||
|
diagLoading,
|
||||||
|
diagError,
|
||||||
|
diagData,
|
||||||
|
selectedInvoice,
|
||||||
|
setSelectedInvoice,
|
||||||
|
detailModalOpen,
|
||||||
|
setDetailModalOpen,
|
||||||
|
emailDialogOpen,
|
||||||
|
setEmailDialogOpen,
|
||||||
|
reportEmail,
|
||||||
|
setReportEmail,
|
||||||
|
sendingReport,
|
||||||
|
reportMsg,
|
||||||
|
setReportMsg,
|
||||||
|
invoices,
|
||||||
|
invLoading: invLoading || revenueLoading,
|
||||||
|
invError: combinedInvoiceError,
|
||||||
|
reload: reloadFinanceData,
|
||||||
|
totals,
|
||||||
|
filteredBills,
|
||||||
|
exportBills,
|
||||||
|
runPoolCheck,
|
||||||
|
exportInvoice,
|
||||||
|
pdfLoading,
|
||||||
|
uploadModalOpen,
|
||||||
|
setUploadModalOpen,
|
||||||
|
uploadForm,
|
||||||
|
setUploadForm,
|
||||||
|
uploadFile,
|
||||||
|
setUploadFile,
|
||||||
|
uploading,
|
||||||
|
uploadError,
|
||||||
|
setUploadError,
|
||||||
|
viewInvoicePdf,
|
||||||
|
submitUploadInvoice,
|
||||||
|
sendEmailReport,
|
||||||
|
uploadPreview,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,337 +1,294 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React, { useMemo, useState } from 'react'
|
|
||||||
|
import React from 'react'
|
||||||
import PageLayout from '../../components/PageLayout'
|
import PageLayout from '../../components/PageLayout'
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useVatRates } from './hooks/getTaxes'
|
|
||||||
import { useAdminInvoices, type AdminInvoice } from './hooks/getInvoices'
|
|
||||||
import useAuthStore from '../../store/authStore'
|
|
||||||
import InvoiceDetailModal from './components/InvoiceDetailModal'
|
import InvoiceDetailModal from './components/InvoiceDetailModal'
|
||||||
|
import { type AdminInvoice } from './hooks/getInvoices'
|
||||||
|
import { useFinanceManagementPageState } from './hooks/useFinanceManagementPageState'
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status?: string) {
|
||||||
|
if (status === 'paid') return 'bg-green-100 text-green-700'
|
||||||
|
if (status === 'issued') return 'bg-indigo-100 text-indigo-700'
|
||||||
|
if (status === 'draft') return 'bg-slate-100 text-slate-700'
|
||||||
|
if (status === 'overdue') return 'bg-red-100 text-red-700'
|
||||||
|
return 'bg-amber-100 text-amber-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(t: (key: string) => string, status?: string) {
|
||||||
|
if (status === 'draft') return t('autofix.k5f6d9f11')
|
||||||
|
if (status === 'issued') return t('autofix.kdc8f2ab2')
|
||||||
|
if (status === 'paid') return t('autofix.k9d5b2d74')
|
||||||
|
if (status === 'overdue') return t('autofix.k2f44ec11')
|
||||||
|
if (status === 'canceled') return t('autofix.kcf31ed66')
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinanceInvoiceActions({
|
||||||
|
invoice,
|
||||||
|
pdfLoading,
|
||||||
|
onViewPdf,
|
||||||
|
onOpenDetails,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
invoice: AdminInvoice
|
||||||
|
pdfLoading: string | number | null
|
||||||
|
onViewPdf: (invoice: AdminInvoice) => void
|
||||||
|
onOpenDetails: (invoice: AdminInvoice) => void
|
||||||
|
t: (key: string) => string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<td className="px-3 py-2 space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onViewPdf(invoice)}
|
||||||
|
disabled={pdfLoading === invoice.id || !invoice.pdf_storage_key}
|
||||||
|
className="text-xs rounded-lg border border-slate-200 px-2.5 py-1.5 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{pdfLoading === invoice.id ? t('autofix.k79d12c2e') : t('autofix.kfbe29d11')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenDetails(invoice)}
|
||||||
|
className="text-xs rounded-lg border border-slate-200 px-2.5 py-1.5 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{t('autofix.kf67200af')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function FinanceManagementPage() {
|
export default function FinanceManagementPage() {
|
||||||
const router = useRouter()
|
|
||||||
const accessToken = useAuthStore(s => s.accessToken)
|
|
||||||
const { rates, loading: vatLoading, error: vatError } = useVatRates()
|
|
||||||
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
|
|
||||||
const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' })
|
|
||||||
const [diagLoading, setDiagLoading] = useState(false)
|
|
||||||
const [diagError, setDiagError] = useState('')
|
|
||||||
const [diagData, setDiagData] = useState<any | null>(null)
|
|
||||||
const [selectedInvoice, setSelectedInvoice] = useState<AdminInvoice | null>(null)
|
|
||||||
const [detailModalOpen, setDetailModalOpen] = useState(false)
|
|
||||||
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
|
|
||||||
const [reportEmail, setReportEmail] = useState('')
|
|
||||||
const [sendingReport, setSendingReport] = useState(false)
|
|
||||||
const [reportMsg, setReportMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
|
||||||
|
|
||||||
// NEW: fetch invoices from backend
|
|
||||||
const {
|
const {
|
||||||
invoices,
|
t,
|
||||||
loading: invLoading,
|
router,
|
||||||
error: invError,
|
rates,
|
||||||
|
vatLoading,
|
||||||
|
vatError,
|
||||||
|
timeframe,
|
||||||
|
setTimeframe,
|
||||||
|
billFilter,
|
||||||
|
setBillFilter,
|
||||||
|
diagLoading,
|
||||||
|
diagError,
|
||||||
|
diagData,
|
||||||
|
selectedInvoice,
|
||||||
|
setSelectedInvoice,
|
||||||
|
detailModalOpen,
|
||||||
|
setDetailModalOpen,
|
||||||
|
emailDialogOpen,
|
||||||
|
setEmailDialogOpen,
|
||||||
|
reportEmail,
|
||||||
|
setReportEmail,
|
||||||
|
sendingReport,
|
||||||
|
reportMsg,
|
||||||
|
setReportMsg,
|
||||||
|
invLoading,
|
||||||
|
invError,
|
||||||
reload,
|
reload,
|
||||||
} = useAdminInvoices({
|
totals,
|
||||||
status: billFilter.status !== 'all' ? billFilter.status : undefined,
|
filteredBills,
|
||||||
limit: 200,
|
exportBills,
|
||||||
offset: 0,
|
runPoolCheck,
|
||||||
})
|
exportInvoice,
|
||||||
|
pdfLoading,
|
||||||
// NEW: totals from backend invoices
|
uploadModalOpen,
|
||||||
const totals = useMemo(() => {
|
setUploadModalOpen,
|
||||||
const now = new Date()
|
uploadForm,
|
||||||
const inRange = (d: Date) => {
|
setUploadForm,
|
||||||
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
uploadFile,
|
||||||
if (timeframe === '7d') return diff <= 7
|
setUploadFile,
|
||||||
if (timeframe === '30d') return diff <= 30
|
uploading,
|
||||||
if (timeframe === '90d') return diff <= 90
|
uploadError,
|
||||||
return true
|
setUploadError,
|
||||||
}
|
viewInvoicePdf,
|
||||||
const range = invoices.filter(inv => {
|
submitUploadInvoice,
|
||||||
const dStr = inv.issued_at ?? inv.created_at
|
sendEmailReport,
|
||||||
if (!dStr) return false
|
uploadPreview,
|
||||||
const d = new Date(dStr)
|
} = useFinanceManagementPageState()
|
||||||
return inRange(d)
|
|
||||||
})
|
|
||||||
const totalAll = invoices.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
|
|
||||||
const totalRange = range.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
|
|
||||||
return { totalAll, totalRange }
|
|
||||||
}, [invoices, timeframe])
|
|
||||||
|
|
||||||
// NEW: filtered rows for table
|
|
||||||
const filteredBills = useMemo(() => {
|
|
||||||
const q = billFilter.query.trim().toLowerCase()
|
|
||||||
const from = billFilter.from ? new Date(billFilter.from) : null
|
|
||||||
const to = billFilter.to ? new Date(billFilter.to) : null
|
|
||||||
|
|
||||||
return invoices.filter(inv => {
|
|
||||||
const byQuery =
|
|
||||||
!q ||
|
|
||||||
String(inv.invoice_number ?? inv.id).toLowerCase().includes(q) ||
|
|
||||||
String(inv.buyer_name ?? '').toLowerCase().includes(q)
|
|
||||||
const issued = inv.issued_at ? new Date(inv.issued_at) : (inv.created_at ? new Date(inv.created_at) : null)
|
|
||||||
const byFrom = from ? (issued ? issued >= from : false) : true
|
|
||||||
const byTo = to ? (issued ? issued <= to : false) : true
|
|
||||||
return byQuery && byFrom && byTo
|
|
||||||
})
|
|
||||||
}, [invoices, billFilter])
|
|
||||||
|
|
||||||
const exportBills = (format: 'csv' | 'pdf') => {
|
|
||||||
console.log('[export]', format, { filters: billFilter, invoices: filteredBills })
|
|
||||||
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const runPoolCheck = async (invoiceId: string | number) => {
|
|
||||||
setDiagLoading(true)
|
|
||||||
setDiagError('')
|
|
||||||
setDiagData(null)
|
|
||||||
try {
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
||||||
const url = `${base}/api/admin/pools/inflow-diagnostics?invoiceId=${encodeURIComponent(String(invoiceId))}`
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const body = await res.json().catch(() => ({}))
|
|
||||||
if (!res.ok || body?.success === false) {
|
|
||||||
setDiagError(body?.message || `Check failed (${res.status})`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setDiagData(body?.data || null)
|
|
||||||
} catch (e: any) {
|
|
||||||
setDiagError(e?.message || 'Network error')
|
|
||||||
} finally {
|
|
||||||
setDiagLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportInvoice = (inv: AdminInvoice) => {
|
|
||||||
const pretty = JSON.stringify(inv, null, 2)
|
|
||||||
const blob = new Blob([pretty], { type: 'application/json;charset=utf-8' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `invoice-${inv.invoice_number || inv.id}.json`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
a.remove()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [pdfLoading, setPdfLoading] = useState<string | number | null>(null)
|
|
||||||
|
|
||||||
const viewInvoicePdf = async (inv: AdminInvoice) => {
|
|
||||||
setPdfLoading(inv.id)
|
|
||||||
try {
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
||||||
const res = await fetch(`${base}/api/invoices/${inv.id}/pdf`, {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}))
|
|
||||||
throw new Error(body?.message || `Failed to load PDF (${res.status})`)
|
|
||||||
}
|
|
||||||
const blob = await res.blob()
|
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
|
||||||
window.open(blobUrl, '_blank', 'noopener,noreferrer')
|
|
||||||
} catch (e: any) {
|
|
||||||
setReportMsg({ type: 'error', text: e?.message || 'Failed to load invoice PDF.' })
|
|
||||||
} finally {
|
|
||||||
setPdfLoading(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendEmailReport = async () => {
|
|
||||||
if (!reportEmail.trim()) return
|
|
||||||
setReportMsg(null)
|
|
||||||
setSendingReport(true)
|
|
||||||
try {
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
||||||
const res = await fetch(`${base}/api/admin/invoices/email-report`, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: reportEmail.trim(),
|
|
||||||
from: billFilter.from || undefined,
|
|
||||||
to: billFilter.to || undefined,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const body = await res.json().catch(() => ({}))
|
|
||||||
if (!res.ok || body?.success === false) {
|
|
||||||
throw new Error(body?.message || `Request failed (${res.status})`)
|
|
||||||
}
|
|
||||||
setReportMsg({ type: 'success', text: `Report sent to ${reportEmail.trim()} (${body.data?.sentCount ?? 0} paid invoice(s)).` })
|
|
||||||
setEmailDialogOpen(false)
|
|
||||||
setReportEmail('')
|
|
||||||
} catch (e: any) {
|
|
||||||
setReportMsg({ type: 'error', text: e?.message || 'Failed to send email report.' })
|
|
||||||
} finally {
|
|
||||||
setSendingReport(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
<header className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex flex-col gap-2">
|
<header className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<h1 className="text-3xl font-extrabold text-blue-900">Finance Management</h1>
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
<p className="text-sm text-blue-700">Overview of taxes, revenue, and invoices.</p>
|
{t('autofix.k8070cd52')}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-3xl sm:text-4xl font-extrabold text-slate-900 tracking-tight break-words">{t('autofix.k777299de')}</h1>
|
||||||
|
<p className="text-sm sm:text-base text-slate-600 mt-2 break-words">{t('autofix.k01ad6d49')}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Stats */}
|
<section className="grid [grid-template-columns:repeat(auto-fit,minmax(16rem,1fr))] gap-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="rounded-3xl border border-white/80 bg-white/90 p-5 shadow-[0_24px_70px_-44px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
<div className="text-xs text-slate-500 mb-1">{t('autofix.k73f7184d')}</div>
|
||||||
<div className="text-xs text-gray-500 mb-1">Total revenue (all time)</div>
|
<div className="text-2xl font-semibold text-slate-900">EUR {totals.totalAll.toFixed(2)}</div>
|
||||||
<div className="text-2xl font-semibold text-[#1C2B4A]">€{totals.totalAll.toFixed(2)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
<div className="rounded-3xl border border-white/80 bg-white/90 p-5 shadow-[0_24px_70px_-44px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<div className="text-xs text-gray-500 mb-1">Revenue (range)</div>
|
<div className="text-xs text-slate-500 mb-1">{t('autofix.k9b3082af')}</div>
|
||||||
<div className="text-2xl font-semibold text-[#1C2B4A]">€{totals.totalRange.toFixed(2)}</div>
|
<div className="text-2xl font-semibold text-slate-900">EUR {totals.totalRange.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
<div className="rounded-3xl border border-white/80 bg-white/90 p-5 shadow-[0_24px_70px_-44px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<div className="text-xs text-gray-500 mb-1">Invoices (range)</div>
|
<div className="text-xs text-slate-500 mb-1">{t('autofix.k9f4ec5e2')}</div>
|
||||||
<div className="text-2xl font-semibold text-[#1C2B4A]">{filteredBills.length}</div>
|
<div className="text-2xl font-semibold text-slate-900">{filteredBills.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
<div className="rounded-3xl border border-white/80 bg-white/90 p-5 shadow-[0_24px_70px_-44px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<div className="text-xs text-gray-500 mb-1">Timeframe</div>
|
<div className="text-xs text-slate-500 mb-1">{t('autofix.kafb65833')}</div>
|
||||||
<select
|
<select
|
||||||
value={timeframe}
|
value={timeframe}
|
||||||
onChange={e => setTimeframe(e.target.value as any)}
|
onChange={(event) => setTimeframe(event.target.value as '7d' | '30d' | '90d' | 'ytd')}
|
||||||
className="mt-2 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="7d">Last 7 days</option>
|
<option value="7d">{t('autofix.k502a0057')}</option>
|
||||||
<option value="30d">Last 30 days</option>
|
<option value="30d">{t('autofix.k5f74c123')}</option>
|
||||||
<option value="90d">Last 90 days</option>
|
<option value="90d">{t('autofix.k915115a9')}</option>
|
||||||
<option value="ytd">YTD</option>
|
<option value="ytd">{t('autofix.k0f5d95a1')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* VAT summary */}
|
<section className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-3">
|
||||||
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-[#1C2B4A]">Manage VAT rates</h2>
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.kf2180ff6')}</h2>
|
||||||
<p className="text-xs text-gray-600">Live data from backend; edit on a separate page.</p>
|
<p className="text-xs text-slate-600">{t('autofix.k5ce7a5b0')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/admin/finance-management/vat-edit')}
|
onClick={() => router.push('/admin/finance-management/vat-edit')}
|
||||||
className="rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-[#1C2B4A]/90"
|
className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800 transition"
|
||||||
>
|
>
|
||||||
Edit VAT
|
{t('autofix.k4191cdba')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700">
|
<div className="text-sm text-slate-700">
|
||||||
{vatLoading && 'Loading VAT rates...'}
|
{vatLoading && t('autofix.ka5d50257')}
|
||||||
{vatError && <span className="text-red-600">{vatError}</span>}
|
{vatError && <span className="text-red-600">{vatError}</span>}
|
||||||
{!vatLoading && !vatError && (
|
{!vatLoading && !vatError && (
|
||||||
<>Active countries: {rates.length} • Examples: {rates.slice(0, 5).map(r => r.country_code).join(', ')}</>
|
<>
|
||||||
|
{t('autofix.k3e4a95bc').replace('{count}', String(rates.length)).replace('{examples}', rates.slice(0, 5).map((rate) => rate.country_code).join(', '))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Bills list & filters */}
|
<section className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-4">
|
||||||
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-lg font-semibold text-[#1C2B4A]">Invoices</h2>
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.k21f123af')}</h2>
|
||||||
<div className="flex flex-wrap gap-2 text-sm">
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
<button onClick={() => { setReportMsg(null); setEmailDialogOpen(true) }} className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-blue-900 font-medium hover:bg-blue-100">Send Email Report</button>
|
<button
|
||||||
<button onClick={() => exportBills('csv')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export CSV</button>
|
onClick={() => {
|
||||||
<button onClick={() => exportBills('pdf')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export PDF</button>
|
setUploadError(null)
|
||||||
<button onClick={reload} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Reload</button>
|
setUploadModalOpen(true)
|
||||||
|
}}
|
||||||
|
className="rounded-xl bg-slate-900 px-3 py-2 text-white font-medium hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.kec5a5357')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setReportMsg(null)
|
||||||
|
setEmailDialogOpen(true)
|
||||||
|
}}
|
||||||
|
className="rounded-xl border border-sky-200 bg-sky-50 px-3 py-2 text-sky-900 font-medium hover:bg-sky-100 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.kfdcad59b')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => exportBills('csv')} className="rounded-xl border border-slate-200 px-3 py-2 hover:bg-slate-50 transition">{t('autofix.k4c5e8e87')}</button>
|
||||||
|
<button onClick={() => exportBills('pdf')} className="rounded-xl border border-slate-200 px-3 py-2 hover:bg-slate-50 transition">{t('autofix.k4c5ecd73')}</button>
|
||||||
|
<button onClick={reload} className="rounded-xl border border-slate-200 px-3 py-2 hover:bg-slate-50 transition">{t('autofix.kddf7ca98')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
<div className="grid gap-3 lg:grid-cols-4 text-sm">
|
||||||
<input
|
<input
|
||||||
placeholder="Search (invoice no., customer)"
|
placeholder={t('autofix.k8bb2fe26')}
|
||||||
value={billFilter.query}
|
value={billFilter.query}
|
||||||
onChange={e => setBillFilter(f => ({ ...f, query: e.target.value }))}
|
onChange={(event) => setBillFilter((current) => ({ ...current, query: event.target.value }))}
|
||||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="rounded-lg border border-slate-200 px-3 py-2 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={billFilter.status}
|
value={billFilter.status}
|
||||||
onChange={e => setBillFilter(f => ({ ...f, status: e.target.value }))}
|
onChange={(event) => setBillFilter((current) => ({ ...current, status: event.target.value }))}
|
||||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="rounded-lg border border-slate-200 px-3 py-2 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="all">Status: All</option>
|
<option value="all">{t('autofix.kec99a6cc')}</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">{t('autofix.k5f6d9f11')}</option>
|
||||||
<option value="issued">Issued</option>
|
<option value="issued">{t('autofix.kdc8f2ab2')}</option>
|
||||||
<option value="paid">Paid</option>
|
<option value="paid">{t('autofix.k9d5b2d74')}</option>
|
||||||
<option value="overdue">Overdue</option>
|
<option value="overdue">{t('autofix.k2f44ec11')}</option>
|
||||||
<option value="canceled">Canceled</option>
|
<option value="canceled">{t('autofix.kcf31ed66')}</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={billFilter.from}
|
value={billFilter.from}
|
||||||
onChange={e => setBillFilter(f => ({ ...f, from: e.target.value }))}
|
onChange={(event) => setBillFilter((current) => ({ ...current, from: event.target.value }))}
|
||||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="rounded-lg border border-slate-200 px-3 py-2 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={billFilter.to}
|
value={billFilter.to}
|
||||||
onChange={e => setBillFilter(f => ({ ...f, to: e.target.value }))}
|
onChange={(event) => setBillFilter((current) => ({ ...current, to: event.target.value }))}
|
||||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="rounded-lg border border-slate-200 px-3 py-2 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto rounded-2xl border border-slate-200/70 bg-white/70 p-1">
|
||||||
{reportMsg && (
|
{reportMsg && (
|
||||||
<div className={`rounded-md border px-3 py-2 text-sm mb-3 ${reportMsg.type === 'success' ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-700'}`}>
|
<div className={`rounded-xl border px-3 py-2 text-sm mb-3 ${reportMsg.type === 'success' ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-700'}`}>
|
||||||
{reportMsg.text}
|
{reportMsg.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{invError && (
|
{invError && (
|
||||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 mb-3">
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 mb-3">
|
||||||
{invError}
|
{invError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(diagLoading || diagError || diagData) && (
|
{(diagLoading || diagError || diagData) && (
|
||||||
<div className="rounded-md border border-blue-100 bg-blue-50/60 px-3 py-3 text-sm mb-3">
|
<div className="rounded-xl border border-sky-200 bg-sky-50/60 px-3 py-3 text-sm mb-3">
|
||||||
{diagLoading && <div className="text-blue-800">Checking pool inflow...</div>}
|
{diagLoading && <div className="text-sky-800">{t('autofix.k37d7b9c4')}</div>}
|
||||||
{!diagLoading && diagError && <div className="text-red-700">{diagError}</div>}
|
{!diagLoading && diagError && <div className="text-red-700">{diagError}</div>}
|
||||||
{!diagLoading && !diagError && diagData && (
|
{!diagLoading && !diagError && diagData && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-blue-900 font-semibold">Pool inflow diagnostic for invoice #{diagData.invoice_id ?? '—'}</div>
|
<div className="text-sky-900 font-semibold">
|
||||||
<div className="text-gray-700">
|
{t('autofix.kf6a5a971').replace('{invoice}', String(diagData.invoice_id ?? '—'))}
|
||||||
Status: <span className="font-medium">{diagData.ok ? 'OK' : 'Blocked'}</span> • Reason: <span className="font-mono">{diagData.reason}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-slate-700">
|
||||||
|
{t('autofix.k81c0b74b')}
|
||||||
|
<span className="font-medium">{diagData.ok ? t('autofix.kaf7e90cc') : t('autofix.k6ba7f5b1')}</span>
|
||||||
|
{t('autofix.k77049179')}
|
||||||
|
<span className="font-mono">{diagData.reason}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{diagData.ok && (
|
{diagData.ok && (
|
||||||
<div className="text-gray-700">
|
<div className="text-slate-700">
|
||||||
Abonement: <span className="font-medium">{diagData.abonement_id}</span> • Will book: <span className="font-medium">{diagData.will_book_count}</span> • Already booked: <span className="font-medium">{diagData.already_booked_count}</span>
|
{t('autofix.k4968eb2a')}
|
||||||
|
<span className="font-medium">{diagData.abonement_id}</span>
|
||||||
|
{t('autofix.kfaa8fc4a')}
|
||||||
|
<span className="font-medium">{diagData.will_book_count}</span>
|
||||||
|
{t('autofix.kd2e5e813')}
|
||||||
|
<span className="font-medium">{diagData.already_booked_count}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Array.isArray(diagData.candidates) && diagData.candidates.length > 0 && (
|
{Array.isArray(diagData.candidates) && diagData.candidates.length > 0 && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-xs">
|
<table className="min-w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-blue-900">
|
<tr className="text-left text-sky-900">
|
||||||
<th className="pr-3 py-1">Pool</th>
|
<th className="pr-3 py-1">{t('autofix.kf1b73a92')}</th>
|
||||||
<th className="pr-3 py-1">Coffee</th>
|
<th className="pr-3 py-1">{t('autofix.k2f9cd1e0')}</th>
|
||||||
<th className="pr-3 py-1">Capsules</th>
|
<th className="pr-3 py-1">{t('autofix.k1ddc3f42')}</th>
|
||||||
<th className="pr-3 py-1">Amount (gross)</th>
|
<th className="pr-3 py-1">{t('autofix.kdb79aa30')}</th>
|
||||||
<th className="pr-3 py-1">Booked</th>
|
<th className="pr-3 py-1">{t('autofix.k93e61ad1')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{diagData.candidates.map((c: any) => (
|
{diagData.candidates.map((candidate: any) => (
|
||||||
<tr key={`${c.pool_id}-${c.coffee_table_id}`}>
|
<tr key={`${candidate.pool_id}-${candidate.coffee_table_id}`}>
|
||||||
<td className="pr-3 py-1">{c.pool_name}</td>
|
<td className="pr-3 py-1">{candidate.pool_name}</td>
|
||||||
<td className="pr-3 py-1">#{c.coffee_table_id}</td>
|
<td className="pr-3 py-1">#{candidate.coffee_table_id}</td>
|
||||||
<td className="pr-3 py-1">{c.capsules_count}</td>
|
<td className="pr-3 py-1">{candidate.capsules_count}</td>
|
||||||
<td className="pr-3 py-1">€{Number(c.amount_gross ?? c.amount_net ?? 0).toFixed(2)}</td>
|
<td className="pr-3 py-1">EUR {Number(candidate.amount_gross ?? candidate.amount_net ?? 0).toFixed(2)}</td>
|
||||||
<td className="pr-3 py-1">{c.already_booked ? 'yes' : 'no'}</td>
|
<td className="pr-3 py-1">{candidate.already_booked ? t('common.yes') : t('common.no')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -342,90 +299,72 @@ export default function FinanceManagementPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<table className="min-w-full text-sm">
|
|
||||||
|
<table className="min-w-full text-sm rounded-xl overflow-hidden">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-blue-50 text-left text-blue-900">
|
<tr className="bg-slate-50 text-left text-slate-900">
|
||||||
<th className="px-3 py-2 font-semibold">Invoice</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.kf8f0c1f3')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Customer</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.kf2b5c1a6')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Issued</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.kd4af6368')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Due Date</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.k867f8265')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Amount</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.k762eef76')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Status</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.k81c0b74b')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Actions</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.k0afbbac4')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{invLoading ? (
|
{invLoading ? (
|
||||||
<>
|
<>
|
||||||
<tr><td colSpan={7} className="px-3 py-3"><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
|
<tr><td colSpan={7} className="px-3 py-3"><div className="h-4 w-40 bg-slate-200 animate-pulse rounded" /></td></tr>
|
||||||
<tr><td colSpan={7} className="px-3 py-3"><div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" /></td></tr>
|
<tr><td colSpan={7} className="px-3 py-3"><div className="h-4 w-3/4 bg-slate-200 animate-pulse rounded" /></td></tr>
|
||||||
</>
|
</>
|
||||||
) : filteredBills.length === 0 ? (
|
) : filteredBills.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-3 py-4 text-center text-gray-500">
|
<td colSpan={7} className="px-3 py-4 text-center text-slate-500">{t('autofix.kbdb02e32')}</td>
|
||||||
Keine Rechnungen gefunden.
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredBills.map(inv => (
|
filteredBills.map((invoice) => (
|
||||||
<tr key={inv.id} className="border-b last:border-0">
|
<tr key={invoice.id} className="border-b border-slate-100 last:border-0">
|
||||||
<td className="px-3 py-2">{inv.invoice_number ?? inv.id}</td>
|
<td className="px-3 py-2">{invoice.invoice_number ?? invoice.id}</td>
|
||||||
<td className="px-3 py-2">{inv.buyer_name ?? '—'}</td>
|
<td className="px-3 py-2">{invoice.buyer_name ?? '—'}</td>
|
||||||
<td className="px-3 py-2">{inv.issued_at ? new Date(inv.issued_at).toLocaleDateString() : '—'}</td>
|
<td className="px-3 py-2">{invoice.issued_at ? new Date(invoice.issued_at).toLocaleDateString() : '—'}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
if (!inv.due_at) return <span className="text-gray-400">—</span>
|
if (!invoice.due_at) return <span className="text-slate-400">—</span>
|
||||||
const due = new Date(inv.due_at)
|
|
||||||
const now = new Date()
|
const due = new Date(invoice.due_at)
|
||||||
const diffDays = Math.ceil((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
const today = new Date()
|
||||||
let cls = 'bg-green-100 text-green-700' // plenty of time
|
due.setHours(0, 0, 0, 0)
|
||||||
if (inv.status === 'paid') cls = 'bg-green-100 text-green-700'
|
today.setHours(0, 0, 0, 0)
|
||||||
else if (diffDays < 0) cls = 'bg-red-100 text-red-700'
|
const diffDays = Math.round((due.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
let cls = 'bg-green-100 text-green-700'
|
||||||
|
if (invoice.status === 'paid') cls = 'bg-green-100 text-green-700'
|
||||||
|
else if (invoice.status === 'overdue' || diffDays < 0) cls = 'bg-red-100 text-red-700'
|
||||||
else if (diffDays <= 3) cls = 'bg-red-100 text-red-700'
|
else if (diffDays <= 3) cls = 'bg-red-100 text-red-700'
|
||||||
else if (diffDays <= 7) cls = 'bg-amber-100 text-amber-700'
|
else if (diffDays <= 7) cls = 'bg-amber-100 text-amber-700'
|
||||||
return (
|
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${cls}`}>
|
return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${cls}`}>{due.toLocaleDateString()}</span>
|
||||||
{due.toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
€{Number(inv.total_gross ?? 0).toFixed(2)}{' '}
|
EUR {Number(invoice.total_gross ?? 0).toFixed(2)} <span className="text-xs text-slate-500">{invoice.currency ?? 'EUR'}</span>
|
||||||
<span className="text-xs text-gray-500">{inv.currency ?? 'EUR'}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span
|
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${getStatusBadgeClass(invoice.status ?? '')}`}>
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
{getStatusLabel(t, invoice.status ?? '')}
|
||||||
inv.status === 'paid'
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: inv.status === 'issued'
|
|
||||||
? 'bg-indigo-100 text-indigo-700'
|
|
||||||
: inv.status === 'draft'
|
|
||||||
? 'bg-gray-100 text-gray-700'
|
|
||||||
: inv.status === 'overdue'
|
|
||||||
? 'bg-red-100 text-red-700'
|
|
||||||
: 'bg-yellow-100 text-yellow-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{inv.status}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 space-x-2">
|
<FinanceInvoiceActions
|
||||||
<button
|
invoice={invoice}
|
||||||
onClick={() => viewInvoicePdf(inv)}
|
pdfLoading={pdfLoading}
|
||||||
disabled={pdfLoading === inv.id || !inv.pdf_storage_key}
|
onViewPdf={viewInvoicePdf}
|
||||||
className="text-xs rounded border px-2 py-1 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
onOpenDetails={(value) => {
|
||||||
>
|
setSelectedInvoice(value)
|
||||||
{pdfLoading === inv.id ? 'Loading…' : 'View PDF'}
|
setDetailModalOpen(true)
|
||||||
</button>
|
}}
|
||||||
<button
|
t={t}
|
||||||
onClick={() => { setSelectedInvoice(inv); setDetailModalOpen(true) }}
|
/>
|
||||||
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@ -437,48 +376,145 @@ export default function FinanceManagementPage() {
|
|||||||
<InvoiceDetailModal
|
<InvoiceDetailModal
|
||||||
invoice={selectedInvoice}
|
invoice={selectedInvoice}
|
||||||
open={detailModalOpen}
|
open={detailModalOpen}
|
||||||
onClose={() => { setDetailModalOpen(false); setTimeout(() => setSelectedInvoice(null), 200) }}
|
onClose={() => {
|
||||||
|
setDetailModalOpen(false)
|
||||||
|
setTimeout(() => setSelectedInvoice(null), 200)
|
||||||
|
}}
|
||||||
onStatusChanged={reload}
|
onStatusChanged={reload}
|
||||||
onRunPoolCheck={(id) => { setDetailModalOpen(false); runPoolCheck(id) }}
|
onRunPoolCheck={(id) => {
|
||||||
onExport={(inv) => exportInvoice(inv)}
|
setDetailModalOpen(false)
|
||||||
|
runPoolCheck(id)
|
||||||
|
}}
|
||||||
|
onExport={(invoice) => exportInvoice(invoice)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email Report Dialog */}
|
{uploadModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/35 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-2xl rounded-[28px] border border-white/80 bg-white/95 p-6 shadow-[0_24px_70px_-30px_rgba(15,23,42,0.35)] overflow-y-auto max-h-[90vh]">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">{t('autofix.kec5a5357')}</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.kf2b5c1a6')}</label>
|
||||||
|
<input className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_name} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_name: event.target.value }))} placeholder={t('autofix.k1882bd75')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k48852b8d')}</label>
|
||||||
|
<input type="email" className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_email} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_email: event.target.value }))} placeholder={t('autofix.kf8c220d3')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.kba8ee9b1')}</label>
|
||||||
|
<input className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_street} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_street: event.target.value }))} placeholder={t('autofix.k81c7c2f2')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.kc9d9d15d')}</label>
|
||||||
|
<input className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_postal_code} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_postal_code: event.target.value }))} placeholder="8010" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k5d52917f')}</label>
|
||||||
|
<input className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_city} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_city: event.target.value }))} placeholder="Graz" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k9e39e560')}</label>
|
||||||
|
<input className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_country} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_country: event.target.value }))} placeholder="Austria" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k002455d8')}<span className="text-red-500">*</span></label>
|
||||||
|
<input type="number" step="0.01" min="0" className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.total_gross} onChange={(event) => setUploadForm((current) => ({ ...current, total_gross: event.target.value }))} placeholder="0.00" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k57d5f250')}</label>
|
||||||
|
<input type="number" step="0.01" min="0" max="100" className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.vat_rate} onChange={(event) => setUploadForm((current) => ({ ...current, vat_rate: event.target.value }))} placeholder="20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2 grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg bg-slate-50 border border-slate-100 px-3 py-2">
|
||||||
|
<div className="text-xs text-slate-500 mb-0.5">{t('autofix.k1f5a403a')}</div>
|
||||||
|
<div className="font-semibold text-slate-800">{uploadForm.currency} {uploadPreview.net.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-50 border border-slate-100 px-3 py-2">
|
||||||
|
<div className="text-xs text-slate-500 mb-0.5">{t('autofix.k089e8c08')}</div>
|
||||||
|
<div className="font-semibold text-slate-800">{uploadForm.currency} {uploadPreview.tax.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k3466b0e0')}</label>
|
||||||
|
<select className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.currency} onChange={(event) => setUploadForm((current) => ({ ...current, currency: event.target.value }))}>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
<option value="CHF">CHF</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k81c0b74b')}</label>
|
||||||
|
<select className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.status} onChange={(event) => setUploadForm((current) => ({ ...current, status: event.target.value }))}>
|
||||||
|
<option value="issued">{t('autofix.kdc8f2ab2')}</option>
|
||||||
|
<option value="paid">{t('autofix.k9d5b2d74')}</option>
|
||||||
|
<option value="draft">{t('autofix.k5f6d9f11')}</option>
|
||||||
|
<option value="overdue">{t('autofix.k2f44ec11')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.kd4af6368')}</label>
|
||||||
|
<input type="date" className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.issued_at} onChange={(event) => setUploadForm((current) => ({ ...current, issued_at: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k867f8265')}</label>
|
||||||
|
<input type="date" className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.due_at} onChange={(event) => setUploadForm((current) => ({ ...current, due_at: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.kd6024811')}</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
className="w-full text-sm text-slate-700 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-50 file:px-3 file:py-2 file:text-sky-900 file:font-medium hover:file:bg-sky-100"
|
||||||
|
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{uploadFile && <p className="mt-1 text-xs text-slate-500">{uploadFile.name}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadError && <div className="mt-3 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{uploadError}</div>}
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
|
<button onClick={() => { setUploadModalOpen(false); setUploadError(null) }} disabled={uploading} className="rounded-xl border border-slate-200 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-60">{t('common.cancel')}</button>
|
||||||
|
<button onClick={submitUploadInvoice} disabled={uploading} className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800 disabled:opacity-60 disabled:cursor-not-allowed">
|
||||||
|
{uploading ? t('autofix.k3bc9a0f1') : t('autofix.k1139753d')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{emailDialogOpen && (
|
{emailDialogOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/35 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
<div className="w-full max-w-md rounded-[28px] border border-white/80 bg-white/95 p-6 shadow-[0_24px_70px_-30px_rgba(15,23,42,0.35)]">
|
||||||
<h3 className="text-lg font-semibold text-[#1C2B4A] mb-1">Send Email Report</h3>
|
<h3 className="text-lg font-semibold text-slate-900 mb-1">{t('autofix.kfdcad59b')}</h3>
|
||||||
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4">
|
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4">
|
||||||
Only <strong>paid</strong> invoices will be included in the report, regardless of the status filter.
|
{t('autofix.k45c3fd51').replace('{paid}', t('autofix.k9d5b2d74').toLowerCase())}
|
||||||
{(billFilter.from || billFilter.to) && (
|
{(billFilter.from || billFilter.to) && (
|
||||||
<span> The current date range filter ({billFilter.from || '…'} – {billFilter.to || '…'}) will be applied.</span>
|
<span> {t('autofix.kdd22a5f2').replace('{from}', billFilter.from || '…').replace('{to}', billFilter.to || '…')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Recipient Email</label>
|
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">{t('autofix.kd56a13f2')}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={reportEmail}
|
value={reportEmail}
|
||||||
onChange={e => setReportEmail(e.target.value)}
|
onChange={(event) => setReportEmail(event.target.value)}
|
||||||
placeholder="email@example.com"
|
placeholder={t('autofix.k51ee3aae')}
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !sendingReport) sendEmailReport() }}
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !sendingReport) sendEmailReport()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
<button
|
<button onClick={() => { setEmailDialogOpen(false); setReportEmail('') }} disabled={sendingReport} className="rounded-xl border border-slate-200 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-60">{t('common.cancel')}</button>
|
||||||
onClick={() => { setEmailDialogOpen(false); setReportEmail('') }}
|
<button onClick={sendEmailReport} disabled={sendingReport || !reportEmail.trim()} className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800 disabled:opacity-60 disabled:cursor-not-allowed">
|
||||||
disabled={sendingReport}
|
{sendingReport ? t('autofix.k795911e8') : t('autofix.kf6f9b3c0')}
|
||||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={sendEmailReport}
|
|
||||||
disabled={sendingReport || !reportEmail.trim()}
|
|
||||||
className="rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-[#1C2B4A]/90 disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{sendingReport ? 'Sending…' : 'Send Report'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import PageLayout from '../../../components/PageLayout'
|
import PageLayout from '../../../components/PageLayout'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@ -7,6 +10,7 @@ import { importVatCsv } from './hooks/TaxImporter'
|
|||||||
import { exportVatCsv, exportVatPdf } from './hooks/TaxExporter'
|
import { exportVatCsv, exportVatPdf } from './hooks/TaxExporter'
|
||||||
|
|
||||||
export default function VatEditPage() {
|
export default function VatEditPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { rates, loading, error, reload } = useVatRates()
|
const { rates, loading, error, reload } = useVatRates()
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
@ -42,7 +46,7 @@ export default function VatEditPage() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||||
<div className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex items-center justify-between">
|
<div className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-extrabold text-blue-900">Edit VAT rates</h1>
|
<h1 className="text-3xl font-extrabold text-blue-900">{t('autofix.k7572cceb')}</h1>
|
||||||
<p className="text-sm text-blue-700">Import, export, and review (dummy data).</p>
|
<p className="text-sm text-blue-700">Import, export, and review (dummy data).</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -62,21 +66,15 @@ export default function VatEditPage() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={e => onImport(e.target.files?.[0] || null)}
|
onChange={e => onImport(e.target.files?.[0] || null)}
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
/>
|
/>{importing ? 'Importing...' : t('autofix.k8a59f09e')}</label>
|
||||||
{importing ? 'Importing...' : 'Import CSV'}
|
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => exportVatCsv(rates)}
|
onClick={() => exportVatCsv(rates)}
|
||||||
className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50"
|
className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50"
|
||||||
>
|
>{t('autofix.k4c5e8e87')}</button>
|
||||||
Export CSV
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => exportVatPdf(rates)}
|
onClick={() => exportVatPdf(rates)}
|
||||||
className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50"
|
className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50"
|
||||||
>
|
>{t('autofix.k4c5ecd73')}</button>
|
||||||
Export PDF
|
|
||||||
</button>
|
|
||||||
{importResult && <span className="text-xs text-blue-900 break-all">{importResult}</span>}
|
{importResult && <span className="text-xs text-blue-900 break-all">{importResult}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -85,7 +83,7 @@ export default function VatEditPage() {
|
|||||||
<input
|
<input
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={e => { setFilter(e.target.value); setPage(1); }}
|
onChange={e => { setFilter(e.target.value); setPage(1); }}
|
||||||
placeholder="Filter by country or code"
|
placeholder={t('autofix.kc7bb0c06')}
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 mb-3 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 mb-3 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@ -96,13 +94,13 @@ export default function VatEditPage() {
|
|||||||
<th className="px-3 py-2 font-semibold">Code</th>
|
<th className="px-3 py-2 font-semibold">Code</th>
|
||||||
<th className="px-3 py-2 font-semibold">Standard</th>
|
<th className="px-3 py-2 font-semibold">Standard</th>
|
||||||
<th className="px-3 py-2 font-semibold">Reduced</th>
|
<th className="px-3 py-2 font-semibold">Reduced</th>
|
||||||
<th className="px-3 py-2 font-semibold">Super reduced</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.k678d2b40')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Parking</th>
|
<th className="px-3 py-2 font-semibold">Parking</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{loading && (
|
{loading && (
|
||||||
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">Loading VAT rates…</td></tr>
|
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">{t('autofix.ka5d50257')}</td></tr>
|
||||||
)}
|
)}
|
||||||
{!loading && pageData.map(v => (
|
{!loading && pageData.map(v => (
|
||||||
<tr key={v.country_code} className="border-b last:border-0">
|
<tr key={v.country_code} className="border-b last:border-0">
|
||||||
@ -115,14 +113,14 @@ export default function VatEditPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!loading && !error && pageData.length === 0 && (
|
{!loading && !error && pageData.length === 0 && (
|
||||||
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">No entries found.</td></tr>
|
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">{t('autofix.kb337d94e')}</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mt-4 text-sm text-gray-700">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mt-4 text-sm text-gray-700">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>Rows per page:</span>
|
<span>{t('autofix.k2f4ebc32')}</span>
|
||||||
<select
|
<select
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
|
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||||
|
|||||||
@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
import { useModalAnimation } from '../hooks/useModalAnimation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
newCode: string;
|
||||||
|
setNewCode: (value: string) => void;
|
||||||
|
newName: string;
|
||||||
|
setNewName: (value: string) => void;
|
||||||
|
addError: string;
|
||||||
|
setAddError: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AddLanguageModal({
|
||||||
|
isOpen,
|
||||||
|
newCode,
|
||||||
|
setNewCode,
|
||||||
|
newName,
|
||||||
|
setNewName,
|
||||||
|
addError,
|
||||||
|
setAddError,
|
||||||
|
onClose,
|
||||||
|
onAdd,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isRendered, isVisible } = useModalAnimation(isOpen);
|
||||||
|
|
||||||
|
if (!isRendered) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/30 backdrop-blur-md transition-opacity duration-200 ${
|
||||||
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}>
|
||||||
|
<div className={`mx-4 w-full max-w-sm rounded-[28px] border border-white/80 bg-white/90 backdrop-blur p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] transform transition-all duration-200 ${
|
||||||
|
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-5">
|
||||||
|
<div>
|
||||||
|
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500 shadow-sm mb-2">
|
||||||
|
Languages
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black tracking-tight text-slate-950">{t('autofix.kf4e45236')}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white/80 px-2.5 py-1.5 text-slate-400 hover:text-slate-700 hover:bg-white transition shadow-sm text-base leading-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wide mb-1.5">{t('autofix.k92639a9a')}</label>
|
||||||
|
<input
|
||||||
|
value={newCode}
|
||||||
|
onChange={(e) => setNewCode(e.target.value)}
|
||||||
|
placeholder={t('autofix.k03538639')}
|
||||||
|
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-2.5 text-sm shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300 transition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wide mb-1.5">{t('autofix.k926966d0')}</label>
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder={t('autofix.ka019b3c0')}
|
||||||
|
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-2.5 text-sm shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300 transition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{addError && (
|
||||||
|
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">{addError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
setAddError('');
|
||||||
|
setNewCode('');
|
||||||
|
setNewName('');
|
||||||
|
}}
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition shadow-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onAdd}
|
||||||
|
className="rounded-2xl bg-slate-900 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,235 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
import type { NamespaceCategory } from '../hooks/useNamespaceCategories';
|
||||||
|
import { useModalAnimation } from '../hooks/useModalAnimation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
newCategoryLabel: string;
|
||||||
|
setNewCategoryLabel: (value: string) => void;
|
||||||
|
onCreateCategory: () => void;
|
||||||
|
uncategorizedNamespaces: string[];
|
||||||
|
categoriesWithKnownNamespaces: NamespaceCategory[];
|
||||||
|
namespaces: string[];
|
||||||
|
assignNamespaceByCategory: Record<string, string>;
|
||||||
|
setAssignNamespaceByCategory: (next: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
|
||||||
|
expandedCategoryId: string | null;
|
||||||
|
setExpandedCategoryId: (value: string | null | ((prev: string | null) => string | null)) => void;
|
||||||
|
dragNamespace: string | null;
|
||||||
|
setDragNamespace: (value: string | null) => void;
|
||||||
|
addNamespaceToCategory: (categoryId: string, namespace: string) => void;
|
||||||
|
removeNamespaceFromCategory: (categoryId: string, namespace: string) => void;
|
||||||
|
deleteCategory: (categoryId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CategoryManagerModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
newCategoryLabel,
|
||||||
|
setNewCategoryLabel,
|
||||||
|
onCreateCategory,
|
||||||
|
uncategorizedNamespaces,
|
||||||
|
categoriesWithKnownNamespaces,
|
||||||
|
namespaces,
|
||||||
|
assignNamespaceByCategory,
|
||||||
|
setAssignNamespaceByCategory,
|
||||||
|
expandedCategoryId,
|
||||||
|
setExpandedCategoryId,
|
||||||
|
dragNamespace,
|
||||||
|
setDragNamespace,
|
||||||
|
addNamespaceToCategory,
|
||||||
|
removeNamespaceFromCategory,
|
||||||
|
deleteCategory,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isRendered, isVisible } = useModalAnimation(isOpen);
|
||||||
|
|
||||||
|
if (!isRendered) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed inset-0 z-[150] flex items-center justify-center bg-black/30 backdrop-blur-md transition-opacity duration-200 ${
|
||||||
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}>
|
||||||
|
<div className={`mx-4 w-full max-w-5xl rounded-[30px] border border-white/80 bg-white/88 backdrop-blur overflow-hidden shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)] transform transition-all duration-200 ${
|
||||||
|
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
|
||||||
|
}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-5 border-b border-white/60 flex items-start justify-between gap-4 bg-white/40">
|
||||||
|
<div>
|
||||||
|
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500 shadow-sm mb-2">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black tracking-tight text-slate-950">{t('autofix.kef9de7f0')}</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.kc4671abe')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white/80 px-2.5 py-1.5 text-slate-400 hover:text-slate-700 hover:bg-white transition shadow-sm text-base leading-none shrink-0"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-6 py-5 max-h-[70vh] overflow-y-auto space-y-4">
|
||||||
|
{/* Create category row */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
value={newCategoryLabel}
|
||||||
|
onChange={(e) => setNewCategoryLabel(e.target.value)}
|
||||||
|
placeholder={t('autofix.ke52ed6e9')}
|
||||||
|
className="w-56 rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCreateCategory}
|
||||||
|
className="rounded-2xl bg-slate-900 text-white px-4 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.k1db86f96')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Uncategorized pool */}
|
||||||
|
<div className="rounded-[20px] border border-white/80 bg-white/70 backdrop-blur p-4 shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-2.5">{t('autofix.k505ebdae')}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 min-h-10">
|
||||||
|
{uncategorizedNamespaces.map((ns) => (
|
||||||
|
<span
|
||||||
|
key={ns}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragNamespace(ns)}
|
||||||
|
className="cursor-grab rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition"
|
||||||
|
title={t('autofix.k66edf1eb')}
|
||||||
|
>
|
||||||
|
{ns}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{uncategorizedNamespaces.length === 0 && (
|
||||||
|
<span className="text-xs text-slate-400">{t('autofix.k741a01f7')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category list */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{categoriesWithKnownNamespaces.map((cat) => {
|
||||||
|
const availableToAssign = namespaces.filter((ns) => !cat.namespaces.includes(ns));
|
||||||
|
const selectValue = assignNamespaceByCategory[cat.id] ?? '';
|
||||||
|
const isExpanded = expandedCategoryId === cat.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
onDragEnter={() => {
|
||||||
|
if (!dragNamespace) return;
|
||||||
|
if (expandedCategoryId !== cat.id) setExpandedCategoryId(cat.id);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => {
|
||||||
|
if (dragNamespace) {
|
||||||
|
addNamespaceToCategory(cat.id, dragNamespace);
|
||||||
|
setDragNamespace(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-[20px] border border-white/80 bg-white/70 backdrop-blur overflow-hidden shadow-sm"
|
||||||
|
>
|
||||||
|
{/* Category header row */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 flex items-center justify-between gap-2 cursor-pointer hover:bg-white/60 transition"
|
||||||
|
onClick={() => setExpandedCategoryId((prev) => (prev === cat.id ? null : cat.id))}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-slate-950">{cat.label}</span>
|
||||||
|
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-slate-500 shadow-sm">
|
||||||
|
{cat.namespaces.length}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">{isExpanded ? t('autofix.k5daa1471') : t('autofix.k893106ba')}</span>
|
||||||
|
</div>
|
||||||
|
{cat.isCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteCategory(cat.id);
|
||||||
|
}}
|
||||||
|
className="rounded-2xl border border-red-200 bg-red-50 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-100 transition"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-white/60 p-4 space-y-3 bg-white/40">
|
||||||
|
<div className="flex flex-col lg:flex-row items-stretch gap-2">
|
||||||
|
<select
|
||||||
|
value={selectValue}
|
||||||
|
onChange={(e) => setAssignNamespaceByCategory((prev) => ({ ...prev, [cat.id]: e.target.value }))}
|
||||||
|
className="w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition"
|
||||||
|
>
|
||||||
|
<option value="">{t('autofix.k0cdc3ee9')}</option>
|
||||||
|
{availableToAssign.map((ns) => (
|
||||||
|
<option key={ns} value={ns}>{ns}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectValue) return;
|
||||||
|
addNamespaceToCategory(cat.id, selectValue);
|
||||||
|
setAssignNamespaceByCategory((prev) => ({ ...prev, [cat.id]: '' }));
|
||||||
|
}}
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 min-h-10 rounded-2xl border border-dashed border-slate-200 bg-white/60 p-3">
|
||||||
|
{cat.namespaces.map((ns) => (
|
||||||
|
<span
|
||||||
|
key={ns}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragNamespace(ns)}
|
||||||
|
className="group cursor-grab inline-flex items-center rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700 hover:border-indigo-300 transition"
|
||||||
|
>
|
||||||
|
{ns}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeNamespaceFromCategory(cat.id, ns)}
|
||||||
|
className="ml-1.5 text-indigo-400 group-hover:text-red-500 transition leading-none"
|
||||||
|
title={t('autofix.ka6791a02')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{cat.namespaces.length === 0 && (
|
||||||
|
<span className="text-xs text-slate-400">{t('autofix.kf3c3223a')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-white/60 bg-white/40 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-2xl bg-slate-900 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { LanguageEntry } from '../hooks/useLanguageManagementTranslations';
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
import { useModalAnimation } from '../hooks/useModalAnimation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
deleteTarget: string | null;
|
||||||
|
allLanguages: LanguageEntry[];
|
||||||
|
onClose: () => void;
|
||||||
|
onDelete: (code: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteLanguageModal({ deleteTarget, allLanguages, onClose, onDelete }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [displayTarget, setDisplayTarget] = useState<string | null>(deleteTarget);
|
||||||
|
const isOpen = Boolean(deleteTarget);
|
||||||
|
const { isRendered, isVisible } = useModalAnimation(isOpen);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (deleteTarget) setDisplayTarget(deleteTarget);
|
||||||
|
}, [deleteTarget]);
|
||||||
|
|
||||||
|
if (!isRendered || !displayTarget) return null;
|
||||||
|
|
||||||
|
const languageName = allLanguages.find((l) => l.code === displayTarget)?.name ?? displayTarget;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/30 backdrop-blur-md transition-opacity duration-200 ${
|
||||||
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}>
|
||||||
|
<div className={`mx-4 w-full max-w-sm rounded-[28px] border border-white/80 bg-white/90 backdrop-blur p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] transform transition-all duration-200 ${
|
||||||
|
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-5">
|
||||||
|
<div>
|
||||||
|
<span className="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-red-600 shadow-sm mb-2">
|
||||||
|
Destructive
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black tracking-tight text-slate-950">{t('autofix.kda5f982e')}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white/80 px-2.5 py-1.5 text-slate-400 hover:text-slate-700 hover:bg-white transition shadow-sm text-base leading-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-red-100 bg-red-50/60 px-4 py-3 mb-5">
|
||||||
|
<p className="text-sm text-slate-700">
|
||||||
|
Delete <strong className="text-slate-950">{languageName}</strong>?{' '}
|
||||||
|
All translations for this language will be permanently removed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition shadow-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(displayTarget)}
|
||||||
|
className="rounded-2xl bg-red-600 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(220,38,38,0.7)] hover:bg-red-700 transition"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,243 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
|
||||||
|
type LanguageEntry = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
headerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
totalKeys: number;
|
||||||
|
onScan: () => void;
|
||||||
|
isScanning: boolean;
|
||||||
|
isAutoFixing: boolean;
|
||||||
|
onBackToAdmin: () => void;
|
||||||
|
isDirty: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
saved: boolean;
|
||||||
|
saveError: string;
|
||||||
|
allLanguages: LanguageEntry[];
|
||||||
|
activeLang: string;
|
||||||
|
setActiveLang: (code: string) => void;
|
||||||
|
isBuiltin: (code: string) => boolean;
|
||||||
|
onDeleteLanguageRequest: (code: string) => void;
|
||||||
|
onOpenAddLanguage: () => void;
|
||||||
|
allTabStats: { total: number; translated: number; missing: number };
|
||||||
|
translationProgressPercent: number;
|
||||||
|
wizardMissingKeysCount: number;
|
||||||
|
onOpenTranslationWizard: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LanguageManagementTopSection({
|
||||||
|
headerRef,
|
||||||
|
totalKeys,
|
||||||
|
onScan,
|
||||||
|
isScanning,
|
||||||
|
isAutoFixing,
|
||||||
|
onBackToAdmin,
|
||||||
|
isDirty,
|
||||||
|
onSave,
|
||||||
|
saved,
|
||||||
|
saveError,
|
||||||
|
allLanguages,
|
||||||
|
activeLang,
|
||||||
|
setActiveLang,
|
||||||
|
isBuiltin,
|
||||||
|
onDeleteLanguageRequest,
|
||||||
|
onOpenAddLanguage,
|
||||||
|
allTabStats,
|
||||||
|
translationProgressPercent,
|
||||||
|
wizardMissingKeysCount,
|
||||||
|
onOpenTranslationWizard,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const prioritizedLanguages = useMemo(() => {
|
||||||
|
const byCode = new Map(allLanguages.map((lang) => [lang.code, lang]));
|
||||||
|
const english = byCode.get('en');
|
||||||
|
const german = byCode.get('de');
|
||||||
|
const rest = allLanguages
|
||||||
|
.filter((lang) => lang.code !== 'en' && lang.code !== 'de')
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return [english, german, ...rest].filter((lang): lang is LanguageEntry => Boolean(lang));
|
||||||
|
}, [allLanguages]);
|
||||||
|
|
||||||
|
const englishLanguage = prioritizedLanguages.find((lang) => lang.code === 'en');
|
||||||
|
const germanLanguage = prioritizedLanguages.find((lang) => lang.code === 'de');
|
||||||
|
const otherLanguages = prioritizedLanguages.filter((lang) => lang.code !== 'en' && lang.code !== 'de');
|
||||||
|
|
||||||
|
const renderLanguageButton = (lang: LanguageEntry) => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
onClick={() => setActiveLang(lang.code)}
|
||||||
|
className={`flex shrink-0 items-center gap-2 px-4 py-2.5 rounded-2xl text-sm font-medium transition whitespace-nowrap ${
|
||||||
|
activeLang === lang.code
|
||||||
|
? 'bg-slate-900 text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)]'
|
||||||
|
: 'bg-transparent text-slate-700 hover:bg-slate-100 hover:text-slate-900 border border-transparent hover:border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lang.name}
|
||||||
|
<span className={`text-xs ${activeLang === lang.code ? 'opacity-50' : 'opacity-40'}`}>
|
||||||
|
{lang.code}
|
||||||
|
</span>
|
||||||
|
{!isBuiltin(lang.code) && (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteLanguageRequest(lang.code);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteLanguageRequest(lang.code);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t('autofix.k5fcc9b0e')}
|
||||||
|
className={`ml-0.5 inline-flex items-center justify-center rounded-full w-4 h-4 text-xs leading-none cursor-pointer transition ${
|
||||||
|
activeLang === lang.code
|
||||||
|
? 'bg-white/20 hover:bg-white/40 text-white'
|
||||||
|
: 'bg-slate-200 hover:bg-red-100 text-slate-500 hover:text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Hero header card ─────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
ref={headerRef}
|
||||||
|
className="rounded-[30px] border border-white/80 bg-white/85 px-5 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:px-8 md:py-8"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
|
{t('autofix.ka8c928ac')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-black tracking-tight text-slate-950 md:text-4xl">
|
||||||
|
{t('autofix.k346a2c64')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
|
||||||
|
{t('autofix.k7227f13d')} {totalKeys} {t('autofix.k511d7fab')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onScan}
|
||||||
|
disabled={isScanning || isAutoFixing}
|
||||||
|
className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
{isScanning ? t('autofix.kf191f6df5') : t('autofix.k9863fa5')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onBackToAdmin}
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.kea7cde7a')}
|
||||||
|
</button>
|
||||||
|
{saved && !isDirty && (
|
||||||
|
<span className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700">{t('autofix.kac6aab53')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* stat pills */}
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs text-slate-600">
|
||||||
|
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">
|
||||||
|
{allLanguages.length} {allLanguages.length === 1 ? t('autofix.k20eb1f87') : t('autofix.ka6cf3286')}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1.5 font-medium text-slate-700 shadow-sm">
|
||||||
|
{totalKeys} {t('autofix.k3931709b')}
|
||||||
|
</span>
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-3 py-1.5 font-medium shadow-sm ${
|
||||||
|
allTabStats.missing > 0
|
||||||
|
? 'border-red-200 bg-red-50 text-red-700'
|
||||||
|
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||||
|
}`}>
|
||||||
|
{allTabStats.missing > 0 ? `${allTabStats.missing} ${t('autofix.k571ffd91')}` : t('autofix.kdcc78d97')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Save error banner ────────────────────────────────── */}
|
||||||
|
{saveError && (
|
||||||
|
<div className="rounded-2xl border border-red-200 bg-red-50/80 backdrop-blur px-5 py-3 text-sm text-red-700 shadow-sm">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Language tabs card ───────────────────────────────── */}
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/85 px-4 py-3 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur md:px-5">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{englishLanguage && renderLanguageButton(englishLanguage)}
|
||||||
|
{germanLanguage && renderLanguageButton(germanLanguage)}
|
||||||
|
{otherLanguages.map((lang) => renderLanguageButton(lang))}
|
||||||
|
<button
|
||||||
|
onClick={onOpenAddLanguage}
|
||||||
|
className="flex shrink-0 items-center gap-1.5 px-4 py-2.5 rounded-2xl text-sm font-medium text-slate-400 border border-dashed border-slate-300 hover:border-slate-400 hover:text-slate-600 transition whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>{t('autofix.k7a515516')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Translation progress card ────────────────────────── */}
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/85 px-5 py-4 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur flex items-center gap-5">
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div className="flex justify-between text-xs text-slate-500">
|
||||||
|
<span>{t('autofix.kb8f33873')}</span>
|
||||||
|
<span className="font-medium text-slate-700">{allTabStats.translated} / {allTabStats.total} {t('autofix.k33f55455')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-slate-900 transition-all duration-500"
|
||||||
|
style={{ width: `${translationProgressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-black tracking-tight text-slate-950 tabular-nums">
|
||||||
|
{translationProgressPercent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Wizard nudge card ────────────────────────────────── */}
|
||||||
|
{activeLang !== 'en' && allTabStats.missing > 0 && wizardMissingKeysCount > 0 && (
|
||||||
|
<div className="rounded-[28px] border border-indigo-200/80 bg-indigo-50/80 backdrop-blur px-5 py-4 shadow-[0_22px_60px_-34px_rgba(99,102,241,0.3)] flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-indigo-950">{t('autofix.k5e5e8744')}</p>
|
||||||
|
<p className="text-xs text-indigo-800/80 mt-1">
|
||||||
|
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang} still has{' '}
|
||||||
|
<span className="font-semibold">{wizardMissingKeysCount}</span>{t('autofix.k0a50d234')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenTranslationWizard}
|
||||||
|
className="shrink-0 rounded-2xl bg-slate-900 text-white px-4 py-2 text-xs font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.k725dd1d6')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/app/admin/language-management/components/ScanFixPanel.tsx
Normal file
100
src/app/admin/language-management/components/ScanFixPanel.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
type Props = {
|
||||||
|
fixableFiles: string[]
|
||||||
|
selectedFiles: string[]
|
||||||
|
isAutoFixing: boolean
|
||||||
|
forceConvertToClient: boolean
|
||||||
|
onToggleFile: (file: string) => void
|
||||||
|
onSelectAll: () => void
|
||||||
|
onClear: () => void
|
||||||
|
onToggleForceConvertToClient: () => void
|
||||||
|
onRunFixSelected: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScanFixPanel({
|
||||||
|
fixableFiles,
|
||||||
|
selectedFiles,
|
||||||
|
isAutoFixing,
|
||||||
|
forceConvertToClient,
|
||||||
|
onToggleFile,
|
||||||
|
onSelectAll,
|
||||||
|
onClear,
|
||||||
|
onToggleForceConvertToClient,
|
||||||
|
onRunFixSelected,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="mb-5 rounded-xl border border-indigo-200 bg-indigo-50/40 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-indigo-800">{t('autofix.k43218db0')}</h3>
|
||||||
|
<p className="text-xs text-indigo-700/90 mt-1">{t('autofix.k34a0a2e4')}</p>
|
||||||
|
<label className="mt-2 inline-flex items-center gap-2 text-xs text-indigo-800">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceConvertToClient}
|
||||||
|
onChange={onToggleForceConvertToClient}
|
||||||
|
className="h-3.5 w-3.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>{t('autofix.k68c88f41')}</label>
|
||||||
|
<p className="text-[11px] text-indigo-700/80 mt-1">{t('autofix.k6569783c')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelectAll}
|
||||||
|
className="rounded-md border border-indigo-200 bg-white px-2.5 py-1.5 text-xs font-medium text-indigo-700 hover:bg-indigo-50"
|
||||||
|
>{t('autofix.k4c6eb72c')}</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fixableFiles.length === 0 ? (
|
||||||
|
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">{t('autofix.ke3480838')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2 max-h-44 overflow-y-auto pr-1">
|
||||||
|
{fixableFiles.map((file) => {
|
||||||
|
const checked = selectedFiles.includes(file)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={file}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-indigo-100 bg-white px-3 py-2 text-xs text-indigo-900"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onToggleFile(file)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="font-mono break-all">{file}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<p className="text-xs text-indigo-700">
|
||||||
|
Selected: {selectedFiles.length} / {fixableFiles.length}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isAutoFixing || selectedFiles.length === 0}
|
||||||
|
onClick={onRunFixSelected}
|
||||||
|
className="rounded-md bg-indigo-700 text-white px-3 py-1.5 text-xs font-semibold hover:bg-indigo-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isAutoFixing ? 'Applying fix...' : `Fix selected files (${selectedFiles.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,364 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ScanFixPanel from './ScanFixPanel';
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
import type { WorkspaceScanResult } from '../hooks/useI18nScanWorkflow';
|
||||||
|
import { useModalAnimation } from '../hooks/useModalAnimation';
|
||||||
|
|
||||||
|
type LanguageEntry = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NamespaceScanResult = {
|
||||||
|
ns: string;
|
||||||
|
total: number;
|
||||||
|
translated: number;
|
||||||
|
missing: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
lastScanTime: Date | null;
|
||||||
|
workspaceScan: WorkspaceScanResult | null;
|
||||||
|
totalKeys: number;
|
||||||
|
namespacesCount: number;
|
||||||
|
allLanguages: LanguageEntry[];
|
||||||
|
activeLang: string;
|
||||||
|
scanResults: NamespaceScanResult[];
|
||||||
|
scanError: string | null;
|
||||||
|
isScanning: boolean;
|
||||||
|
isAutoFixing: boolean;
|
||||||
|
isAddingMissingKeys: boolean;
|
||||||
|
fixableFiles: string[];
|
||||||
|
selectedFiles: string[];
|
||||||
|
forceConvertToClient: boolean;
|
||||||
|
onToggleFile: (file: string) => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onToggleForceConvertToClient: () => void;
|
||||||
|
onRunFixSelected: () => void;
|
||||||
|
onAddMissingKeys: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScanResultsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
lastScanTime,
|
||||||
|
workspaceScan,
|
||||||
|
totalKeys,
|
||||||
|
namespacesCount,
|
||||||
|
allLanguages,
|
||||||
|
activeLang,
|
||||||
|
scanResults,
|
||||||
|
scanError,
|
||||||
|
isScanning,
|
||||||
|
isAutoFixing,
|
||||||
|
isAddingMissingKeys,
|
||||||
|
fixableFiles,
|
||||||
|
selectedFiles,
|
||||||
|
forceConvertToClient,
|
||||||
|
onToggleFile,
|
||||||
|
onSelectAll,
|
||||||
|
onClear,
|
||||||
|
onToggleForceConvertToClient,
|
||||||
|
onRunFixSelected,
|
||||||
|
onAddMissingKeys,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isRendered, isVisible } = useModalAnimation(isOpen);
|
||||||
|
|
||||||
|
if (!isRendered) return null;
|
||||||
|
|
||||||
|
const totalTranslated = scanResults.reduce((sum, row) => sum + row.translated, 0);
|
||||||
|
const coveragePercent = totalKeys === 0
|
||||||
|
? 100
|
||||||
|
: Math.round((totalTranslated / totalKeys) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed inset-0 z-[9999] flex items-start justify-center pt-14 pb-6 bg-black/50 backdrop-blur-sm transition-opacity duration-200 ${
|
||||||
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}>
|
||||||
|
<div className={`mx-3 w-full max-w-[1400px] rounded-3xl border border-slate-200 bg-white shadow-2xl flex flex-col max-h-[calc(100vh-5rem)] overflow-hidden transform transition-all duration-200 ${
|
||||||
|
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
|
||||||
|
}`}>
|
||||||
|
<div className="px-8 pt-6 pb-5 border-b border-slate-200 bg-gradient-to-b from-slate-50 to-white">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-[#1C2B4A]">{t('autofix.kb2217bdf')}</h2>
|
||||||
|
<p className="text-sm text-slate-600 mt-1">
|
||||||
|
{workspaceScan
|
||||||
|
? `${workspaceScan.scannedFiles} files across ${workspaceScan.scannedDirectories} directories scanned`
|
||||||
|
: `${totalKeys} keys across ${namespacesCount} namespaces`}
|
||||||
|
{lastScanTime && (
|
||||||
|
<span className="ml-2 text-xs text-slate-500">
|
||||||
|
· Scanned {lastScanTime.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-slate-700 ml-4 text-xl leading-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
||||||
|
<span className="font-medium">Overall coverage ({allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang})</span>
|
||||||
|
<span>
|
||||||
|
{activeLang === 'en'
|
||||||
|
? `${totalKeys} / ${totalKeys}`
|
||||||
|
: `${totalTranslated} / ${totalKeys}`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 rounded-full bg-slate-100 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[#1C2B4A] transition-all"
|
||||||
|
style={{ width: activeLang === 'en' ? '100%' : `${coveragePercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{workspaceScan && (
|
||||||
|
<div className="mt-5 grid grid-cols-2 lg:grid-cols-6 gap-2.5">
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2.5">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-slate-500">{t('autofix.k91052e3f')}</p>
|
||||||
|
<p className="text-sm font-semibold text-[#1C2B4A]">{workspaceScan.translationCallCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2.5">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-slate-500">{t('autofix.k90a6e795')}</p>
|
||||||
|
<p className="text-sm font-semibold text-[#1C2B4A]">{workspaceScan.uniqueKeyCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-red-600">{t('autofix.k8cf40180')}</p>
|
||||||
|
<p className="text-sm font-semibold text-red-600">{workspaceScan.missingKeys.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2.5">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-amber-700">{t('autofix.k1bf4ffa4')}</p>
|
||||||
|
<p className="text-sm font-semibold text-amber-700">{workspaceScan.untranslatedLiterals.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-green-200 bg-green-50 px-3 py-2.5">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-green-700">{t('autofix.k9b173204')}</p>
|
||||||
|
<p className="text-sm font-semibold text-green-700">{workspaceScan.changedFileCount ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-indigo-200 bg-indigo-50 px-3 py-2.5">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-indigo-700">{t('autofix.k60874ea3')}</p>
|
||||||
|
<p className="text-sm font-semibold text-indigo-700">{workspaceScan.createdKeyCount ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 px-8 py-5">
|
||||||
|
{scanError && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{scanError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isScanning && (
|
||||||
|
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-700">{t('autofix.k0d9c63c5')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAutoFixing && (
|
||||||
|
<div className="mb-4 rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-700">{t('autofix.ka802064d')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAddingMissingKeys && (
|
||||||
|
<div className="mb-4 rounded-lg border border-cyan-200 bg-cyan-50 px-3 py-2 text-sm text-cyan-700">{t('autofix.k68e73120')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-12 gap-5 items-start">
|
||||||
|
<div className="xl:col-span-5 space-y-4">
|
||||||
|
<ScanFixPanel
|
||||||
|
fixableFiles={fixableFiles}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
isAutoFixing={isAutoFixing}
|
||||||
|
forceConvertToClient={forceConvertToClient}
|
||||||
|
onToggleFile={onToggleFile}
|
||||||
|
onSelectAll={onSelectAll}
|
||||||
|
onClear={onClear}
|
||||||
|
onToggleForceConvertToClient={onToggleForceConvertToClient}
|
||||||
|
onRunFixSelected={onRunFixSelected}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{workspaceScan && (workspaceScan.changedFileCount ?? 0) > 0 && (
|
||||||
|
<div className="rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
|
||||||
|
Auto-fix updated {(workspaceScan.changedFileCount ?? 0)} files and created {(workspaceScan.createdKeyCount ?? 0)} translation keys.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workspaceScan && Array.isArray(workspaceScan.changedFiles) && workspaceScan.changedFiles.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-green-200 bg-green-50/50 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-green-700 mb-2">{t('autofix.k5ad4d864')}</h3>
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{workspaceScan.changedFiles.map((entry) => (
|
||||||
|
<div key={entry.file} className="rounded-md border border-green-100 bg-white px-3 py-2">
|
||||||
|
<p className="font-mono text-xs text-green-800">{entry.file}</p>
|
||||||
|
<p className="text-[11px] text-gray-600 mt-1">
|
||||||
|
{entry.replacements} replacements
|
||||||
|
{entry.addedImport ? ' · import added' : ''}
|
||||||
|
{entry.addedHook ? ' · hook added' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workspaceScan && Array.isArray(workspaceScan.skippedFiles) && workspaceScan.skippedFiles.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-2">{t('autofix.k56a52520')}</h3>
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{workspaceScan.skippedFiles.map((entry) => (
|
||||||
|
<div key={entry.file} className="rounded-md border border-gray-100 bg-white px-3 py-2">
|
||||||
|
<p className="font-mono text-xs text-gray-700">{entry.file}</p>
|
||||||
|
<p className="text-[11px] text-gray-500 mt-1">{entry.reason}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workspaceScan && Array.isArray(workspaceScan.autoFixDebug) && workspaceScan.autoFixDebug.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-700 mb-2">{t('autofix.kc8034db6')}</h3>
|
||||||
|
<p className="text-xs text-slate-600 mb-3">{t('autofix.k9bd0812b')}</p>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||||
|
{workspaceScan.autoFixDebug.slice(0, 200).map((entry) => (
|
||||||
|
<div key={`${entry.file}-${entry.status}-${entry.beforeLiteralCount}-${entry.afterLiteralCount}`} className="rounded-md border border-slate-200 bg-white px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="font-mono text-xs text-slate-800 break-all">{entry.file}</p>
|
||||||
|
<span className={`text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full ${
|
||||||
|
entry.status === 'changed'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: entry.status === 'skipped'
|
||||||
|
? 'bg-amber-100 text-amber-700'
|
||||||
|
: 'bg-slate-100 text-slate-700'
|
||||||
|
}`}>
|
||||||
|
{entry.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-600 mt-1">
|
||||||
|
before: {entry.beforeLiteralCount} · text: {entry.textReplacements} · attr: {entry.attrReplacements} · after: {entry.afterLiteralCount}
|
||||||
|
</p>
|
||||||
|
{entry.reason && <p className="text-[11px] text-slate-500 mt-1">{entry.reason}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="xl:col-span-7 space-y-4">
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50/40 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-[#1C2B4A] mb-3">{t('autofix.kbe30c353')}</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100">
|
||||||
|
<th className="pb-2 text-left font-medium text-gray-500">Namespace</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-gray-500">Keys</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-gray-500">Translated</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-gray-500">Missing</th>
|
||||||
|
<th className="pb-2 text-left pl-4 font-medium text-gray-500">Coverage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{scanResults.map(({ ns, total, translated, missing }) => {
|
||||||
|
const pct = total === 0 ? 100 : Math.round((translated / total) * 100);
|
||||||
|
return (
|
||||||
|
<tr key={ns} className="hover:bg-gray-50">
|
||||||
|
<td className="py-2 font-mono text-xs text-[#1C2B4A]">{ns}</td>
|
||||||
|
<td className="py-2 text-right text-gray-500">{total}</td>
|
||||||
|
<td className="py-2 text-right text-green-600 font-medium">{translated}</td>
|
||||||
|
<td className={`py-2 text-right font-medium ${missing > 0 ? 'text-red-500' : 'text-gray-400'}`}>
|
||||||
|
{missing}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-24 h-1.5 rounded-full bg-gray-100 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${
|
||||||
|
pct === 100 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-400' : 'bg-red-400'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{workspaceScan && workspaceScan.missingKeys.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50/50 p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-red-700">{t('autofix.kae63e46a')}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddMissingKeys}
|
||||||
|
disabled={isAddingMissingKeys || isAutoFixing || isScanning}
|
||||||
|
className="rounded-md bg-red-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>{t('autofix.kbd3f0f44')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{workspaceScan.missingKeys.map((entry) => (
|
||||||
|
<div key={entry.key} className="rounded-md border border-red-100 bg-white px-3 py-2">
|
||||||
|
<p className="font-mono text-xs text-red-700">{entry.key}</p>
|
||||||
|
<p className="text-[11px] text-gray-500 mt-1">
|
||||||
|
{entry.files.slice(0, 3).join(', ')}
|
||||||
|
{entry.files.length > 3 ? ` (+${entry.files.length - 3} more)` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workspaceScan && workspaceScan.untranslatedLiterals.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50/50 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-amber-700 mb-2">{t('autofix.k14eb468b')}</h3>
|
||||||
|
<p className="text-xs text-amber-700/80 mb-3">
|
||||||
|
These literals appear directly in JSX. Replace them with t('...') to make the page translatable.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 max-h-56 overflow-y-auto pr-1">
|
||||||
|
{workspaceScan.untranslatedLiterals.slice(0, 80).map((entry) => (
|
||||||
|
<div key={entry.text} className="rounded-md border border-amber-100 bg-white px-3 py-2">
|
||||||
|
<p className="text-xs font-medium text-amber-800">{entry.text}</p>
|
||||||
|
<p className="text-[11px] text-gray-500 mt-1">
|
||||||
|
{entry.files.slice(0, 3).join(', ')}
|
||||||
|
{entry.files.length > 3 ? ` (+${entry.files.length - 3} more)` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-8 py-4 border-t border-slate-200 flex justify-between items-center bg-white">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Scan now checks workspace files (pages, components, hooks, utils) and compares used keys against en.ts.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,527 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { MutableRefObject, RefObject } from 'react';
|
||||||
|
import { getEnglishValue, useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
import type { NamespaceCategory } from '../hooks/useNamespaceCategories';
|
||||||
|
|
||||||
|
type LanguageEntry = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
activeCategory: string;
|
||||||
|
setActiveCategory: (category: string) => void;
|
||||||
|
categoriesWithKnownNamespaces: NamespaceCategory[];
|
||||||
|
allTabStats: { total: number; translated: number; missing: number };
|
||||||
|
categoryTabStats: Record<string, { total: number; translated: number; missing: number }>;
|
||||||
|
globalTabStats: { total: number; translated: number; missing: number };
|
||||||
|
activeLang: string;
|
||||||
|
allLanguages: LanguageEntry[];
|
||||||
|
search: string;
|
||||||
|
setSearch: (value: string) => void;
|
||||||
|
autoScrollOnPanelOpen: boolean;
|
||||||
|
setAutoScrollOnPanelOpen: (value: boolean) => void;
|
||||||
|
autoScrollOnSave: boolean;
|
||||||
|
setAutoScrollOnSave: (value: boolean) => void;
|
||||||
|
newGlobalKeySelection: string;
|
||||||
|
setNewGlobalKeySelection: (value: string) => void;
|
||||||
|
availableGlobalKeyOptions: string[];
|
||||||
|
addGlobalKey: (key: string) => void;
|
||||||
|
globalFilteredKeys: string[];
|
||||||
|
removeGlobalKey: (key: string) => void;
|
||||||
|
getDisplayValue: (key: string) => string;
|
||||||
|
translations: Record<string, Record<string, string>>;
|
||||||
|
handleChange: (key: string, value: string) => void;
|
||||||
|
globalKeySet: Set<string>;
|
||||||
|
englishReferenceKeySet: Set<string>;
|
||||||
|
setEnglishReferenceForKey: (key: string, enabled: boolean) => void;
|
||||||
|
filteredNs: string[];
|
||||||
|
filteredGroups: Record<string, string[]>;
|
||||||
|
activeNamespacePanel: string | null;
|
||||||
|
setActiveNamespacePanel: (next: string | null | ((prev: string | null) => string | null)) => void;
|
||||||
|
namespaceTranslationStats: Record<string, { total: number; translated: number; missing: number }>;
|
||||||
|
openedNamespacePanelRef: RefObject<HTMLDivElement | null>;
|
||||||
|
openFromPanelClickRef: MutableRefObject<boolean>;
|
||||||
|
onBackToPanels: () => void;
|
||||||
|
onOpenCategoryManager: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TranslationCoverageEditor({
|
||||||
|
activeCategory,
|
||||||
|
setActiveCategory,
|
||||||
|
categoriesWithKnownNamespaces,
|
||||||
|
allTabStats,
|
||||||
|
categoryTabStats,
|
||||||
|
globalTabStats,
|
||||||
|
activeLang,
|
||||||
|
allLanguages,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
autoScrollOnPanelOpen,
|
||||||
|
setAutoScrollOnPanelOpen,
|
||||||
|
autoScrollOnSave,
|
||||||
|
setAutoScrollOnSave,
|
||||||
|
newGlobalKeySelection,
|
||||||
|
setNewGlobalKeySelection,
|
||||||
|
availableGlobalKeyOptions,
|
||||||
|
addGlobalKey,
|
||||||
|
globalFilteredKeys,
|
||||||
|
removeGlobalKey,
|
||||||
|
getDisplayValue,
|
||||||
|
translations,
|
||||||
|
handleChange,
|
||||||
|
globalKeySet,
|
||||||
|
englishReferenceKeySet,
|
||||||
|
setEnglishReferenceForKey,
|
||||||
|
filteredNs,
|
||||||
|
filteredGroups,
|
||||||
|
activeNamespacePanel,
|
||||||
|
setActiveNamespacePanel,
|
||||||
|
namespaceTranslationStats,
|
||||||
|
openedNamespacePanelRef,
|
||||||
|
openFromPanelClickRef,
|
||||||
|
onBackToPanels,
|
||||||
|
onOpenCategoryManager,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Shared tab button renderer
|
||||||
|
const renderCategoryTab = (
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
stats: { total: number; translated: number; missing: number }
|
||||||
|
) => {
|
||||||
|
const isActive = activeCategory === key;
|
||||||
|
const hasMissing = activeLang !== 'en' && stats.missing > 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setActiveCategory(key)}
|
||||||
|
className={`flex shrink-0 items-center gap-2 px-4 py-2.5 rounded-2xl text-sm font-medium transition whitespace-nowrap ${
|
||||||
|
isActive
|
||||||
|
? hasMissing
|
||||||
|
? 'bg-red-600 text-white shadow-[0_18px_40px_-24px_rgba(220,38,38,0.7)]'
|
||||||
|
: 'bg-slate-900 text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)]'
|
||||||
|
: hasMissing
|
||||||
|
? 'border border-red-200 bg-red-50/80 text-red-700 hover:bg-red-100'
|
||||||
|
: 'bg-transparent text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-transparent hover:border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{hasMissing ? (
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] leading-none font-semibold tabular-nums ${
|
||||||
|
isActive ? 'bg-white/20 text-white' : 'bg-red-100 text-red-600'
|
||||||
|
}`}>
|
||||||
|
{stats.missing}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] leading-none font-medium tabular-nums ${
|
||||||
|
isActive ? 'bg-white/15 text-white/80' : 'bg-slate-100 text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{stats.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Category tabs card ───────────────────────────────── */}
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/85 px-5 py-4 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-slate-950">{t('autofix.k5f978731')}</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.kb7a30760')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenCategoryManager}
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.kd6e42900')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{renderCategoryTab('all', 'All', allTabStats)}
|
||||||
|
{renderCategoryTab('global', 'Global', globalTabStats)}
|
||||||
|
{categoriesWithKnownNamespaces.map((cat) =>
|
||||||
|
renderCategoryTab(cat.id, cat.label, categoryTabStats[cat.id] ?? { total: 0, translated: 0, missing: 0 })
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Search + options row ─────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="relative flex-1 min-w-[14rem] max-w-sm">
|
||||||
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t('autofix.kbf49d59b')}
|
||||||
|
className="w-full rounded-2xl border border-slate-200 bg-white/90 pl-9 pr-3 py-2 text-sm shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300 transition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-slate-600 select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoScrollOnPanelOpen}
|
||||||
|
onChange={(e) => setAutoScrollOnPanelOpen(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
|
||||||
|
/>
|
||||||
|
{t('autofix.kfd1e0089')}
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-slate-600 select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoScrollOnSave}
|
||||||
|
onChange={(e) => setAutoScrollOnSave(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
|
||||||
|
/>
|
||||||
|
{t('autofix.k23e95df1')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content area ────────────────────────────────────── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
|
||||||
|
{/* Global keys table */}
|
||||||
|
{activeCategory === 'global' && (
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/85 overflow-hidden shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur">
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-slate-950">{t('autofix.k6cfeedd3')}</span>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.kad7d8c49')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={newGlobalKeySelection}
|
||||||
|
onChange={(e) => setNewGlobalKeySelection(e.target.value)}
|
||||||
|
className="w-72 max-w-full rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||||
|
>
|
||||||
|
<option value="">{t('autofix.k47bce570')}</option>
|
||||||
|
{availableGlobalKeyOptions.map((key) => (
|
||||||
|
<option key={key} value={key}>{key}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!newGlobalKeySelection) return;
|
||||||
|
addGlobalKey(newGlobalKeySelection);
|
||||||
|
setNewGlobalKeySelection('');
|
||||||
|
}}
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{globalFilteredKeys.length > 0 ? (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-100 bg-slate-50/60">
|
||||||
|
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">Key</th>
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">English</th>
|
||||||
|
)}
|
||||||
|
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{globalFilteredKeys.map((key) => {
|
||||||
|
const enVal = getEnglishValue(key);
|
||||||
|
const currentVal = getDisplayValue(key);
|
||||||
|
const hasOverride = (translations[activeLang]?.[key] ?? '') !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={key} className="border-b border-slate-50 last:border-0 hover:bg-slate-50/60 transition-colors">
|
||||||
|
<td className="px-5 py-2.5 font-mono text-xs text-slate-500 align-top pt-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span>{key}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('autofix.kc02b17c3')}
|
||||||
|
onClick={() => removeGlobalKey(key)}
|
||||||
|
className="shrink-0 rounded-xl border border-red-200 px-1.5 py-0.5 text-[10px] text-red-500 hover:bg-red-50 transition"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<td className="px-5 py-2.5 text-slate-500 align-top pt-3 text-xs">{enVal}</td>
|
||||||
|
)}
|
||||||
|
<td className="px-5 py-2.5">
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
rows={1}
|
||||||
|
value={activeLang === 'en' ? currentVal : (translations[activeLang]?.[key] ?? '')}
|
||||||
|
onChange={(e) => handleChange(key, e.target.value)}
|
||||||
|
placeholder={activeLang === 'en' ? '' : enVal}
|
||||||
|
className={`w-full rounded-xl border px-3 py-1.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition ${
|
||||||
|
hasOverride && activeLang !== 'en'
|
||||||
|
? 'border-emerald-300 bg-emerald-50/60'
|
||||||
|
: 'border-slate-200 bg-white'
|
||||||
|
}`}
|
||||||
|
style={{ minHeight: '2.25rem', fieldSizing: 'content' } as React.CSSProperties}
|
||||||
|
onInput={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.height = 'auto';
|
||||||
|
target.style.height = `${target.scrollHeight}px`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{hasOverride && activeLang !== 'en' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('autofix.k644d9ea8')}
|
||||||
|
onClick={() => handleChange(key, '')}
|
||||||
|
className="absolute top-1.5 right-2 text-xs text-slate-400 hover:text-red-500 transition"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="p-10 text-center text-sm text-slate-400">{t('autofix.k0700b1f2')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Namespace grid + open panel */}
|
||||||
|
{activeCategory !== 'global' && filteredNs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||||
|
{filteredNs.map((ns, idx) => {
|
||||||
|
const keys = filteredGroups[ns] ?? [];
|
||||||
|
const nsStats = namespaceTranslationStats[ns] ?? {
|
||||||
|
total: keys.length,
|
||||||
|
translated: keys.length,
|
||||||
|
missing: 0,
|
||||||
|
};
|
||||||
|
const hasMissing = activeLang !== 'en' && nsStats.missing > 0;
|
||||||
|
const isActive = activeNamespacePanel === ns;
|
||||||
|
const activeIdx = activeNamespacePanel ? filteredNs.indexOf(activeNamespacePanel) : -1;
|
||||||
|
const shiftClass = !activeNamespacePanel || isActive
|
||||||
|
? 'translate-x-0'
|
||||||
|
: idx < activeIdx ? '-translate-x-1' : 'translate-x-1';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ns}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
openFromPanelClickRef.current = true;
|
||||||
|
setActiveNamespacePanel((prev) => (prev === ns ? null : ns));
|
||||||
|
}}
|
||||||
|
className={`w-full rounded-[20px] border px-4 py-3 text-left transition-all duration-300 backdrop-blur ${shiftClass} ${
|
||||||
|
isActive
|
||||||
|
? hasMissing
|
||||||
|
? 'border-red-300 bg-red-50/90 shadow-[0_12px_30px_-18px_rgba(220,38,38,0.5)]'
|
||||||
|
: 'border-slate-300 bg-white/95 shadow-[0_12px_30px_-18px_rgba(15,23,42,0.4)]'
|
||||||
|
: hasMissing
|
||||||
|
? 'border-red-200 bg-red-50/60 hover:bg-red-50/90'
|
||||||
|
: 'border-white/80 bg-white/80 hover:bg-white/95 hover:border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className={`text-sm font-semibold capitalize ${isActive ? 'text-slate-950' : 'text-slate-800'}`}>
|
||||||
|
{ns}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${
|
||||||
|
isActive
|
||||||
|
? hasMissing ? 'bg-red-500 text-white' : 'bg-slate-900 text-white'
|
||||||
|
: hasMissing ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}>
|
||||||
|
{nsStats.translated}/{nsStats.total}
|
||||||
|
</span>
|
||||||
|
{hasMissing && (
|
||||||
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${
|
||||||
|
isActive ? 'bg-red-200 text-red-800' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{nsStats.missing}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-400">
|
||||||
|
{isActive ? t('autofix.k77d01d6a') : t('autofix.kcfb5fb54')}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeNamespacePanel && filteredGroups[activeNamespacePanel] && (
|
||||||
|
<div
|
||||||
|
ref={openedNamespacePanelRef}
|
||||||
|
className="rounded-[28px] border border-white/80 bg-white/90 overflow-hidden shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur transition-all duration-300"
|
||||||
|
>
|
||||||
|
{/* Panel header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50/60">
|
||||||
|
<span className="font-bold text-slate-950 capitalize text-base">{activeNamespacePanel}</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs rounded-full border border-slate-200 bg-white px-2 py-0.5 text-slate-600 font-medium">
|
||||||
|
{(namespaceTranslationStats[activeNamespacePanel]?.translated ?? 0)}/{(namespaceTranslationStats[activeNamespacePanel]?.total ?? 0)}
|
||||||
|
</span>
|
||||||
|
{activeLang !== 'en' && (namespaceTranslationStats[activeNamespacePanel]?.missing ?? 0) > 0 && (
|
||||||
|
<span className="text-xs rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-red-700 font-medium">
|
||||||
|
{namespaceTranslationStats[activeNamespacePanel]?.missing} missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-100 bg-slate-50/40">
|
||||||
|
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">Key</th>
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 w-1/3">English</th>
|
||||||
|
)}
|
||||||
|
<th className="px-5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredGroups[activeNamespacePanel].map((key) => {
|
||||||
|
const enVal = getEnglishValue(key);
|
||||||
|
const currentVal = getDisplayValue(key);
|
||||||
|
const isGlobal = globalKeySet.has(key);
|
||||||
|
const isEnglishReference = englishReferenceKeySet.has(key);
|
||||||
|
const visibleValue = activeLang === 'en'
|
||||||
|
? currentVal
|
||||||
|
: (translations[activeLang]?.[key] ?? '');
|
||||||
|
const hasOverride = (translations[activeLang]?.[key] ?? '') !== '';
|
||||||
|
const isMissingInOpenedPanel =
|
||||||
|
activeLang !== 'en' &&
|
||||||
|
!isGlobal &&
|
||||||
|
!isEnglishReference && (
|
||||||
|
visibleValue.trim() === '' ||
|
||||||
|
visibleValue.trim() === enVal.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={key} className={`border-b border-slate-50 last:border-0 transition-colors ${
|
||||||
|
isMissingInOpenedPanel ? 'bg-red-50/50 hover:bg-red-50/80' : 'hover:bg-slate-50/60'
|
||||||
|
}`}>
|
||||||
|
<td className={`px-5 py-2.5 font-mono text-xs align-top pt-3 ${
|
||||||
|
isMissingInOpenedPanel ? 'text-red-700' : 'text-slate-500'
|
||||||
|
}`}>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="block">{key}</span>
|
||||||
|
{isGlobal && (
|
||||||
|
<span className="rounded-full border border-indigo-200 bg-indigo-50 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700">
|
||||||
|
Global
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isEnglishReference && activeLang !== 'en' && (
|
||||||
|
<span className="rounded-full border border-sky-200 bg-sky-50 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700">{t('autofix.k8de6d3df')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] font-sans text-slate-500 select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isGlobal}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) { addGlobalKey(key); return; }
|
||||||
|
removeGlobalKey(key);
|
||||||
|
}}
|
||||||
|
className="h-3.5 w-3.5 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
|
||||||
|
/>
|
||||||
|
{t('autofix.kb1cf599b')}
|
||||||
|
</label>
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] font-sans text-slate-500 select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isEnglishReference}
|
||||||
|
onChange={(e) => setEnglishReferenceForKey(key, e.target.checked)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-slate-300 text-slate-900 focus:ring-slate-900/20"
|
||||||
|
/>{t('autofix.k6d79b1df')}</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<td className="px-5 py-2.5 text-slate-400 align-top pt-3 text-xs">{enVal}</td>
|
||||||
|
)}
|
||||||
|
<td className="px-5 py-2.5">
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
rows={1}
|
||||||
|
value={visibleValue}
|
||||||
|
onChange={(e) => handleChange(key, e.target.value)}
|
||||||
|
placeholder={activeLang === 'en' ? '' : enVal}
|
||||||
|
className={`w-full rounded-xl border px-3 py-1.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-slate-900/20 transition ${
|
||||||
|
isMissingInOpenedPanel
|
||||||
|
? 'border-red-300 bg-red-50/60'
|
||||||
|
: hasOverride && activeLang !== 'en'
|
||||||
|
? 'border-emerald-300 bg-emerald-50/60'
|
||||||
|
: 'border-slate-200 bg-white'
|
||||||
|
}`}
|
||||||
|
style={{ minHeight: '2.25rem', fieldSizing: 'content' } as React.CSSProperties}
|
||||||
|
onInput={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.height = 'auto';
|
||||||
|
target.style.height = `${target.scrollHeight}px`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{hasOverride && activeLang !== 'en' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('autofix.k644d9ea8')}
|
||||||
|
onClick={() => handleChange(key, '')}
|
||||||
|
className="absolute top-1.5 right-2 text-xs text-slate-400 hover:text-red-500 transition"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100 px-5 py-3 bg-slate-50/40 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBackToPanels}
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.k6aba2cb0')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeCategory !== 'global' && filteredNs.length === 0 && (
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/85 p-10 text-center text-sm text-slate-400 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.18)] backdrop-blur">
|
||||||
|
{t('autofix.k6a892262')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
import type { LanguageEntry } from '../hooks/useLanguageManagementTranslations';
|
||||||
|
import { useModalAnimation } from '../hooks/useModalAnimation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
currentWizardKey: string | null;
|
||||||
|
wizardIndex: number;
|
||||||
|
wizardMissingCount: number;
|
||||||
|
activeLang: string;
|
||||||
|
allLanguages: LanguageEntry[];
|
||||||
|
wizardInput: string;
|
||||||
|
setWizardInput: (value: string) => void;
|
||||||
|
wizardMarkGlobal: boolean;
|
||||||
|
setWizardMarkGlobal: (value: boolean) => void;
|
||||||
|
wizardUseEnglishReference: boolean;
|
||||||
|
setWizardUseEnglishReference: (value: boolean) => void;
|
||||||
|
englishValue: string;
|
||||||
|
addGlobalKey: (key: string) => void;
|
||||||
|
removeGlobalKey: (key: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onNext: () => void | Promise<void>;
|
||||||
|
isSavingStep?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TranslationWizardModal({
|
||||||
|
isOpen,
|
||||||
|
currentWizardKey,
|
||||||
|
wizardIndex,
|
||||||
|
wizardMissingCount,
|
||||||
|
activeLang,
|
||||||
|
allLanguages,
|
||||||
|
wizardInput,
|
||||||
|
setWizardInput,
|
||||||
|
wizardMarkGlobal,
|
||||||
|
setWizardMarkGlobal,
|
||||||
|
wizardUseEnglishReference,
|
||||||
|
setWizardUseEnglishReference,
|
||||||
|
englishValue,
|
||||||
|
addGlobalKey,
|
||||||
|
removeGlobalKey,
|
||||||
|
onClose,
|
||||||
|
onPrevious,
|
||||||
|
onSkip,
|
||||||
|
onNext,
|
||||||
|
isSavingStep = false,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isRendered, isVisible } = useModalAnimation(isOpen && Boolean(currentWizardKey));
|
||||||
|
|
||||||
|
if (!isRendered || !currentWizardKey) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed inset-0 z-[120] flex items-center justify-center bg-black/45 backdrop-blur-sm transition-opacity duration-200 ${
|
||||||
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}>
|
||||||
|
<div className={`mx-4 w-full max-w-2xl rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden transform transition-all duration-200 ${
|
||||||
|
isVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-[0.98]'
|
||||||
|
}`}>
|
||||||
|
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-[#1C2B4A]">{t('autofix.kcd190bdd')}</h2>
|
||||||
|
<p className="text-xs text-slate-600 mt-1">
|
||||||
|
Step {wizardIndex + 1} of {wizardMissingCount} for {allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-slate-400 hover:text-slate-700 text-xl leading-none">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5 space-y-4">
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-slate-500">Key</p>
|
||||||
|
<p className="font-mono text-xs text-slate-700 mt-1 break-all">{currentWizardKey}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-slate-500">{t('autofix.kc518ff5c')}</p>
|
||||||
|
<p className="text-sm text-slate-700 mt-1">{englishValue}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">Translation</label>
|
||||||
|
<textarea
|
||||||
|
value={wizardInput}
|
||||||
|
onChange={(e) => setWizardInput(e.target.value)}
|
||||||
|
placeholder={t('languageManagement.wizardInputPlaceholder')}
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={wizardMarkGlobal}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setWizardMarkGlobal(checked);
|
||||||
|
if (!currentWizardKey) return;
|
||||||
|
if (checked) {
|
||||||
|
addGlobalKey(currentWizardKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeGlobalKey(currentWizardKey);
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-[#1C2B4A] focus:ring-[#1C2B4A]"
|
||||||
|
/>
|
||||||
|
Counts as global key (same value as English)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={wizardUseEnglishReference}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setWizardUseEnglishReference(checked);
|
||||||
|
if (checked) {
|
||||||
|
setWizardInput(englishValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-[#1C2B4A] focus:ring-[#1C2B4A]"
|
||||||
|
/>
|
||||||
|
Use English value (this language only, not global)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 border-t border-slate-200 bg-white flex items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={wizardIndex === 0 || isSavingStep}
|
||||||
|
className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSkip}
|
||||||
|
disabled={isSavingStep}
|
||||||
|
className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={isSavingStep || (wizardInput.trim() === '' && !wizardMarkGlobal && !wizardUseEnglishReference)}
|
||||||
|
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90 disabled:opacity-50"
|
||||||
|
>{isSavingStep
|
||||||
|
? t('common.saving')
|
||||||
|
: (wizardIndex >= wizardMissingCount - 1 ? t('autofix.k230e2c3c') : t('autofix.kb270a988'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
src/app/admin/language-management/hooks/useI18nScanWorkflow.ts
Normal file
272
src/app/admin/language-management/hooks/useI18nScanWorkflow.ts
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { authFetch } from '../../../utils/authFetch'
|
||||||
|
|
||||||
|
const SCAN_EXCLUDED_FILE_MARKERS = ['src/app/components/toast/toastComponent.tsx'] as const
|
||||||
|
|
||||||
|
function normalizeScanPath(path: string): string {
|
||||||
|
return path.replace(/\\/g, '/').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExcludedScanFile(path: string): boolean {
|
||||||
|
const normalized = normalizeScanPath(path)
|
||||||
|
return SCAN_EXCLUDED_FILE_MARKERS.some((marker) => normalized.endsWith(marker.toLowerCase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterScanFiles(files: unknown): string[] {
|
||||||
|
if (!Array.isArray(files)) return []
|
||||||
|
return files
|
||||||
|
.filter((file): file is string => typeof file === 'string')
|
||||||
|
.filter((file) => !isExcludedScanFile(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseI18nScanWorkflowOptions = {
|
||||||
|
onUnauthorized?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceScanResult = {
|
||||||
|
scannedFiles: number
|
||||||
|
scannedDirectories: number
|
||||||
|
translationCallCount: number
|
||||||
|
uniqueKeyCount: number
|
||||||
|
missingKeys: Array<{ key: string; files: string[] }>
|
||||||
|
untranslatedLiterals: Array<{ text: string; files: string[] }>
|
||||||
|
autoFixEligibleFiles?: string[]
|
||||||
|
autoFixForceConvertibleFiles?: string[]
|
||||||
|
changedFileCount?: number
|
||||||
|
createdKeyCount?: number
|
||||||
|
changedFiles?: Array<{ file: string; replacements: number; addedImport: boolean; addedHook: boolean }>
|
||||||
|
skippedFiles?: Array<{ file: string; reason: string }>
|
||||||
|
autoFixDebug?: Array<{
|
||||||
|
file: string
|
||||||
|
status: 'changed' | 'skipped' | 'no-op'
|
||||||
|
beforeLiteralCount: number
|
||||||
|
textReplacements: number
|
||||||
|
attrReplacements: number
|
||||||
|
afterLiteralCount: number
|
||||||
|
reason?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScanResult(result: any): WorkspaceScanResult {
|
||||||
|
const missingKeys = Array.isArray(result?.missingKeys)
|
||||||
|
? result.missingKeys
|
||||||
|
.filter((entry: any) => entry && typeof entry === 'object' && typeof entry.key === 'string')
|
||||||
|
.map((entry: any) => ({ key: entry.key, files: filterScanFiles(entry.files) }))
|
||||||
|
.filter((entry: { key: string; files: string[] }) => entry.files.length > 0)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const untranslatedLiterals = Array.isArray(result?.untranslatedLiterals)
|
||||||
|
? result.untranslatedLiterals
|
||||||
|
.filter((entry: any) => entry && typeof entry === 'object' && typeof entry.text === 'string')
|
||||||
|
.map((entry: any) => ({ text: entry.text, files: filterScanFiles(entry.files) }))
|
||||||
|
.filter((entry: { text: string; files: string[] }) => entry.files.length > 0)
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
scannedFiles: Number(result?.scannedFiles ?? 0),
|
||||||
|
scannedDirectories: Number(result?.scannedDirectories ?? 0),
|
||||||
|
translationCallCount: Number(result?.translationCallCount ?? 0),
|
||||||
|
uniqueKeyCount: Number(result?.uniqueKeyCount ?? 0),
|
||||||
|
missingKeys,
|
||||||
|
untranslatedLiterals,
|
||||||
|
autoFixEligibleFiles: Array.isArray(result?.autoFixEligibleFiles) ? filterScanFiles(result.autoFixEligibleFiles) : undefined,
|
||||||
|
autoFixForceConvertibleFiles: Array.isArray(result?.autoFixForceConvertibleFiles) ? filterScanFiles(result.autoFixForceConvertibleFiles) : undefined,
|
||||||
|
changedFileCount: Number(result?.changedFileCount ?? 0),
|
||||||
|
createdKeyCount: Number(result?.createdKeyCount ?? 0),
|
||||||
|
changedFiles: Array.isArray(result?.changedFiles)
|
||||||
|
? result.changedFiles.filter((entry: any) => entry && typeof entry.file === 'string' && !isExcludedScanFile(entry.file))
|
||||||
|
: [],
|
||||||
|
skippedFiles: Array.isArray(result?.skippedFiles)
|
||||||
|
? result.skippedFiles.filter((entry: any) => entry && typeof entry.file === 'string' && !isExcludedScanFile(entry.file))
|
||||||
|
: [],
|
||||||
|
autoFixDebug: Array.isArray(result?.autoFixDebug) ? result.autoFixDebug : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFixableFiles(scan: WorkspaceScanResult | null, forceConvertToClient: boolean): string[] {
|
||||||
|
if (!scan) return []
|
||||||
|
|
||||||
|
if (forceConvertToClient && Array.isArray(scan.autoFixForceConvertibleFiles)) {
|
||||||
|
return [...scan.autoFixForceConvertibleFiles].sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer backend-provided eligibility, including the intentional empty list.
|
||||||
|
if (Array.isArray(scan.autoFixEligibleFiles)) {
|
||||||
|
return [...scan.autoFixEligibleFiles].sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSet = new Set<string>()
|
||||||
|
for (const entry of scan.untranslatedLiterals) {
|
||||||
|
for (const file of entry.files) {
|
||||||
|
fileSet.add(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(fileSet).sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18nScanWorkflow({ onUnauthorized }: UseI18nScanWorkflowOptions = {}) {
|
||||||
|
const onUnauthorizedRef = useRef(onUnauthorized)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onUnauthorizedRef.current = onUnauthorized
|
||||||
|
}, [onUnauthorized])
|
||||||
|
|
||||||
|
const [showScanModal, setShowScanModal] = useState(false)
|
||||||
|
const [lastScanTime, setLastScanTime] = useState<Date | null>(null)
|
||||||
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
|
const [isAutoFixing, setIsAutoFixing] = useState(false)
|
||||||
|
const [isAddingMissingKeys, setIsAddingMissingKeys] = useState(false)
|
||||||
|
const [scanError, setScanError] = useState<string | null>(null)
|
||||||
|
const [workspaceScan, setWorkspaceScan] = useState<WorkspaceScanResult | null>(null)
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||||
|
const [forceConvertToClient, setForceConvertToClient] = useState(false)
|
||||||
|
|
||||||
|
const fixableFiles = useMemo(
|
||||||
|
() => getFixableFiles(workspaceScan, forceConvertToClient),
|
||||||
|
[workspaceScan, forceConvertToClient]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedFiles(fixableFiles)
|
||||||
|
}, [fixableFiles])
|
||||||
|
|
||||||
|
const applyScanResult = (result: any) => {
|
||||||
|
const normalized = normalizeScanResult(result)
|
||||||
|
const files = getFixableFiles(normalized, forceConvertToClient)
|
||||||
|
|
||||||
|
setWorkspaceScan(normalized)
|
||||||
|
setSelectedFiles(files)
|
||||||
|
setLastScanTime(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
const scan = async () => {
|
||||||
|
setShowScanModal(true)
|
||||||
|
setIsScanning(true)
|
||||||
|
setScanError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch('/api/i18n/scan', { method: 'GET' })
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.()
|
||||||
|
throw new Error('Session expired. Redirecting to login.')
|
||||||
|
}
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
throw new Error(result?.message || 'Scan failed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
applyScanResult(result)
|
||||||
|
} catch (error) {
|
||||||
|
setScanError(error instanceof Error ? error.message : 'Scan failed.')
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runFixSelected = async () => {
|
||||||
|
const selectedEligible = selectedFiles.filter((file) => fixableFiles.includes(file) && !isExcludedScanFile(file))
|
||||||
|
|
||||||
|
if (selectedEligible.length === 0) {
|
||||||
|
setScanError('Select at least one file before running auto-fix.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowScanModal(true)
|
||||||
|
setIsAutoFixing(true)
|
||||||
|
setScanError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch('/api/i18n/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetFiles: selectedEligible,
|
||||||
|
forceConvertToClient,
|
||||||
|
excludedFiles: [...SCAN_EXCLUDED_FILE_MARKERS],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.()
|
||||||
|
throw new Error('Session expired. Redirecting to login.')
|
||||||
|
}
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
throw new Error(result?.message || 'Auto-fix failed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
applyScanResult(result)
|
||||||
|
} catch (error) {
|
||||||
|
setScanError(error instanceof Error ? error.message : 'Auto-fix failed.')
|
||||||
|
} finally {
|
||||||
|
setIsAutoFixing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMissingKeys = async () => {
|
||||||
|
setShowScanModal(true)
|
||||||
|
setIsAddingMissingKeys(true)
|
||||||
|
setScanError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch('/api/i18n/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode: 'add-missing-keys' }),
|
||||||
|
})
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.()
|
||||||
|
throw new Error('Session expired. Redirecting to login.')
|
||||||
|
}
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
throw new Error(result?.message || 'Adding missing keys failed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
applyScanResult(result)
|
||||||
|
} catch (error) {
|
||||||
|
setScanError(error instanceof Error ? error.message : 'Adding missing keys failed.')
|
||||||
|
} finally {
|
||||||
|
setIsAddingMissingKeys(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFileSelection = (file: string) => {
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
if (prev.includes(file)) return prev.filter((f) => f !== file)
|
||||||
|
return [...prev, file]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllFiles = () => {
|
||||||
|
setSelectedFiles(fixableFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSelectedFiles = () => {
|
||||||
|
setSelectedFiles([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showScanModal,
|
||||||
|
setShowScanModal,
|
||||||
|
lastScanTime,
|
||||||
|
isScanning,
|
||||||
|
isAutoFixing,
|
||||||
|
isAddingMissingKeys,
|
||||||
|
scanError,
|
||||||
|
workspaceScan,
|
||||||
|
selectedFiles,
|
||||||
|
fixableFiles,
|
||||||
|
forceConvertToClient,
|
||||||
|
setForceConvertToClient,
|
||||||
|
scan,
|
||||||
|
runFixSelected,
|
||||||
|
addMissingKeys,
|
||||||
|
toggleFileSelection,
|
||||||
|
selectAllFiles,
|
||||||
|
clearSelectedFiles,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,503 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { authFetch } from '../../../utils/authFetch';
|
||||||
|
import useAuthStore from '../../../store/authStore';
|
||||||
|
|
||||||
|
export type LanguageEntry = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileBackedI18nData = {
|
||||||
|
languages: LanguageEntry[];
|
||||||
|
translations: Record<string, Record<string, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseLanguageManagementTranslationsOptions = {
|
||||||
|
coreLanguages: Set<string>;
|
||||||
|
onAction?: (notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => void;
|
||||||
|
onUnauthorized?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE_LANG_STORAGE_KEY = 'language-management-active-lang';
|
||||||
|
|
||||||
|
export function useLanguageManagementTranslations({ coreLanguages, onAction, onUnauthorized }: UseLanguageManagementTranslationsOptions) {
|
||||||
|
const onActionRef = useRef(onAction);
|
||||||
|
const onUnauthorizedRef = useRef(onUnauthorized);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onUnauthorizedRef.current = onUnauthorized;
|
||||||
|
}, [onUnauthorized]);
|
||||||
|
|
||||||
|
const emitAction = useCallback((notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => {
|
||||||
|
try {
|
||||||
|
onActionRef.current?.(notice);
|
||||||
|
} catch {
|
||||||
|
// Keep persistence flow running even if notification UI crashes.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onActionRef.current = onAction;
|
||||||
|
}, [onAction]);
|
||||||
|
|
||||||
|
const [data, setData] = useState<FileBackedI18nData>({ languages: [], translations: {} });
|
||||||
|
const dataRef = useRef<FileBackedI18nData>({ languages: [], translations: {} });
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [loadingPhase, setLoadingPhase] = useState('idle');
|
||||||
|
const [loadingProgress, setLoadingProgress] = useState(0);
|
||||||
|
const [loadingLogs, setLoadingLogs] = useState<string[]>([]);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState('');
|
||||||
|
|
||||||
|
// Base translations from en.ts — used to detect which keys are genuine overrides (delta).
|
||||||
|
const enBaseRef = useRef<Record<string, string>>({});
|
||||||
|
// Overrides currently stored in DB per custom language — used to track what needs deletion on revert.
|
||||||
|
const dbOverridesRef = useRef<Record<string, Record<string, string>>>({});
|
||||||
|
|
||||||
|
const [activeLang, setActiveLang] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return 'en';
|
||||||
|
const stored = window.localStorage.getItem(ACTIVE_LANG_STORAGE_KEY);
|
||||||
|
return stored && stored.trim() !== '' ? stored : 'en';
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [newCode, setNewCode] = useState('');
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [addError, setAddError] = useState('');
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
|
const hasFetchedOnceRef = useRef(false);
|
||||||
|
|
||||||
|
const appendLoadingLog = useCallback((message: string) => {
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
setLoadingLogs((prev) => [...prev, `[${timestamp}] ${message}`].slice(-30));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchTranslationFiles = useCallback(async () => {
|
||||||
|
setLoadingPhase('refresh-auth');
|
||||||
|
setLoadingProgress(15);
|
||||||
|
appendLoadingLog('Refreshing auth session before translation bootstrap');
|
||||||
|
|
||||||
|
// Preflight refresh avoids immediate 401/refresh/retry loops during initial mount.
|
||||||
|
await useAuthStore.getState().refreshAuthToken(true).catch(() => null);
|
||||||
|
|
||||||
|
setLoadingPhase('fetch-translations');
|
||||||
|
setLoadingProgress(35);
|
||||||
|
appendLoadingLog('Fetching translation files from /api/i18n/translations');
|
||||||
|
|
||||||
|
const response = await authFetch('/api/i18n/translations', { cache: 'no-store' });
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
throw new Error(result?.message || 'Failed to load translation files.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const languages = Array.isArray(result.languages)
|
||||||
|
? result.languages.filter((lang: unknown): lang is LanguageEntry => {
|
||||||
|
if (!lang || typeof lang !== 'object') return false;
|
||||||
|
const entry = lang as LanguageEntry;
|
||||||
|
return typeof entry.code === 'string' && typeof entry.name === 'string';
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const translations = result.translations && typeof result.translations === 'object'
|
||||||
|
? result.translations as Record<string, Record<string, string>>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// Store en.ts as the immutable base for delta calculation.
|
||||||
|
enBaseRef.current = translations['en'] ?? {};
|
||||||
|
setLoadingProgress(60);
|
||||||
|
|
||||||
|
// Fetch DB overrides and merge them on top of .ts file values for custom languages.
|
||||||
|
// This ensures admin edits from previous sessions are restored on load.
|
||||||
|
try {
|
||||||
|
setLoadingPhase('fetch-preferences');
|
||||||
|
setLoadingProgress(75);
|
||||||
|
appendLoadingLog('Fetching DB overrides from /api/i18n/preferences');
|
||||||
|
|
||||||
|
const prefResponse = await authFetch('/api/i18n/preferences', { cache: 'no-store' });
|
||||||
|
if (prefResponse.ok) {
|
||||||
|
const prefResult = await prefResponse.json().catch(() => null);
|
||||||
|
const payload = prefResult?.preferences && typeof prefResult.preferences === 'object'
|
||||||
|
? prefResult.preferences
|
||||||
|
: prefResult;
|
||||||
|
const overridesArray: unknown[] = Array.isArray(payload?.translations) ? payload.translations : [];
|
||||||
|
|
||||||
|
const dbOverrides: Record<string, Record<string, string>> = {};
|
||||||
|
for (const entry of overridesArray) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
const e = entry as Record<string, unknown>;
|
||||||
|
if (typeof e.languageCode !== 'string' || typeof e.namespace !== 'string' ||
|
||||||
|
typeof e.key !== 'string' || typeof e.value !== 'string') continue;
|
||||||
|
const flatKey = `${e.namespace}.${e.key}`;
|
||||||
|
if (!dbOverrides[e.languageCode]) dbOverrides[e.languageCode] = {};
|
||||||
|
dbOverrides[e.languageCode][flatKey] = e.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbOverridesRef.current = dbOverrides;
|
||||||
|
|
||||||
|
// Deep-merge: DB overrides win on conflict, for custom languages only.
|
||||||
|
const mergedTranslations = { ...translations };
|
||||||
|
for (const [langCode, overrides] of Object.entries(dbOverrides)) {
|
||||||
|
if (coreLanguages.has(langCode)) continue;
|
||||||
|
mergedTranslations[langCode] = { ...(translations[langCode] ?? {}), ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
setData({ languages, translations: mergedTranslations });
|
||||||
|
setLoadingProgress(95);
|
||||||
|
appendLoadingLog('Translations + DB overrides merged successfully');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// DB overrides unavailable — fall through to file-only data.
|
||||||
|
appendLoadingLog('DB override fetch failed, continuing with file-only translations');
|
||||||
|
}
|
||||||
|
|
||||||
|
setData({ languages, translations });
|
||||||
|
setLoadingProgress(95);
|
||||||
|
appendLoadingLog('Loaded file-backed translations without DB overrides');
|
||||||
|
}, [appendLoadingLog, coreLanguages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasFetchedOnceRef.current) return;
|
||||||
|
hasFetchedOnceRef.current = true;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadingPhase('initializing');
|
||||||
|
setLoadingProgress(5);
|
||||||
|
appendLoadingLog('Starting language management bootstrap');
|
||||||
|
|
||||||
|
void fetchTranslationFiles()
|
||||||
|
.catch((error) => {
|
||||||
|
setLoadingPhase('error');
|
||||||
|
appendLoadingLog(`Bootstrap error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
emitAction({
|
||||||
|
variant: 'warning',
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to load translation files.',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingPhase('ready');
|
||||||
|
setLoadingProgress(100);
|
||||||
|
appendLoadingLog('Translation bootstrap complete');
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [appendLoadingLog, emitAction, fetchTranslationFiles]);
|
||||||
|
|
||||||
|
const allLanguages: LanguageEntry[] = useMemo(() => data.languages, [data.languages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (allLanguages.length === 0) return;
|
||||||
|
if (allLanguages.some((lang) => lang.code === activeLang)) return;
|
||||||
|
|
||||||
|
const fallback = allLanguages.find((lang) => lang.code === 'en')?.code ?? allLanguages[0].code;
|
||||||
|
setActiveLang(fallback);
|
||||||
|
}, [allLanguages, activeLang]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(ACTIVE_LANG_STORAGE_KEY, activeLang);
|
||||||
|
}, [activeLang]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dataRef.current = data;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const getDisplayValue = useCallback(
|
||||||
|
(key: string): string => data.translations[activeLang]?.[key] ?? '',
|
||||||
|
[activeLang, data.translations]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback((key: string, value: string) => {
|
||||||
|
setData((prev) => {
|
||||||
|
const langTranslations = { ...(prev.translations[activeLang] ?? {}) };
|
||||||
|
langTranslations[key] = value;
|
||||||
|
const next = {
|
||||||
|
...prev,
|
||||||
|
translations: { ...prev.translations, [activeLang]: langTranslations },
|
||||||
|
};
|
||||||
|
dataRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setIsDirty(true);
|
||||||
|
setSaved(false);
|
||||||
|
}, [activeLang]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async (translationsOverride?: Record<string, Record<string, string>>) => {
|
||||||
|
try {
|
||||||
|
setSaveError('');
|
||||||
|
const effectiveTranslations = translationsOverride ?? dataRef.current.translations;
|
||||||
|
const response = await authFetch('/api/i18n/translations', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ translations: effectiveTranslations }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
throw new Error(result?.message || 'Failed to save translation files.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const languages = Array.isArray(result.languages) ? result.languages as LanguageEntry[] : [];
|
||||||
|
const translations = result.translations && typeof result.translations === 'object'
|
||||||
|
? result.translations as Record<string, Record<string, string>>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
setData({ languages, translations });
|
||||||
|
setIsDirty(false);
|
||||||
|
setSaved(true);
|
||||||
|
emitAction({ variant: 'success', message: 'Translations saved successfully.' });
|
||||||
|
setTimeout(() => setSaved(false), 2500);
|
||||||
|
|
||||||
|
// Best-effort: sync ONLY delta overrides for custom languages to DB.
|
||||||
|
// Delta = keys whose value differs from the en.ts base. Core languages are .ts-only.
|
||||||
|
const enBase = enBaseRef.current;
|
||||||
|
const customLangCodes = Object.keys(translations).filter((c) => !coreLanguages.has(c));
|
||||||
|
if (customLangCodes.length > 0) {
|
||||||
|
const overrides: Array<{ languageCode: string; namespace: string; key: string; value: string; isCustom: boolean }> = [];
|
||||||
|
const nextDbOverrides: Record<string, Record<string, string>> = { ...dbOverridesRef.current };
|
||||||
|
|
||||||
|
for (const langCode of customLangCodes) {
|
||||||
|
const flat = translations[langCode] ?? {};
|
||||||
|
const prevDbLang = dbOverridesRef.current[langCode] ?? {};
|
||||||
|
const nextDbLang: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [flatKey, value] of Object.entries(flat)) {
|
||||||
|
const baseValue = enBase[flatKey] ?? '';
|
||||||
|
if (!value || value === baseValue) {
|
||||||
|
// Key matches base or is empty — not a genuine override; omit from DB.
|
||||||
|
// If it was previously stored, it will be absent from the next PUT batch
|
||||||
|
// (backends using full-replace semantics will remove it automatically).
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dotIdx = flatKey.indexOf('.');
|
||||||
|
const namespace = dotIdx !== -1 ? flatKey.slice(0, dotIdx) : flatKey;
|
||||||
|
const key = dotIdx !== -1 ? flatKey.slice(dotIdx + 1) : flatKey;
|
||||||
|
overrides.push({ languageCode: langCode, namespace, key, value, isCustom: true });
|
||||||
|
nextDbLang[flatKey] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carry forward any DB overrides for keys not present in flat (shouldn't happen normally).
|
||||||
|
for (const [flatKey, value] of Object.entries(prevDbLang)) {
|
||||||
|
if (!(flatKey in nextDbLang)) {
|
||||||
|
// Key was reverted to base; drop from our local cache.
|
||||||
|
} else {
|
||||||
|
nextDbLang[flatKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextDbOverrides[langCode] = nextDbLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local DB overrides cache regardless of whether the PUT succeeds.
|
||||||
|
dbOverridesRef.current = nextDbOverrides;
|
||||||
|
|
||||||
|
if (overrides.length > 0) {
|
||||||
|
authFetch('/api/i18n/preferences', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ translations: overrides }),
|
||||||
|
}).catch(() => {
|
||||||
|
// Non-fatal: .ts files are already saved successfully.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to save translation files.';
|
||||||
|
setSaveError(message);
|
||||||
|
emitAction({ variant: 'error', message });
|
||||||
|
setSaved(false);
|
||||||
|
}
|
||||||
|
}, [emitAction]);
|
||||||
|
|
||||||
|
const handleAddLanguage = useCallback(async () => {
|
||||||
|
const code = newCode.trim().toLowerCase();
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!code) { setAddError('Language code is required.'); return; }
|
||||||
|
if (!name) { setAddError('Language name is required.'); return; }
|
||||||
|
if (!/^[a-z]{2,5}(-[a-zA-Z]{2,4})?$/.test(code)) {
|
||||||
|
setAddError('Use a valid BCP-47 code, e.g. fr, es, zh-TW.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allLanguages.some((l) => l.code === code)) {
|
||||||
|
setAddError(`Language "${code}" already exists.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch('/api/i18n/translations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
throw new Error(result?.message || 'Failed to create language file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const languages = Array.isArray(result.languages) ? result.languages as LanguageEntry[] : [];
|
||||||
|
const translations = result.translations && typeof result.translations === 'object'
|
||||||
|
? result.translations as Record<string, Record<string, string>>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
setData({ languages, translations });
|
||||||
|
setShowAddModal(false);
|
||||||
|
setNewCode('');
|
||||||
|
setNewName('');
|
||||||
|
setAddError('');
|
||||||
|
setActiveLang(code);
|
||||||
|
setIsDirty(false);
|
||||||
|
setSaved(false);
|
||||||
|
emitAction({ variant: 'success', message: `Language ${name} (${code}) added successfully.` });
|
||||||
|
|
||||||
|
// Best-effort: register the new language in the DB.
|
||||||
|
authFetch('/api/i18n/preferences', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
language: { languageCode: code, label: name, isEnabled: true, isCustom: true },
|
||||||
|
}),
|
||||||
|
}).catch(() => {
|
||||||
|
// Non-fatal: .ts file was created successfully.
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to create language file.';
|
||||||
|
setAddError(message);
|
||||||
|
emitAction({ variant: 'error', message });
|
||||||
|
}
|
||||||
|
}, [allLanguages, emitAction, newCode, newName]);
|
||||||
|
|
||||||
|
const handleDeleteLanguage = useCallback(async (code: string) => {
|
||||||
|
if (coreLanguages.has(code)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Delete the .ts translation file
|
||||||
|
const response = await authFetch('/api/i18n/translations', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
throw new Error(result?.message || 'Failed to delete language file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const languages = Array.isArray(result.languages) ? result.languages as LanguageEntry[] : [];
|
||||||
|
const translations = result.translations && typeof result.translations === 'object'
|
||||||
|
? result.translations as Record<string, Record<string, string>>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
setData({ languages, translations });
|
||||||
|
setIsDirty(false);
|
||||||
|
setSaved(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
if (activeLang === code) setActiveLang('en');
|
||||||
|
|
||||||
|
// 2. Best-effort: remove language-specific records from the DB
|
||||||
|
try {
|
||||||
|
await authFetch(
|
||||||
|
`/api/i18n/preferences?languageCode=${encodeURIComponent(code)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// DB cleanup failure is non-fatal; file is already deleted.
|
||||||
|
emitAction({ variant: 'warning', message: `Language "${code}" file deleted, but DB cleanup failed. You may need to remove DB records manually.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
emitAction({ variant: 'success', message: `Language ${code} deleted successfully.` });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to delete language file.';
|
||||||
|
setAddError(message);
|
||||||
|
emitAction({ variant: 'error', message });
|
||||||
|
}
|
||||||
|
}, [activeLang, coreLanguages, emitAction]);
|
||||||
|
|
||||||
|
const isBuiltin = useCallback((code: string) => coreLanguages.has(code), [coreLanguages]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given flat key for the currently active language (or a specified language)
|
||||||
|
* is a genuine DB override — i.e. its value differs from the en.ts base.
|
||||||
|
*/
|
||||||
|
const isOverridden = useCallback(
|
||||||
|
(flatKey: string, langCode?: string): boolean => {
|
||||||
|
const lang = langCode ?? activeLang;
|
||||||
|
if (coreLanguages.has(lang)) return false;
|
||||||
|
const currentValue = data.translations[lang]?.[flatKey] ?? '';
|
||||||
|
const baseValue = enBaseRef.current[flatKey] ?? '';
|
||||||
|
return currentValue !== '' && currentValue !== baseValue;
|
||||||
|
},
|
||||||
|
[activeLang, coreLanguages, data.translations]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts a key to its en.ts base value (removes the override from the working state).
|
||||||
|
* The override will be excluded from the next DB sync automatically.
|
||||||
|
*/
|
||||||
|
const handleRevertKey = useCallback(
|
||||||
|
(flatKey: string) => {
|
||||||
|
if (coreLanguages.has(activeLang)) return;
|
||||||
|
const baseValue = enBaseRef.current[flatKey] ?? '';
|
||||||
|
handleChange(flatKey, baseValue);
|
||||||
|
},
|
||||||
|
[activeLang, coreLanguages, handleChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
loadingPhase,
|
||||||
|
loadingProgress,
|
||||||
|
loadingLogs,
|
||||||
|
allLanguages,
|
||||||
|
activeLang,
|
||||||
|
setActiveLang,
|
||||||
|
getDisplayValue,
|
||||||
|
isDirty,
|
||||||
|
saved,
|
||||||
|
saveError,
|
||||||
|
handleChange,
|
||||||
|
handleSave,
|
||||||
|
showAddModal,
|
||||||
|
setShowAddModal,
|
||||||
|
newCode,
|
||||||
|
setNewCode,
|
||||||
|
newName,
|
||||||
|
setNewName,
|
||||||
|
addError,
|
||||||
|
setAddError,
|
||||||
|
handleAddLanguage,
|
||||||
|
deleteTarget,
|
||||||
|
setDeleteTarget,
|
||||||
|
handleDeleteLanguage,
|
||||||
|
isBuiltin,
|
||||||
|
isOverridden,
|
||||||
|
handleRevertKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
34
src/app/admin/language-management/hooks/useModalAnimation.ts
Normal file
34
src/app/admin/language-management/hooks/useModalAnimation.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type UseModalAnimationResult = {
|
||||||
|
isRendered: boolean;
|
||||||
|
isVisible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useModalAnimation(isOpen: boolean, durationMs = 220): UseModalAnimationResult {
|
||||||
|
const [isRendered, setIsRendered] = useState(isOpen);
|
||||||
|
const [isVisible, setIsVisible] = useState(isOpen);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
setIsRendered(true);
|
||||||
|
const frameId = requestAnimationFrame(() => setIsVisible(true));
|
||||||
|
return () => cancelAnimationFrame(frameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRendered) {
|
||||||
|
setIsVisible(false);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
setIsRendered(false);
|
||||||
|
}, durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [isOpen, isRendered, durationMs]);
|
||||||
|
|
||||||
|
return { isRendered, isVisible };
|
||||||
|
}
|
||||||
@ -0,0 +1,497 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { authFetch } from '../../../utils/authFetch';
|
||||||
|
import useAuthStore from '../../../store/authStore';
|
||||||
|
|
||||||
|
type NamespaceCategorySeed = { label: string; namespaces: string[] };
|
||||||
|
|
||||||
|
export type NamespaceCategory = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
namespaces: string[];
|
||||||
|
isCustom: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizeCategoryId(label: string): string {
|
||||||
|
return `custom-${label
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultCategories(seeds: NamespaceCategorySeed[]): NamespaceCategory[] {
|
||||||
|
return seeds.map((cat) => ({
|
||||||
|
id: `default-${sanitizeCategoryId(cat.label)}`,
|
||||||
|
label: cat.label,
|
||||||
|
namespaces: [...cat.namespaces],
|
||||||
|
isCustom: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseNamespaceCategoriesOptions = {
|
||||||
|
namespaces: string[];
|
||||||
|
allKeys: string[];
|
||||||
|
defaultCategories: NamespaceCategorySeed[];
|
||||||
|
onAction?: (notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => void;
|
||||||
|
onUnauthorized?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreferencesSaveSnapshot = {
|
||||||
|
categories?: NamespaceCategory[];
|
||||||
|
globalKeys?: string[];
|
||||||
|
englishReferenceKeysByLanguage?: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeCategories(value: unknown): NamespaceCategory[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
return value
|
||||||
|
.filter((c): c is { id: string; label: string; namespaces: string[]; isCustom: boolean } => {
|
||||||
|
return Boolean(c) && typeof c === 'object' && typeof c.id === 'string' && typeof c.label === 'string';
|
||||||
|
})
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
namespaces: Array.isArray(c.namespaces) ? c.namespaces.filter((ns): ns is string => typeof ns === 'string') : [],
|
||||||
|
isCustom: Boolean(c.isCustom),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalKeys(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((k): k is string => typeof k === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEnglishReferenceKeysByLanguage(value: unknown): Record<string, string[]> {
|
||||||
|
if (!value || typeof value !== 'object') return {};
|
||||||
|
const result: Record<string, string[]> = {};
|
||||||
|
for (const [languageCode, keys] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (!Array.isArray(keys)) continue;
|
||||||
|
const normalizedKeys = keys.filter((k): k is string => typeof k === 'string');
|
||||||
|
if (normalizedKeys.length > 0) {
|
||||||
|
result[languageCode] = Array.from(new Set(normalizedKeys)).sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNamespaceCategories({ namespaces, allKeys, defaultCategories, onAction, onUnauthorized }: UseNamespaceCategoriesOptions) {
|
||||||
|
const fallbackCategories = useMemo(() => createDefaultCategories(defaultCategories), [defaultCategories]);
|
||||||
|
const onActionRef = useRef(onAction);
|
||||||
|
const onUnauthorizedRef = useRef(onUnauthorized);
|
||||||
|
const lastSavedPreferencesRef = useRef('');
|
||||||
|
const hasLoadedPreferencesOnceRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onUnauthorizedRef.current = onUnauthorized;
|
||||||
|
}, [onUnauthorized]);
|
||||||
|
|
||||||
|
const emitAction = (notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => {
|
||||||
|
try {
|
||||||
|
onActionRef.current?.(notice);
|
||||||
|
} catch {
|
||||||
|
// Never block DB persistence because of notification rendering failures.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onActionRef.current = onAction;
|
||||||
|
}, [onAction]);
|
||||||
|
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||||
|
const [showCategoryManagerModal, setShowCategoryManagerModal] = useState(false);
|
||||||
|
const [categories, setCategories] = useState<NamespaceCategory[]>(fallbackCategories);
|
||||||
|
|
||||||
|
const [globalKeys, setGlobalKeys] = useState<string[]>([]);
|
||||||
|
const [englishReferenceKeysByLanguage, setEnglishReferenceKeysByLanguage] = useState<Record<string, string[]>>({});
|
||||||
|
const [preferencesHydrated, setPreferencesHydrated] = useState(false);
|
||||||
|
const [preferencesLoadingPhase, setPreferencesLoadingPhase] = useState('idle');
|
||||||
|
const [preferencesLoadingProgress, setPreferencesLoadingProgress] = useState(0);
|
||||||
|
const [preferencesLoadingLogs, setPreferencesLoadingLogs] = useState<string[]>([]);
|
||||||
|
const [isPreferencesDirty, setIsPreferencesDirty] = useState(false);
|
||||||
|
const [isSavingPreferences, setIsSavingPreferences] = useState(false);
|
||||||
|
|
||||||
|
const [newCategoryLabel, setNewCategoryLabel] = useState('');
|
||||||
|
const [assignNamespaceByCategory, setAssignNamespaceByCategory] = useState<Record<string, string>>({});
|
||||||
|
const [expandedCategoryId, setExpandedCategoryId] = useState<string | null>(null);
|
||||||
|
const [dragNamespace, setDragNamespace] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const appendPreferencesLoadingLog = (message: string) => {
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
setPreferencesLoadingLogs((prev) => [...prev, `[${timestamp}] ${message}`].slice(-30));
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializePreferences = (
|
||||||
|
nextCategories: NamespaceCategory[],
|
||||||
|
nextGlobalKeys: string[],
|
||||||
|
nextEnglishReferenceKeysByLanguage: Record<string, string[]>
|
||||||
|
) =>
|
||||||
|
JSON.stringify({
|
||||||
|
categories: nextCategories,
|
||||||
|
globalKeys: nextGlobalKeys,
|
||||||
|
englishReferenceKeysByLanguage: nextEnglishReferenceKeysByLanguage,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasLoadedPreferencesOnceRef.current) return;
|
||||||
|
hasLoadedPreferencesOnceRef.current = true;
|
||||||
|
|
||||||
|
setPreferencesLoadingPhase('initializing');
|
||||||
|
setPreferencesLoadingProgress(5);
|
||||||
|
appendPreferencesLoadingLog('Starting preferences bootstrap');
|
||||||
|
|
||||||
|
const loadPreferences = async () => {
|
||||||
|
console.debug('[LanguageManagement][Preferences] load:start');
|
||||||
|
try {
|
||||||
|
setPreferencesLoadingPhase('refresh-auth');
|
||||||
|
setPreferencesLoadingProgress(15);
|
||||||
|
appendPreferencesLoadingLog('Refreshing auth session before preferences fetch');
|
||||||
|
|
||||||
|
// Preflight refresh avoids immediate 401/refresh/retry loops during initial mount.
|
||||||
|
await useAuthStore.getState().refreshAuthToken(true).catch(() => null);
|
||||||
|
|
||||||
|
setPreferencesLoadingPhase('fetch-preferences');
|
||||||
|
setPreferencesLoadingProgress(35);
|
||||||
|
appendPreferencesLoadingLog('Fetching language preferences from /api/i18n/preferences');
|
||||||
|
|
||||||
|
const response = await authFetch('/api/i18n/preferences', { cache: 'no-store' });
|
||||||
|
console.debug('[LanguageManagement][Preferences] load:response', {
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
throw new Error(result?.message || 'Failed to load language preferences.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = result.preferences && typeof result.preferences === 'object'
|
||||||
|
? result.preferences
|
||||||
|
: result;
|
||||||
|
|
||||||
|
const loadedCategories = normalizeCategories(payload?.categories);
|
||||||
|
const loadedGlobalKeys = normalizeGlobalKeys(payload?.globalKeys);
|
||||||
|
const loadedEnglishReferenceKeysByLanguage =
|
||||||
|
normalizeEnglishReferenceKeysByLanguage(payload?.englishReferenceKeysByLanguage);
|
||||||
|
|
||||||
|
const effectiveCategories = loadedCategories.length > 0 ? loadedCategories : fallbackCategories;
|
||||||
|
const effectiveGlobalKeys = loadedGlobalKeys;
|
||||||
|
const effectiveEnglishReferenceKeysByLanguage = loadedEnglishReferenceKeysByLanguage;
|
||||||
|
|
||||||
|
lastSavedPreferencesRef.current = serializePreferences(
|
||||||
|
effectiveCategories,
|
||||||
|
effectiveGlobalKeys,
|
||||||
|
effectiveEnglishReferenceKeysByLanguage
|
||||||
|
);
|
||||||
|
|
||||||
|
console.debug('[LanguageManagement][Preferences] load:applied', {
|
||||||
|
categoriesCount: effectiveCategories.length,
|
||||||
|
globalKeysCount: effectiveGlobalKeys.length,
|
||||||
|
englishReferenceLanguagesCount: Object.keys(effectiveEnglishReferenceKeysByLanguage).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCategories(effectiveCategories);
|
||||||
|
setGlobalKeys(effectiveGlobalKeys);
|
||||||
|
setEnglishReferenceKeysByLanguage(effectiveEnglishReferenceKeysByLanguage);
|
||||||
|
setIsPreferencesDirty(false);
|
||||||
|
setPreferencesLoadingProgress(90);
|
||||||
|
appendPreferencesLoadingLog('Preferences loaded and applied');
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[LanguageManagement][Preferences] load:error', {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
setPreferencesLoadingPhase('error');
|
||||||
|
appendPreferencesLoadingLog(`Preferences bootstrap error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
setCategories(fallbackCategories);
|
||||||
|
setGlobalKeys([]);
|
||||||
|
setEnglishReferenceKeysByLanguage({});
|
||||||
|
lastSavedPreferencesRef.current = serializePreferences(fallbackCategories, [], {});
|
||||||
|
setIsPreferencesDirty(false);
|
||||||
|
emitAction({
|
||||||
|
variant: 'warning',
|
||||||
|
message: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to load language preferences from backend.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPreferencesHydrated(true);
|
||||||
|
setPreferencesLoadingPhase('ready');
|
||||||
|
setPreferencesLoadingProgress(100);
|
||||||
|
appendPreferencesLoadingLog('Preferences bootstrap complete');
|
||||||
|
console.debug('[LanguageManagement][Preferences] load:hydrated');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadPreferences();
|
||||||
|
}, [fallbackCategories]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preferencesHydrated) return;
|
||||||
|
const currentSerialized = serializePreferences(categories, globalKeys, englishReferenceKeysByLanguage);
|
||||||
|
const nextDirty = currentSerialized !== lastSavedPreferencesRef.current;
|
||||||
|
setIsPreferencesDirty(nextDirty);
|
||||||
|
console.debug('[LanguageManagement][Preferences] dirty:recompute', {
|
||||||
|
preferencesHydrated,
|
||||||
|
categoriesCount: categories.length,
|
||||||
|
globalKeysCount: globalKeys.length,
|
||||||
|
englishReferenceLanguagesCount: Object.keys(englishReferenceKeysByLanguage).length,
|
||||||
|
isDirty: nextDirty,
|
||||||
|
});
|
||||||
|
}, [categories, globalKeys, englishReferenceKeysByLanguage, preferencesHydrated]);
|
||||||
|
|
||||||
|
const savePreferences = async (snapshot?: PreferencesSaveSnapshot) => {
|
||||||
|
const effectiveCategories = snapshot?.categories ?? categories;
|
||||||
|
const effectiveGlobalKeys = snapshot?.globalKeys ?? globalKeys;
|
||||||
|
const effectiveEnglishReferenceKeysByLanguage =
|
||||||
|
snapshot?.englishReferenceKeysByLanguage ?? englishReferenceKeysByLanguage;
|
||||||
|
|
||||||
|
if (!preferencesHydrated) {
|
||||||
|
console.debug('[LanguageManagement][Preferences] save:skipped:notHydrated');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSerialized = serializePreferences(
|
||||||
|
effectiveCategories,
|
||||||
|
effectiveGlobalKeys,
|
||||||
|
effectiveEnglishReferenceKeysByLanguage
|
||||||
|
);
|
||||||
|
if (currentSerialized === lastSavedPreferencesRef.current) {
|
||||||
|
console.debug('[LanguageManagement][Preferences] save:skipped:notDirty', {
|
||||||
|
categoriesCount: effectiveCategories.length,
|
||||||
|
globalKeysCount: effectiveGlobalKeys.length,
|
||||||
|
englishReferenceLanguagesCount: Object.keys(effectiveEnglishReferenceKeysByLanguage).length,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('[LanguageManagement][Preferences] save:start', {
|
||||||
|
categoriesCount: effectiveCategories.length,
|
||||||
|
globalKeysCount: effectiveGlobalKeys.length,
|
||||||
|
globalKeysPreview: effectiveGlobalKeys.slice(0, 8),
|
||||||
|
englishReferenceLanguagesCount: Object.keys(effectiveEnglishReferenceKeysByLanguage).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSavingPreferences(true);
|
||||||
|
try {
|
||||||
|
const response = await authFetch('/api/i18n/preferences', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
categories: effectiveCategories,
|
||||||
|
globalKeys: effectiveGlobalKeys,
|
||||||
|
englishReferenceKeysByLanguage: effectiveEnglishReferenceKeysByLanguage,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.debug('[LanguageManagement][Preferences] save:response', {
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.();
|
||||||
|
throw new Error('Session expired. Redirecting to login.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
throw new Error(result?.message || 'Failed to save language preferences.');
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSavedPreferencesRef.current = currentSerialized;
|
||||||
|
setIsPreferencesDirty(false);
|
||||||
|
console.debug('[LanguageManagement][Preferences] save:success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[LanguageManagement][Preferences] save:error', {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
emitAction({
|
||||||
|
variant: 'error',
|
||||||
|
message: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to save language preferences.',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsSavingPreferences(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoriesWithKnownNamespaces = useMemo(() => {
|
||||||
|
const namespaceSet = new Set(namespaces);
|
||||||
|
return categories.map((cat) => ({
|
||||||
|
...cat,
|
||||||
|
namespaces: cat.namespaces.filter((ns) => namespaceSet.has(ns)),
|
||||||
|
}));
|
||||||
|
}, [categories, namespaces]);
|
||||||
|
|
||||||
|
const categorizedNamespaceSet = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const cat of categoriesWithKnownNamespaces) {
|
||||||
|
for (const ns of cat.namespaces) set.add(ns);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [categoriesWithKnownNamespaces]);
|
||||||
|
|
||||||
|
const uncategorizedNamespaces = useMemo(
|
||||||
|
() => namespaces.filter((ns) => !categorizedNamespaceSet.has(ns)),
|
||||||
|
[namespaces, categorizedNamespaceSet]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addNamespaceToCategory = (categoryId: string, namespace: string) => {
|
||||||
|
if (!namespace) return;
|
||||||
|
setCategories((prev) =>
|
||||||
|
prev.map((cat) => {
|
||||||
|
if (cat.id === categoryId) {
|
||||||
|
if (cat.namespaces.includes(namespace)) return cat;
|
||||||
|
return { ...cat, namespaces: [...cat.namespaces, namespace].sort((a, b) => a.localeCompare(b)) };
|
||||||
|
}
|
||||||
|
return { ...cat, namespaces: cat.namespaces.filter((ns) => ns !== namespace) };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeNamespaceFromCategory = (categoryId: string, namespace: string) => {
|
||||||
|
setCategories((prev) =>
|
||||||
|
prev.map((cat) => (cat.id === categoryId ? { ...cat, namespaces: cat.namespaces.filter((ns) => ns !== namespace) } : cat))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateCategory = () => {
|
||||||
|
const label = newCategoryLabel.trim();
|
||||||
|
if (!label) return;
|
||||||
|
|
||||||
|
const duplicate = categories.some((cat) => cat.label.toLowerCase() === label.toLowerCase());
|
||||||
|
if (duplicate) return;
|
||||||
|
|
||||||
|
const baseId = sanitizeCategoryId(label);
|
||||||
|
let id = baseId;
|
||||||
|
let i = 2;
|
||||||
|
while (categories.some((cat) => cat.id === id)) {
|
||||||
|
id = `${baseId}-${i}`;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCategories((prev) => [...prev, { id, label, namespaces: [], isCustom: true }]);
|
||||||
|
setNewCategoryLabel('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCategory = (categoryId: string) => {
|
||||||
|
setCategories((prev) => prev.filter((cat) => cat.id !== categoryId));
|
||||||
|
if (activeCategory === categoryId) setActiveCategory('all');
|
||||||
|
};
|
||||||
|
|
||||||
|
const globalKnownKeys = useMemo(() => {
|
||||||
|
const allKeySet = new Set(allKeys);
|
||||||
|
return globalKeys.filter((key, index, arr) => allKeySet.has(key) && arr.indexOf(key) === index);
|
||||||
|
}, [allKeys, globalKeys]);
|
||||||
|
|
||||||
|
const globalKeySet = useMemo(() => new Set(globalKnownKeys), [globalKnownKeys]);
|
||||||
|
|
||||||
|
const addGlobalKey = (key: string) => {
|
||||||
|
if (!key) return;
|
||||||
|
setGlobalKeys((prev) => {
|
||||||
|
const next = prev.includes(key) ? prev : [...prev, key];
|
||||||
|
console.debug('[LanguageManagement][Preferences] global:add', {
|
||||||
|
key,
|
||||||
|
previousCount: prev.length,
|
||||||
|
nextCount: next.length,
|
||||||
|
changed: next !== prev,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGlobalKey = (key: string) => {
|
||||||
|
setGlobalKeys((prev) => {
|
||||||
|
const next = prev.filter((k) => k !== key);
|
||||||
|
console.debug('[LanguageManagement][Preferences] global:remove', {
|
||||||
|
key,
|
||||||
|
previousCount: prev.length,
|
||||||
|
nextCount: next.length,
|
||||||
|
changed: next.length !== prev.length,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const englishReferenceKeySetByLanguage = useMemo(() => {
|
||||||
|
const result: Record<string, Set<string>> = {};
|
||||||
|
for (const [languageCode, keys] of Object.entries(englishReferenceKeysByLanguage)) {
|
||||||
|
result[languageCode] = new Set(keys);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [englishReferenceKeysByLanguage]);
|
||||||
|
|
||||||
|
const setEnglishReferenceKey = (languageCode: string, key: string, enabled: boolean) => {
|
||||||
|
if (!languageCode || languageCode === 'en' || !key) return;
|
||||||
|
setEnglishReferenceKeysByLanguage((prev) => {
|
||||||
|
const prevKeys = prev[languageCode] ?? [];
|
||||||
|
const prevSet = new Set(prevKeys);
|
||||||
|
if (enabled) {
|
||||||
|
prevSet.add(key);
|
||||||
|
} else {
|
||||||
|
prevSet.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextKeys = Array.from(prevSet).sort((a, b) => a.localeCompare(b));
|
||||||
|
const next = { ...prev };
|
||||||
|
if (nextKeys.length === 0) {
|
||||||
|
delete next[languageCode];
|
||||||
|
} else {
|
||||||
|
next[languageCode] = nextKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('[LanguageManagement][Preferences] english-reference:set', {
|
||||||
|
languageCode,
|
||||||
|
key,
|
||||||
|
enabled,
|
||||||
|
previousCount: prevKeys.length,
|
||||||
|
nextCount: nextKeys.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCategory,
|
||||||
|
setActiveCategory,
|
||||||
|
showCategoryManagerModal,
|
||||||
|
setShowCategoryManagerModal,
|
||||||
|
isPreferencesDirty,
|
||||||
|
preferencesHydrated,
|
||||||
|
preferencesLoadingPhase,
|
||||||
|
preferencesLoadingProgress,
|
||||||
|
preferencesLoadingLogs,
|
||||||
|
isSavingPreferences,
|
||||||
|
savePreferences,
|
||||||
|
categoriesWithKnownNamespaces,
|
||||||
|
uncategorizedNamespaces,
|
||||||
|
addNamespaceToCategory,
|
||||||
|
removeNamespaceFromCategory,
|
||||||
|
newCategoryLabel,
|
||||||
|
setNewCategoryLabel,
|
||||||
|
assignNamespaceByCategory,
|
||||||
|
setAssignNamespaceByCategory,
|
||||||
|
expandedCategoryId,
|
||||||
|
setExpandedCategoryId,
|
||||||
|
dragNamespace,
|
||||||
|
setDragNamespace,
|
||||||
|
handleCreateCategory,
|
||||||
|
deleteCategory,
|
||||||
|
globalKnownKeys,
|
||||||
|
globalKeySet,
|
||||||
|
addGlobalKey,
|
||||||
|
removeGlobalKey,
|
||||||
|
englishReferenceKeysByLanguage,
|
||||||
|
englishReferenceKeySetByLanguage,
|
||||||
|
setEnglishReferenceKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
965
src/app/admin/language-management/page.tsx
Normal file
965
src/app/admin/language-management/page.tsx
Normal file
@ -0,0 +1,965 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import PageLayout from '../../components/PageLayout';
|
||||||
|
import TranslationWizardModal from './components/TranslationWizardModal';
|
||||||
|
import AddLanguageModal from './components/AddLanguageModal';
|
||||||
|
import DeleteLanguageModal from './components/DeleteLanguageModal';
|
||||||
|
import CategoryManagerModal from './components/CategoryManagerModal';
|
||||||
|
import ScanResultsModal from './components/ScanResultsModal';
|
||||||
|
import TranslationCoverageEditor from './components/TranslationCoverageEditor';
|
||||||
|
import LanguageManagementTopSection from './components/LanguageManagementTopSection';
|
||||||
|
import { useI18nScanWorkflow } from './hooks/useI18nScanWorkflow';
|
||||||
|
import { useLanguageManagementTranslations } from './hooks/useLanguageManagementTranslations';
|
||||||
|
import { useNamespaceCategories } from './hooks/useNamespaceCategories';
|
||||||
|
import { useModalAnimation } from './hooks/useModalAnimation';
|
||||||
|
import { useToast } from '../../components/toast/toastComponent';
|
||||||
|
import { isPageTransitioning } from '../../components/animation/pageTransitionEffect';
|
||||||
|
import {
|
||||||
|
getAllTranslationKeys,
|
||||||
|
getEnglishValue,
|
||||||
|
useTranslation,
|
||||||
|
} from '../../i18n/useTranslation';
|
||||||
|
|
||||||
|
const CORE_LANGUAGES = new Set(['en', 'de']);
|
||||||
|
const AUTO_SCROLL_ON_SAVE_STORAGE_KEY = 'language-management-auto-scroll-on-save';
|
||||||
|
|
||||||
|
// ── namespace categories
|
||||||
|
const NAMESPACE_CATEGORIES: { label: string; namespaces: string[] }[] = [
|
||||||
|
{ label: 'General', namespaces: ['common', 'nav', 'footer', 'home'] },
|
||||||
|
{ label: 'Auth', namespaces: ['login', 'register', 'passwordReset'] },
|
||||||
|
{ label: 'Pages', namespaces: ['dashboard', 'profile', 'community', 'shop', 'memberships', 'affiliateLinks', 'aboutUs', 'news'] },
|
||||||
|
{ label: 'Coffee ABO', namespaces: ['coffeeSelection', 'coffeeSummary'] },
|
||||||
|
{ label: 'Account', namespaces: ['personalMatrix', 'referralManagement', 'quickactionDashboard', 'suspended'] },
|
||||||
|
{ label: 'Admin', namespaces: ['adminDashboard', 'userManagement', 'languageManagement', 'contractManagement'] },
|
||||||
|
{ label: 'Notifications', namespaces: ['toasts'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function groupKeys(keys: string[]): Record<string, string[]> {
|
||||||
|
const groups: Record<string, string[]> = {};
|
||||||
|
for (const key of keys) {
|
||||||
|
const ns = key.split('.')[0];
|
||||||
|
if (!groups[ns]) groups[ns] = [];
|
||||||
|
groups[ns].push(key);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LanguageManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const notifyAction = useCallback((notice: { variant: 'success' | 'error' | 'info' | 'warning'; message: string }) => {
|
||||||
|
showToast({
|
||||||
|
variant: notice.variant,
|
||||||
|
message: notice.message,
|
||||||
|
duration: notice.variant === 'error' ? 6000 : 3500,
|
||||||
|
});
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const handleUnauthorized = useCallback(() => {
|
||||||
|
router.push('/login');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
showScanModal,
|
||||||
|
setShowScanModal,
|
||||||
|
lastScanTime,
|
||||||
|
isScanning,
|
||||||
|
isAutoFixing,
|
||||||
|
isAddingMissingKeys,
|
||||||
|
scanError,
|
||||||
|
workspaceScan,
|
||||||
|
selectedFiles,
|
||||||
|
fixableFiles,
|
||||||
|
forceConvertToClient,
|
||||||
|
setForceConvertToClient,
|
||||||
|
scan,
|
||||||
|
runFixSelected,
|
||||||
|
addMissingKeys,
|
||||||
|
toggleFileSelection,
|
||||||
|
selectAllFiles,
|
||||||
|
clearSelectedFiles,
|
||||||
|
} = useI18nScanWorkflow({ onUnauthorized: handleUnauthorized });
|
||||||
|
|
||||||
|
// ── all flat keys from the English source-of-truth
|
||||||
|
const allKeys = useMemo(() => getAllTranslationKeys(), []);
|
||||||
|
const keyGroups = useMemo(() => groupKeys(allKeys), [allKeys]);
|
||||||
|
const namespaces = useMemo(() => Object.keys(keyGroups).sort(), [keyGroups]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading: isTranslationsLoading,
|
||||||
|
loadingPhase: translationsLoadingPhase,
|
||||||
|
loadingProgress: translationsLoadingProgress,
|
||||||
|
loadingLogs: translationsLoadingLogs,
|
||||||
|
allLanguages,
|
||||||
|
activeLang,
|
||||||
|
setActiveLang,
|
||||||
|
getDisplayValue,
|
||||||
|
isDirty,
|
||||||
|
saved,
|
||||||
|
saveError,
|
||||||
|
handleChange,
|
||||||
|
handleSave,
|
||||||
|
showAddModal,
|
||||||
|
setShowAddModal,
|
||||||
|
newCode,
|
||||||
|
setNewCode,
|
||||||
|
newName,
|
||||||
|
setNewName,
|
||||||
|
addError,
|
||||||
|
setAddError,
|
||||||
|
handleAddLanguage,
|
||||||
|
deleteTarget,
|
||||||
|
setDeleteTarget,
|
||||||
|
handleDeleteLanguage,
|
||||||
|
isBuiltin,
|
||||||
|
} = useLanguageManagementTranslations({
|
||||||
|
coreLanguages: CORE_LANGUAGES,
|
||||||
|
onAction: notifyAction,
|
||||||
|
onUnauthorized: handleUnauthorized,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showTranslationWizard, setShowTranslationWizard] = useState(false);
|
||||||
|
const [wizardIndex, setWizardIndex] = useState(0);
|
||||||
|
const [wizardInput, setWizardInput] = useState('');
|
||||||
|
const [wizardMarkGlobal, setWizardMarkGlobal] = useState(false);
|
||||||
|
const [wizardUseEnglishReference, setWizardUseEnglishReference] = useState(false);
|
||||||
|
const [isWizardSavingStep, setIsWizardSavingStep] = useState(false);
|
||||||
|
|
||||||
|
const [newGlobalKeySelection, setNewGlobalKeySelection] = useState('');
|
||||||
|
const [reloadAfterScanClose, setReloadAfterScanClose] = useState(false);
|
||||||
|
const [pendingAutoFixResult, setPendingAutoFixResult] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeCategory,
|
||||||
|
setActiveCategory,
|
||||||
|
showCategoryManagerModal,
|
||||||
|
setShowCategoryManagerModal,
|
||||||
|
isPreferencesDirty,
|
||||||
|
preferencesHydrated,
|
||||||
|
preferencesLoadingPhase,
|
||||||
|
preferencesLoadingProgress,
|
||||||
|
preferencesLoadingLogs,
|
||||||
|
isSavingPreferences,
|
||||||
|
savePreferences,
|
||||||
|
categoriesWithKnownNamespaces,
|
||||||
|
uncategorizedNamespaces,
|
||||||
|
addNamespaceToCategory,
|
||||||
|
removeNamespaceFromCategory,
|
||||||
|
newCategoryLabel,
|
||||||
|
setNewCategoryLabel,
|
||||||
|
assignNamespaceByCategory,
|
||||||
|
setAssignNamespaceByCategory,
|
||||||
|
expandedCategoryId,
|
||||||
|
setExpandedCategoryId,
|
||||||
|
dragNamespace,
|
||||||
|
setDragNamespace,
|
||||||
|
handleCreateCategory,
|
||||||
|
deleteCategory,
|
||||||
|
globalKnownKeys,
|
||||||
|
globalKeySet,
|
||||||
|
addGlobalKey,
|
||||||
|
removeGlobalKey,
|
||||||
|
englishReferenceKeysByLanguage,
|
||||||
|
englishReferenceKeySetByLanguage,
|
||||||
|
setEnglishReferenceKey,
|
||||||
|
} = useNamespaceCategories({
|
||||||
|
namespaces,
|
||||||
|
allKeys,
|
||||||
|
defaultCategories: NAMESPACE_CATEGORIES,
|
||||||
|
onAction: notifyAction,
|
||||||
|
onUnauthorized: handleUnauthorized,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasUnsavedChanges = isDirty || isPreferencesDirty;
|
||||||
|
const isInitialDataLoading = isTranslationsLoading || !preferencesHydrated;
|
||||||
|
const initialLoadingProgress = Math.round((translationsLoadingProgress + preferencesLoadingProgress) / 2);
|
||||||
|
const [isPageTransitionDone, setIsPageTransitionDone] = useState(() => !isPageTransitioning);
|
||||||
|
const [showDelayedFetchingScreen, setShowDelayedFetchingScreen] = useState(false);
|
||||||
|
const [showDelayedFetchLogs, setShowDelayedFetchLogs] = useState(false);
|
||||||
|
const combinedLoadingLogs = useMemo(() => {
|
||||||
|
const preferenceLines = preferencesLoadingLogs.map((line) => `[PREF] ${line}`);
|
||||||
|
const translationLines = translationsLoadingLogs.map((line) => `[I18N] ${line}`);
|
||||||
|
return [...preferenceLines, ...translationLines].slice(-18);
|
||||||
|
}, [preferencesLoadingLogs, translationsLoadingLogs]);
|
||||||
|
const { isRendered: isSaveBarRendered, isVisible: isSaveBarVisible } = useModalAnimation(hasUnsavedChanges);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPageTransitionDone) return;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
const pollTransition = () => {
|
||||||
|
if (!isPageTransitioning) {
|
||||||
|
setIsPageTransitionDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rafId = window.requestAnimationFrame(pollTransition);
|
||||||
|
};
|
||||||
|
|
||||||
|
rafId = window.requestAnimationFrame(pollTransition);
|
||||||
|
return () => {
|
||||||
|
if (rafId) window.cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [isPageTransitionDone]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialDataLoading || !isPageTransitionDone) {
|
||||||
|
setShowDelayedFetchingScreen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => setShowDelayedFetchingScreen(true), 350);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [isInitialDataLoading, isPageTransitionDone]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showDelayedFetchingScreen) {
|
||||||
|
setShowDelayedFetchLogs(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => setShowDelayedFetchLogs(true), 1400);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [showDelayedFetchingScreen]);
|
||||||
|
|
||||||
|
const showFetchingScreen = isInitialDataLoading && isPageTransitionDone && showDelayedFetchingScreen;
|
||||||
|
const showWarmGap = isInitialDataLoading && isPageTransitionDone && !showDelayedFetchingScreen;
|
||||||
|
|
||||||
|
// ── search / filter
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [autoScrollOnPanelOpen, setAutoScrollOnPanelOpen] = useState(true);
|
||||||
|
const [autoScrollOnSave, setAutoScrollOnSave] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.localStorage.getItem(AUTO_SCROLL_ON_SAVE_STORAGE_KEY) === '1';
|
||||||
|
});
|
||||||
|
const [activeNamespacePanel, setActiveNamespacePanel] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const languageManagementHeaderRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const openedNamespacePanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const openFromPanelClickRef = useRef(false);
|
||||||
|
const saveBarRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||||
|
const scrollHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const { isRendered: isHintRendered, isVisible: isHintVisible } = useModalAnimation(showScrollHint);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(AUTO_SCROLL_ON_SAVE_STORAGE_KEY, autoScrollOnSave ? '1' : '0');
|
||||||
|
}, [autoScrollOnSave]);
|
||||||
|
|
||||||
|
const filteredGroups = useMemo(() => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
if (!q) return keyGroups;
|
||||||
|
const result: Record<string, string[]> = {};
|
||||||
|
for (const [ns, keys] of Object.entries(keyGroups)) {
|
||||||
|
const filtered = keys.filter(
|
||||||
|
(k) =>
|
||||||
|
k.toLowerCase().includes(q) ||
|
||||||
|
getEnglishValue(k).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
if (filtered.length > 0) result[ns] = filtered;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [keyGroups, search]);
|
||||||
|
|
||||||
|
// ── scan results: coverage per namespace
|
||||||
|
const scanResults = useMemo(() => {
|
||||||
|
return namespaces.map((ns) => {
|
||||||
|
const keys = keyGroups[ns] ?? [];
|
||||||
|
let translated = 0;
|
||||||
|
if (activeLang === 'en') {
|
||||||
|
translated = keys.length; // English is the source
|
||||||
|
} else {
|
||||||
|
translated = keys.filter((k) => getDisplayValue(k) !== '').length;
|
||||||
|
}
|
||||||
|
return { ns, total: keys.length, translated, missing: keys.length - translated };
|
||||||
|
});
|
||||||
|
}, [namespaces, keyGroups, activeLang, getDisplayValue]);
|
||||||
|
|
||||||
|
|
||||||
|
const categoryNamespaces = useMemo(() => {
|
||||||
|
if (activeCategory === 'all') return null; // null = no filter
|
||||||
|
if (activeCategory === 'global') return [];
|
||||||
|
return categoriesWithKnownNamespaces.find((c) => c.id === activeCategory)?.namespaces ?? [];
|
||||||
|
}, [activeCategory, categoriesWithKnownNamespaces]);
|
||||||
|
|
||||||
|
const filteredNs = useMemo(() => {
|
||||||
|
if (activeCategory === 'global') return [];
|
||||||
|
const base = Object.keys(filteredGroups).sort();
|
||||||
|
if (!categoryNamespaces) return base;
|
||||||
|
return base.filter((ns) => categoryNamespaces.includes(ns));
|
||||||
|
}, [activeCategory, filteredGroups, categoryNamespaces]);
|
||||||
|
|
||||||
|
// Keep active panel in sync with currently visible namespaces
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeCategory === 'global') {
|
||||||
|
setActiveNamespacePanel(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredNs.length === 0) {
|
||||||
|
setActiveNamespacePanel(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeNamespacePanel || !filteredNs.includes(activeNamespacePanel)) {
|
||||||
|
setActiveNamespacePanel(filteredNs[0]);
|
||||||
|
}
|
||||||
|
}, [activeCategory, filteredNs, activeNamespacePanel]);
|
||||||
|
|
||||||
|
const totalKeys = allKeys.length;
|
||||||
|
|
||||||
|
const isMissingForLanguage = (key: string, languageCode: string) => {
|
||||||
|
if (languageCode === 'en') return false;
|
||||||
|
if (globalKeySet.has(key)) return false;
|
||||||
|
if (englishReferenceKeySetByLanguage[languageCode]?.has(key)) return false;
|
||||||
|
|
||||||
|
const value = (data.translations[languageCode]?.[key] ?? '').trim();
|
||||||
|
const englishValue = getEnglishValue(key).trim();
|
||||||
|
return value === '' || value === englishValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const namespaceTranslationStats = useMemo(() => {
|
||||||
|
const stats: Record<string, { total: number; translated: number; missing: number }> = {};
|
||||||
|
|
||||||
|
for (const ns of namespaces) {
|
||||||
|
const keys = keyGroups[ns] ?? [];
|
||||||
|
const total = keys.length;
|
||||||
|
let translated = 0;
|
||||||
|
|
||||||
|
if (activeLang === 'en') {
|
||||||
|
translated = total;
|
||||||
|
} else {
|
||||||
|
translated = keys.filter((k) => !isMissingForLanguage(k, activeLang)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats[ns] = { total, translated, missing: Math.max(total - translated, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}, [namespaces, keyGroups, activeLang, data.translations, globalKeySet]);
|
||||||
|
|
||||||
|
const allTabStats = useMemo(() => {
|
||||||
|
return Object.values(namespaceTranslationStats).reduce(
|
||||||
|
(acc, cur) => ({
|
||||||
|
total: acc.total + cur.total,
|
||||||
|
translated: acc.translated + cur.translated,
|
||||||
|
missing: acc.missing + cur.missing,
|
||||||
|
}),
|
||||||
|
{ total: 0, translated: 0, missing: 0 }
|
||||||
|
);
|
||||||
|
}, [namespaceTranslationStats]);
|
||||||
|
|
||||||
|
const categoryTabStats = useMemo(() => {
|
||||||
|
const result: Record<string, { total: number; translated: number; missing: number }> = {};
|
||||||
|
for (const cat of categoriesWithKnownNamespaces) {
|
||||||
|
result[cat.id] = cat.namespaces.reduce(
|
||||||
|
(acc, ns) => {
|
||||||
|
const st = namespaceTranslationStats[ns] ?? { total: 0, translated: 0, missing: 0 };
|
||||||
|
return {
|
||||||
|
total: acc.total + st.total,
|
||||||
|
translated: acc.translated + st.translated,
|
||||||
|
missing: acc.missing + st.missing,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ total: 0, translated: 0, missing: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [categoriesWithKnownNamespaces, namespaceTranslationStats]);
|
||||||
|
|
||||||
|
const translationProgressPercent = useMemo(() => {
|
||||||
|
if (allTabStats.total === 0) return 100;
|
||||||
|
return Math.round((allTabStats.translated / allTabStats.total) * 100);
|
||||||
|
}, [allTabStats]);
|
||||||
|
|
||||||
|
const wizardMissingKeys = useMemo(() => {
|
||||||
|
if (activeLang === 'en') return [] as string[];
|
||||||
|
return allKeys.filter((k) => isMissingForLanguage(k, activeLang));
|
||||||
|
}, [activeLang, allKeys, data.translations, globalKeySet]);
|
||||||
|
|
||||||
|
const currentWizardKey = showTranslationWizard ? wizardMissingKeys[wizardIndex] ?? null : null;
|
||||||
|
|
||||||
|
const globalFilteredKeys = useMemo(() => {
|
||||||
|
const q = search.toLowerCase().trim();
|
||||||
|
if (!q) return [...globalKnownKeys].sort();
|
||||||
|
return globalKnownKeys
|
||||||
|
.filter((key) => key.toLowerCase().includes(q) || getEnglishValue(key).toLowerCase().includes(q))
|
||||||
|
.sort();
|
||||||
|
}, [globalKnownKeys, search]);
|
||||||
|
|
||||||
|
const availableGlobalKeyOptions = useMemo(() => {
|
||||||
|
const globalSet = new Set(globalKnownKeys);
|
||||||
|
return allKeys.filter((key) => !globalSet.has(key)).sort();
|
||||||
|
}, [allKeys, globalKnownKeys]);
|
||||||
|
|
||||||
|
const globalTabStats = useMemo(() => {
|
||||||
|
const total = globalKnownKeys.length;
|
||||||
|
// Global keys are intentionally the same across languages — never count as missing
|
||||||
|
return { total, translated: total, missing: 0 };
|
||||||
|
}, [globalKnownKeys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showTranslationWizard) return;
|
||||||
|
if (wizardMissingKeys.length === 0) {
|
||||||
|
setShowTranslationWizard(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wizardIndex > wizardMissingKeys.length - 1) {
|
||||||
|
setWizardIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = wizardMissingKeys[wizardIndex];
|
||||||
|
setWizardInput(data.translations[activeLang]?.[key] ?? '');
|
||||||
|
setWizardMarkGlobal(globalKnownKeys.includes(key));
|
||||||
|
setWizardUseEnglishReference(Boolean(englishReferenceKeySetByLanguage[activeLang]?.has(key)));
|
||||||
|
}, [showTranslationWizard, wizardIndex, wizardMissingKeys, data.translations, activeLang, globalKnownKeys, englishReferenceKeySetByLanguage]);
|
||||||
|
|
||||||
|
const saveCurrentWizardValue = () => {
|
||||||
|
if (!currentWizardKey) return;
|
||||||
|
|
||||||
|
const trimmedWizardInput = wizardInput.trim();
|
||||||
|
const englishValue = getEnglishValue(currentWizardKey).trim();
|
||||||
|
const nextTranslations: Record<string, Record<string, string>> = {
|
||||||
|
...data.translations,
|
||||||
|
[activeLang]: {
|
||||||
|
...(data.translations[activeLang] ?? {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextGlobalKeysSet = new Set(globalKnownKeys);
|
||||||
|
const nextEnglishReferenceKeysByLanguage = {
|
||||||
|
...englishReferenceKeysByLanguage,
|
||||||
|
[activeLang]: [...(englishReferenceKeysByLanguage[activeLang] ?? [])],
|
||||||
|
};
|
||||||
|
const nextEnglishReferenceSet = new Set(nextEnglishReferenceKeysByLanguage[activeLang] ?? []);
|
||||||
|
|
||||||
|
if (wizardUseEnglishReference) {
|
||||||
|
nextTranslations[activeLang][currentWizardKey] = englishValue;
|
||||||
|
handleChange(currentWizardKey, englishValue);
|
||||||
|
|
||||||
|
setEnglishReferenceKey(activeLang, currentWizardKey, true);
|
||||||
|
nextEnglishReferenceSet.add(currentWizardKey);
|
||||||
|
|
||||||
|
if (wizardMarkGlobal) {
|
||||||
|
addGlobalKey(currentWizardKey);
|
||||||
|
nextGlobalKeysSet.add(currentWizardKey);
|
||||||
|
} else {
|
||||||
|
removeGlobalKey(currentWizardKey);
|
||||||
|
nextGlobalKeysSet.delete(currentWizardKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextEnglishReferenceKeysByLanguage[activeLang] = [...nextEnglishReferenceSet].sort((a, b) => a.localeCompare(b));
|
||||||
|
return {
|
||||||
|
nextTranslations,
|
||||||
|
preferencesSnapshot: {
|
||||||
|
globalKeys: [...nextGlobalKeysSet].sort((a, b) => a.localeCompare(b)),
|
||||||
|
englishReferenceKeysByLanguage: nextEnglishReferenceKeysByLanguage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global keys intentionally reuse the source term and don't require a local override.
|
||||||
|
const nextValue = wizardMarkGlobal && trimmedWizardInput === englishValue
|
||||||
|
? ''
|
||||||
|
: trimmedWizardInput;
|
||||||
|
|
||||||
|
nextTranslations[activeLang][currentWizardKey] = nextValue;
|
||||||
|
handleChange(currentWizardKey, nextValue);
|
||||||
|
|
||||||
|
setEnglishReferenceKey(activeLang, currentWizardKey, false);
|
||||||
|
nextEnglishReferenceSet.delete(currentWizardKey);
|
||||||
|
|
||||||
|
if (wizardMarkGlobal) {
|
||||||
|
addGlobalKey(currentWizardKey);
|
||||||
|
nextGlobalKeysSet.add(currentWizardKey);
|
||||||
|
} else {
|
||||||
|
removeGlobalKey(currentWizardKey);
|
||||||
|
nextGlobalKeysSet.delete(currentWizardKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEnglishReferenceKeysByLanguage = { ...nextEnglishReferenceKeysByLanguage };
|
||||||
|
const normalizedKeys = [...nextEnglishReferenceSet].sort((a, b) => a.localeCompare(b));
|
||||||
|
if (normalizedKeys.length === 0) {
|
||||||
|
delete normalizedEnglishReferenceKeysByLanguage[activeLang];
|
||||||
|
} else {
|
||||||
|
normalizedEnglishReferenceKeysByLanguage[activeLang] = normalizedKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextTranslations,
|
||||||
|
preferencesSnapshot: {
|
||||||
|
globalKeys: [...nextGlobalKeysSet].sort((a, b) => a.localeCompare(b)),
|
||||||
|
englishReferenceKeysByLanguage: normalizedEnglishReferenceKeysByLanguage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTranslationWizard = () => {
|
||||||
|
if (wizardMissingKeys.length === 0) return;
|
||||||
|
setWizardIndex(0);
|
||||||
|
setShowTranslationWizard(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNextWizardStep = async () => {
|
||||||
|
if (isWizardSavingStep) return;
|
||||||
|
|
||||||
|
setIsWizardSavingStep(true);
|
||||||
|
const saveSnapshot = saveCurrentWizardValue();
|
||||||
|
try {
|
||||||
|
if (saveSnapshot) {
|
||||||
|
// Persist both translation content and preferences on every wizard step.
|
||||||
|
await handleSave(saveSnapshot.nextTranslations);
|
||||||
|
await savePreferences(saveSnapshot.preferencesSnapshot);
|
||||||
|
}
|
||||||
|
if (wizardIndex >= wizardMissingKeys.length - 1) {
|
||||||
|
setShowTranslationWizard(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWizardIndex((prev) => prev + 1);
|
||||||
|
} finally {
|
||||||
|
setIsWizardSavingStep(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipWizardStep = () => {
|
||||||
|
if (wizardIndex >= wizardMissingKeys.length - 1) {
|
||||||
|
setShowTranslationWizard(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWizardIndex((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPreviousWizardStep = () => {
|
||||||
|
if (wizardIndex <= 0) return;
|
||||||
|
setWizardIndex((prev) => prev - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeNamespacePanel) return;
|
||||||
|
if (!openFromPanelClickRef.current) return;
|
||||||
|
if (!autoScrollOnPanelOpen || !openedNamespacePanelRef.current) {
|
||||||
|
openFromPanelClickRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openedNamespacePanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
openFromPanelClickRef.current = false;
|
||||||
|
}, [activeNamespacePanel, autoScrollOnPanelOpen]);
|
||||||
|
|
||||||
|
const scrollToLanguageManagementHeader = () => {
|
||||||
|
if (!languageManagementHeaderRef.current) return;
|
||||||
|
languageManagementHeaderRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show scroll-to-save hint whenever a namespace panel is opened and there are unsaved changes
|
||||||
|
// It stays visible until explicitly dismissed (button click) or save bar disappears
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeNamespacePanel || !isSaveBarRendered) {
|
||||||
|
setShowScrollHint(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current);
|
||||||
|
setShowScrollHint(true);
|
||||||
|
}, [activeNamespacePanel, isSaveBarRendered]);
|
||||||
|
|
||||||
|
// Hide scroll hint when the save bar scrolls into view
|
||||||
|
useEffect(() => {
|
||||||
|
const el = saveBarRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => { if (entry.isIntersecting) setShowScrollHint(false); },
|
||||||
|
{ threshold: 0.5 }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isSaveBarRendered]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingAutoFixResult || !workspaceScan) return;
|
||||||
|
|
||||||
|
const changedFiles = workspaceScan.changedFileCount ?? 0;
|
||||||
|
const createdKeys = workspaceScan.createdKeyCount ?? 0;
|
||||||
|
if (changedFiles > 0 || createdKeys > 0) {
|
||||||
|
setReloadAfterScanClose(true);
|
||||||
|
showToast({
|
||||||
|
variant: 'success',
|
||||||
|
message: `Auto-fix finished: ${changedFiles} files updated, ${createdKeys} keys created.`,
|
||||||
|
duration: 4200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingAutoFixResult(false);
|
||||||
|
}, [pendingAutoFixResult, workspaceScan, showToast]);
|
||||||
|
|
||||||
|
const handleRunFixSelected = async () => {
|
||||||
|
setPendingAutoFixResult(true);
|
||||||
|
await runFixSelected();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeScanModal = () => {
|
||||||
|
setShowScanModal(false);
|
||||||
|
if (!reloadAfterScanClose) return;
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
variant: 'info',
|
||||||
|
message: t('autofix.k3871d88e'),
|
||||||
|
duration: 1800,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, 260);
|
||||||
|
|
||||||
|
setReloadAfterScanClose(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAll = async () => {
|
||||||
|
const hadChangesToSave = isDirty || isPreferencesDirty;
|
||||||
|
console.debug('[LanguageManagement][SaveAll] start', {
|
||||||
|
isDirty,
|
||||||
|
isPreferencesDirty,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
autoScrollOnSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDirty) {
|
||||||
|
console.debug('[LanguageManagement][SaveAll] saving:translations');
|
||||||
|
await handleSave();
|
||||||
|
console.debug('[LanguageManagement][SaveAll] saved:translations');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPreferencesDirty) {
|
||||||
|
console.debug('[LanguageManagement][SaveAll] saving:preferences');
|
||||||
|
const savedPreferences = await savePreferences();
|
||||||
|
console.debug('[LanguageManagement][SaveAll] saved:preferences', { savedPreferences });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoScrollOnSave && hadChangesToSave) {
|
||||||
|
scrollToLanguageManagementHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('[LanguageManagement][SaveAll] done');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
||||||
|
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
|
{showFetchingScreen ? (
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative h-10 w-10">
|
||||||
|
<span className="absolute inset-0 rounded-full border-2 border-slate-200" />
|
||||||
|
<span className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-slate-700" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{t('autofix.k78e1bf35')}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
I18N: {translationsLoadingPhase} | PREF: {preferencesLoadingPhase}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto text-sm font-semibold text-slate-700">{initialLoadingProgress}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 h-2 w-full overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-slate-600 transition-[width] duration-300"
|
||||||
|
style={{ width: `${Math.max(6, initialLoadingProgress)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDelayedFetchLogs && (
|
||||||
|
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-950/95 p-3 text-slate-100 shadow-inner">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold tracking-wide text-slate-200">{t('autofix.k1c7ec4f2')}</p>
|
||||||
|
<p className="text-[11px] text-slate-400">{t('autofix.k057b3dbd')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-40 overflow-y-auto rounded-lg bg-black/30 p-2 font-mono text-[11px] leading-5">
|
||||||
|
{combinedLoadingLogs.length === 0 ? (
|
||||||
|
<p className="text-slate-400">{t('autofix.k835d3cbf')}</p>
|
||||||
|
) : (
|
||||||
|
combinedLoadingLogs.map((line, idx) => (
|
||||||
|
<p key={`${line}-${idx}`} className="text-slate-200">{line}</p>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : showWarmGap ? (
|
||||||
|
<div className="h-24" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LanguageManagementTopSection
|
||||||
|
headerRef={languageManagementHeaderRef}
|
||||||
|
totalKeys={totalKeys}
|
||||||
|
onScan={scan}
|
||||||
|
isScanning={isScanning}
|
||||||
|
isAutoFixing={isAutoFixing}
|
||||||
|
onBackToAdmin={() => router.push('/admin')}
|
||||||
|
isDirty={hasUnsavedChanges}
|
||||||
|
onSave={handleSaveAll}
|
||||||
|
saved={saved}
|
||||||
|
saveError={saveError}
|
||||||
|
allLanguages={allLanguages}
|
||||||
|
activeLang={activeLang}
|
||||||
|
setActiveLang={setActiveLang}
|
||||||
|
isBuiltin={isBuiltin}
|
||||||
|
onDeleteLanguageRequest={setDeleteTarget}
|
||||||
|
onOpenAddLanguage={() => setShowAddModal(true)}
|
||||||
|
allTabStats={allTabStats}
|
||||||
|
translationProgressPercent={translationProgressPercent}
|
||||||
|
wizardMissingKeysCount={wizardMissingKeys.length}
|
||||||
|
onOpenTranslationWizard={openTranslationWizard}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TranslationCoverageEditor
|
||||||
|
activeCategory={activeCategory}
|
||||||
|
setActiveCategory={setActiveCategory}
|
||||||
|
categoriesWithKnownNamespaces={categoriesWithKnownNamespaces}
|
||||||
|
allTabStats={allTabStats}
|
||||||
|
categoryTabStats={categoryTabStats}
|
||||||
|
globalTabStats={globalTabStats}
|
||||||
|
activeLang={activeLang}
|
||||||
|
allLanguages={allLanguages}
|
||||||
|
search={search}
|
||||||
|
setSearch={setSearch}
|
||||||
|
autoScrollOnPanelOpen={autoScrollOnPanelOpen}
|
||||||
|
setAutoScrollOnPanelOpen={setAutoScrollOnPanelOpen}
|
||||||
|
autoScrollOnSave={autoScrollOnSave}
|
||||||
|
setAutoScrollOnSave={setAutoScrollOnSave}
|
||||||
|
newGlobalKeySelection={newGlobalKeySelection}
|
||||||
|
setNewGlobalKeySelection={setNewGlobalKeySelection}
|
||||||
|
availableGlobalKeyOptions={availableGlobalKeyOptions}
|
||||||
|
addGlobalKey={addGlobalKey}
|
||||||
|
globalFilteredKeys={globalFilteredKeys}
|
||||||
|
removeGlobalKey={removeGlobalKey}
|
||||||
|
getDisplayValue={getDisplayValue}
|
||||||
|
translations={data.translations}
|
||||||
|
handleChange={handleChange}
|
||||||
|
globalKeySet={globalKeySet}
|
||||||
|
englishReferenceKeySet={englishReferenceKeySetByLanguage[activeLang] ?? new Set<string>()}
|
||||||
|
setEnglishReferenceForKey={(key, enabled) => {
|
||||||
|
setEnglishReferenceKey(activeLang, key, enabled);
|
||||||
|
if (enabled) {
|
||||||
|
handleChange(key, getEnglishValue(key));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
filteredNs={filteredNs}
|
||||||
|
filteredGroups={filteredGroups}
|
||||||
|
activeNamespacePanel={activeNamespacePanel}
|
||||||
|
setActiveNamespacePanel={setActiveNamespacePanel}
|
||||||
|
namespaceTranslationStats={namespaceTranslationStats}
|
||||||
|
openedNamespacePanelRef={openedNamespacePanelRef}
|
||||||
|
openFromPanelClickRef={openFromPanelClickRef}
|
||||||
|
onBackToPanels={scrollToLanguageManagementHeader}
|
||||||
|
onOpenCategoryManager={() => setShowCategoryManagerModal(true)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll-to-top floating button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('autofix.kb494ddd8')}
|
||||||
|
onClick={scrollToLanguageManagementHeader}
|
||||||
|
className="group fixed bottom-36 right-5 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-white/70 bg-white/80 shadow-[0_8px_24px_-8px_rgba(15,23,42,0.28)] backdrop-blur-md transition-all duration-300 ease-out hover:-translate-y-0.5 hover:scale-105 hover:bg-white hover:shadow-[0_12px_32px_-10px_rgba(15,23,42,0.36)]"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 text-slate-700 transition-transform duration-300 ease-out group-hover:-translate-y-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Scroll-to-save floating button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('autofix.k889cc3e3')}
|
||||||
|
onClick={() => {
|
||||||
|
if (saveBarRef.current) {
|
||||||
|
saveBarRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||||
|
} else {
|
||||||
|
const roots = [
|
||||||
|
document.scrollingElement,
|
||||||
|
document.documentElement,
|
||||||
|
document.body,
|
||||||
|
].filter(Boolean) as HTMLElement[];
|
||||||
|
|
||||||
|
const maxScrollTop = Math.max(
|
||||||
|
0,
|
||||||
|
...roots.map((root) => root.scrollHeight - window.innerHeight)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger scroll on all possible roots so it works regardless of which element owns scrolling.
|
||||||
|
roots.forEach((root) => {
|
||||||
|
root.scrollTo({ top: maxScrollTop, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
window.scrollTo({ top: maxScrollTop, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
setShowScrollHint(false);
|
||||||
|
}}
|
||||||
|
className="group fixed bottom-24 right-5 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-white/70 bg-white/80 shadow-[0_8px_24px_-8px_rgba(15,23,42,0.28)] backdrop-blur-md transition-all duration-300 ease-out hover:translate-y-0.5 hover:scale-105 hover:bg-white hover:shadow-[0_12px_32px_-10px_rgba(15,23,42,0.36)]"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 text-slate-700 transition-transform duration-300 ease-out group-hover:translate-y-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Scroll-to-save hint tooltip */}
|
||||||
|
{isHintRendered && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('autofix.k0b27fdf8')}
|
||||||
|
onClick={() => {
|
||||||
|
saveBarRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||||
|
setShowScrollHint(false);
|
||||||
|
}}
|
||||||
|
className={`fixed bottom-[5.25rem] right-[4rem] z-50 transition-all duration-500 ${
|
||||||
|
isHintVisible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-3 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* outer pulsing ring */}
|
||||||
|
<span className="absolute inset-0 rounded-2xl animate-pulse bg-gradient-to-br from-sky-400/20 via-blue-300/15 to-indigo-400/20" />
|
||||||
|
<div className="relative flex items-center gap-2.5 rounded-2xl border border-blue-300/80 bg-gradient-to-br from-sky-50 via-blue-50 to-indigo-50 px-3.5 py-2.5 shadow-[0_8px_28px_-8px_rgba(37,99,235,0.35)] backdrop-blur-md">
|
||||||
|
{/* logo */}
|
||||||
|
<img
|
||||||
|
src="/images/logos/PP_Logo_BW_round.png"
|
||||||
|
alt={t('autofix.k788633d1')}
|
||||||
|
className="h-7 w-7 rounded-full border border-blue-200/70 shadow-sm shrink-0 object-cover"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
|
<span className="text-[11px] font-bold text-blue-950 whitespace-nowrap">{t('autofix.k5188f06f')}</span>
|
||||||
|
<span className="text-[10px] font-medium text-blue-700 whitespace-nowrap">Click to scroll & save ↓</span>
|
||||||
|
</div>
|
||||||
|
{/* pulsing dot */}
|
||||||
|
<span className="relative flex h-2 w-2 shrink-0">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-500 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
||||||
|
</span>
|
||||||
|
{/* caret pointing right toward the chevron button */}
|
||||||
|
<span className="absolute -right-[5px] top-1/2 -translate-y-1/2 h-2.5 w-2.5 rotate-45 border-r border-t border-blue-300/80 bg-indigo-50" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Glassy save bar */}
|
||||||
|
{isSaveBarRendered && (
|
||||||
|
<div
|
||||||
|
ref={saveBarRef}
|
||||||
|
className={`w-full border-t border-white/60 bg-white/70 backdrop-blur-md px-4 py-4 transition-opacity duration-300 ${
|
||||||
|
isSaveBarVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="max-w-[1820px] mx-auto flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm font-medium text-slate-700">{t('autofix.kd63c8219')}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
disabled={isSavingPreferences}
|
||||||
|
className="rounded-2xl bg-slate-900 text-white px-5 py-2 text-sm font-semibold shadow-[0_18px_40px_-24px_rgba(15,23,42,0.85)] hover:bg-slate-800 transition disabled:opacity-50"
|
||||||
|
>{isSavingPreferences ? t('autofix.kac6cedc7') : 'Save'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TranslationWizardModal
|
||||||
|
isOpen={showTranslationWizard}
|
||||||
|
currentWizardKey={currentWizardKey}
|
||||||
|
wizardIndex={wizardIndex}
|
||||||
|
wizardMissingCount={wizardMissingKeys.length}
|
||||||
|
activeLang={activeLang}
|
||||||
|
allLanguages={allLanguages}
|
||||||
|
wizardInput={wizardInput}
|
||||||
|
setWizardInput={setWizardInput}
|
||||||
|
wizardMarkGlobal={wizardMarkGlobal}
|
||||||
|
setWizardMarkGlobal={setWizardMarkGlobal}
|
||||||
|
wizardUseEnglishReference={wizardUseEnglishReference}
|
||||||
|
setWizardUseEnglishReference={setWizardUseEnglishReference}
|
||||||
|
englishValue={currentWizardKey ? getEnglishValue(currentWizardKey) : ''}
|
||||||
|
addGlobalKey={addGlobalKey}
|
||||||
|
removeGlobalKey={removeGlobalKey}
|
||||||
|
onClose={() => setShowTranslationWizard(false)}
|
||||||
|
onPrevious={goToPreviousWizardStep}
|
||||||
|
onSkip={skipWizardStep}
|
||||||
|
onNext={goToNextWizardStep}
|
||||||
|
isSavingStep={isWizardSavingStep}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddLanguageModal
|
||||||
|
isOpen={showAddModal}
|
||||||
|
newCode={newCode}
|
||||||
|
setNewCode={setNewCode}
|
||||||
|
newName={newName}
|
||||||
|
setNewName={setNewName}
|
||||||
|
addError={addError}
|
||||||
|
setAddError={setAddError}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onAdd={handleAddLanguage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteLanguageModal
|
||||||
|
deleteTarget={deleteTarget}
|
||||||
|
allLanguages={allLanguages}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onDelete={handleDeleteLanguage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CategoryManagerModal
|
||||||
|
isOpen={showCategoryManagerModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowCategoryManagerModal(false);
|
||||||
|
if (isPreferencesDirty) void savePreferences();
|
||||||
|
}}
|
||||||
|
newCategoryLabel={newCategoryLabel}
|
||||||
|
setNewCategoryLabel={setNewCategoryLabel}
|
||||||
|
onCreateCategory={handleCreateCategory}
|
||||||
|
uncategorizedNamespaces={uncategorizedNamespaces}
|
||||||
|
categoriesWithKnownNamespaces={categoriesWithKnownNamespaces}
|
||||||
|
namespaces={namespaces}
|
||||||
|
assignNamespaceByCategory={assignNamespaceByCategory}
|
||||||
|
setAssignNamespaceByCategory={setAssignNamespaceByCategory}
|
||||||
|
expandedCategoryId={expandedCategoryId}
|
||||||
|
setExpandedCategoryId={setExpandedCategoryId}
|
||||||
|
dragNamespace={dragNamespace}
|
||||||
|
setDragNamespace={setDragNamespace}
|
||||||
|
addNamespaceToCategory={addNamespaceToCategory}
|
||||||
|
removeNamespaceFromCategory={removeNamespaceFromCategory}
|
||||||
|
deleteCategory={deleteCategory}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScanResultsModal
|
||||||
|
isOpen={showScanModal}
|
||||||
|
onClose={closeScanModal}
|
||||||
|
lastScanTime={lastScanTime}
|
||||||
|
workspaceScan={workspaceScan}
|
||||||
|
totalKeys={totalKeys}
|
||||||
|
namespacesCount={namespaces.length}
|
||||||
|
allLanguages={allLanguages}
|
||||||
|
activeLang={activeLang}
|
||||||
|
scanResults={scanResults}
|
||||||
|
scanError={scanError}
|
||||||
|
isScanning={isScanning}
|
||||||
|
isAutoFixing={isAutoFixing}
|
||||||
|
isAddingMissingKeys={isAddingMissingKeys}
|
||||||
|
fixableFiles={fixableFiles}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
forceConvertToClient={forceConvertToClient}
|
||||||
|
onToggleFile={toggleFileSelection}
|
||||||
|
onSelectAll={selectAllFiles}
|
||||||
|
onClear={clearSelectedFiles}
|
||||||
|
onToggleForceConvertToClient={() => setForceConvertToClient((prev) => !prev)}
|
||||||
|
onRunFixSelected={handleRunFixSelected}
|
||||||
|
onAddMissingKeys={addMissingKeys}
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -49,8 +49,17 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUser = useAuthStore.getState().user
|
let currentUser = useAuthStore.getState().user
|
||||||
const ok = isUserAdmin(currentUser)
|
let ok = isUserAdmin(currentUser)
|
||||||
|
|
||||||
|
if (currentUser && !ok) {
|
||||||
|
try {
|
||||||
|
console.log('🔐 AdminLayout: user present but not admin, revalidating via refresh')
|
||||||
|
await refreshAuthToken?.()
|
||||||
|
} catch {}
|
||||||
|
currentUser = useAuthStore.getState().user
|
||||||
|
ok = isUserAdmin(currentUser)
|
||||||
|
}
|
||||||
|
|
||||||
console.log('🔐 AdminLayout guard:resolved', {
|
console.log('🔐 AdminLayout guard:resolved', {
|
||||||
hasUser: !!currentUser,
|
hasUser: !!currentUser,
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../../i18n/useTranslation';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { MagnifyingGlassIcon, XMarkIcon, BuildingOffice2Icon, UserIcon } from '@heroicons/react/24/outline'
|
import { MagnifyingGlassIcon, XMarkIcon, BuildingOffice2Icon, UserIcon } from '@heroicons/react/24/outline'
|
||||||
@ -29,6 +32,7 @@ export default function SearchModal({
|
|||||||
onAdd,
|
onAdd,
|
||||||
policyMaxDepth // NEW
|
policyMaxDepth // NEW
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all')
|
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@ -277,9 +281,7 @@ export default function SearchModal({
|
|||||||
<XMarkIcon className="h-5 w-5" />
|
<XMarkIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-blue-200">
|
<p className="mt-1 text-xs text-blue-200">{t('autofix.kd642e230')}</p>
|
||||||
Search by name or email. Minimum 3 characters. Existing matrix members are hidden.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
@ -298,7 +300,7 @@ export default function SearchModal({
|
|||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
placeholder="Search name or email…"
|
placeholder={t('autofix.kb35549bb')}
|
||||||
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 placeholder-blue-300 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 placeholder-blue-300 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -310,7 +312,7 @@ export default function SearchModal({
|
|||||||
onChange={e => setTypeFilter(e.target.value as any)}
|
onChange={e => setTypeFilter(e.target.value as any)}
|
||||||
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||||
>
|
>
|
||||||
<option value="all">All Types</option>
|
<option value="all">{t('autofix.k10e2568f')}</option>
|
||||||
<option value="personal">Personal</option>
|
<option value="personal">Personal</option>
|
||||||
<option value="company">Company</option>
|
<option value="company">Company</option>
|
||||||
</select>
|
</select>
|
||||||
@ -321,9 +323,7 @@ export default function SearchModal({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || query.trim().length < 3}
|
disabled={loading || query.trim().length < 3}
|
||||||
className="flex-1 rounded-md bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
|
className="flex-1 rounded-md bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
|
||||||
>
|
>{loading ? t('autofix.kdff3e58d') : 'Search'}</button>
|
||||||
{loading ? 'Searching…' : 'Search'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setQuery(''); setItems([]); setTotal(0); setError(''); setHasSearched(false); }}
|
onClick={() => { setQuery(''); setItems([]); setTotal(0); setError(''); setHasSearched(false); }}
|
||||||
@ -333,8 +333,7 @@ export default function SearchModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Total */}
|
{/* Total */}
|
||||||
<div className="text-sm text-blue-200 self-center">
|
<div className="text-sm text-blue-200 self-center">{t('autofix.kc0e3b03d')}<span className="font-semibold text-white">{total}</span>
|
||||||
Total: <span className="font-semibold text-white">{total}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -346,14 +345,10 @@ export default function SearchModal({
|
|||||||
<div className="text-sm text-red-400 mb-4">{error}</div>
|
<div className="text-sm text-red-400 mb-4">{error}</div>
|
||||||
)}
|
)}
|
||||||
{!error && query.trim().length < 3 && (
|
{!error && query.trim().length < 3 && (
|
||||||
<div className="py-12 text-sm text-blue-300 text-center">
|
<div className="py-12 text-sm text-blue-300 text-center">{t('autofix.kb87eb38b')}</div>
|
||||||
Enter at least 3 characters and click Search.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!error && query.trim().length >= 3 && !hasSearched && !loading && (
|
{!error && query.trim().length >= 3 && !hasSearched && !loading && (
|
||||||
<div className="py-12 text-sm text-blue-300 text-center">
|
<div className="py-12 text-sm text-blue-300 text-center">{t('autofix.k7c740cd5')}</div>
|
||||||
Ready to search. Click the Search button to fetch candidates.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{/* Skeleton only for first-time load (when no items yet) */}
|
{/* Skeleton only for first-time load (when no items yet) */}
|
||||||
{!error && query.trim().length >= 3 && loading && items.length === 0 && (
|
{!error && query.trim().length >= 3 && loading && items.length === 0 && (
|
||||||
@ -367,9 +362,7 @@ export default function SearchModal({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{!error && hasSearched && !loading && query.trim().length >= 3 && items.length === 0 && (
|
{!error && hasSearched && !loading && query.trim().length >= 3 && items.length === 0 && (
|
||||||
<div className="py-12 text-sm text-blue-300 text-center">
|
<div className="py-12 text-sm text-blue-300 text-center">{t('autofix.k1e5d5139')}</div>
|
||||||
No users match your filters.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!error && hasSearched && items.length > 0 && (
|
{!error && hasSearched && items.length > 0 && (
|
||||||
<ul className="divide-y divide-blue-900/40 border border-blue-900/40 rounded-lg bg-[#132c4e]">
|
<ul className="divide-y divide-blue-900/40 border border-blue-900/40 rounded-lg bg-[#132c4e]">
|
||||||
@ -427,9 +420,7 @@ export default function SearchModal({
|
|||||||
<button
|
<button
|
||||||
onClick={() => { setSelected(null); setParentId(undefined); }}
|
onClick={() => { setSelected(null); setParentId(undefined); }}
|
||||||
className="text-xs text-blue-300 hover:text-white transition"
|
className="text-xs text-blue-300 hover:text-white transition"
|
||||||
>
|
>{t('autofix.kadd80fbc')}</button>
|
||||||
Clear selection
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-xs text-blue-200">
|
<label className="flex items-center gap-2 text-xs text-blue-200">
|
||||||
@ -438,9 +429,7 @@ export default function SearchModal({
|
|||||||
checked={advanced}
|
checked={advanced}
|
||||||
onChange={e => setAdvanced(e.target.checked)}
|
onChange={e => setAdvanced(e.target.checked)}
|
||||||
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
|
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
|
||||||
/>
|
/>{t('autofix.k11974e0f')}</label>
|
||||||
Advanced: choose parent manually
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{advanced && (
|
{advanced && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -481,9 +470,7 @@ export default function SearchModal({
|
|||||||
checked={forceFallback}
|
checked={forceFallback}
|
||||||
onChange={e => setForceFallback(e.target.checked)}
|
onChange={e => setForceFallback(e.target.checked)}
|
||||||
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
|
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
|
||||||
/>
|
/>{t('autofix.kf823daf7')}</label>
|
||||||
Fallback to root if referral parent not in matrix
|
|
||||||
</label>
|
|
||||||
<p className="text-[11px] text-blue-300">
|
<p className="text-[11px] text-blue-300">
|
||||||
If the referrer is outside the matrix, the user is placed under root; enabling fallback may mark the user as rogue.
|
If the referrer is outside the matrix, the user is placed under root; enabling fallback may mark the user as rogue.
|
||||||
</p>
|
</p>
|
||||||
@ -497,9 +484,7 @@ export default function SearchModal({
|
|||||||
disabled={adding || (!!addDisabledReason && !!advanced)} // NEW
|
disabled={adding || (!!addDisabledReason && !!advanced)} // NEW
|
||||||
title={addDisabledReason || undefined} // NEW
|
title={addDisabledReason || undefined} // NEW
|
||||||
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-2 text-xs font-medium shadow-sm transition"
|
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-2 text-xs font-medium shadow-sm transition"
|
||||||
>
|
>{adding ? t('autofix.k12f2d162') : t('autofix.k59b7a324')}</button>
|
||||||
{adding ? 'Adding…' : 'Add to Matrix'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import React, { useEffect, useMemo, useState, Suspense } from 'react' // CHANGED: add Suspense
|
import React, { useEffect, useMemo, useState, Suspense } from 'react' // CHANGED: add Suspense
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import PageLayout from '../../../components/PageLayout'
|
import PageLayout from '../../../components/PageLayout'
|
||||||
@ -13,6 +16,7 @@ const DEFAULT_FETCH_DEPTH = 50 // provisional large depth to approximate unlimit
|
|||||||
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
|
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
|
||||||
|
|
||||||
function MatrixDetailPageInner() {
|
function MatrixDetailPageInner() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const sp = useSearchParams()
|
const sp = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -378,7 +382,7 @@ function MatrixDetailPageInner() {
|
|||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/50 backdrop-blur-sm transition-opacity">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/50 backdrop-blur-sm transition-opacity">
|
||||||
<div className="flex items-center gap-3 rounded-lg bg-white shadow-md border border-gray-200 px-4 py-3">
|
<div className="flex items-center gap-3 rounded-lg bg-white shadow-md border border-gray-200 px-4 py-3">
|
||||||
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
||||||
<span className="text-sm text-gray-700">Refreshing…</span>
|
<span className="text-sm text-gray-700">{t('autofix.k14a4b43e')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -393,12 +397,9 @@ function MatrixDetailPageInner() {
|
|||||||
onClick={() => router.push('/admin/matrix-management')}
|
onClick={() => router.push('/admin/matrix-management')}
|
||||||
className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700"
|
className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ArrowLeftIcon className="h-4 w-4" />{t('autofix.k65b67dc3')}</button>
|
||||||
Back to matrices
|
|
||||||
</button>
|
|
||||||
<h1 className="text-3xl font-extrabold text-blue-900">{matrixName}</h1>
|
<h1 className="text-3xl font-extrabold text-blue-900">{matrixName}</h1>
|
||||||
<p className="text-base text-blue-700">
|
<p className="text-base text-blue-700">{t('autofix.k31d46514')}<span className="font-semibold text-blue-900">{topNodeEmail}</span>
|
||||||
Top node: <span className="font-semibold text-blue-900">{topNodeEmail}</span>
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||||||
@ -431,9 +432,7 @@ function MatrixDetailPageInner() {
|
|||||||
onClick={() => { setOpen(true) }}
|
onClick={() => { setOpen(true) }}
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-5 w-5" />
|
<PlusIcon className="h-5 w-5" />{t('autofix.kc7c429a6')}</button>
|
||||||
Add users to matrix
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -452,7 +451,7 @@ function MatrixDetailPageInner() {
|
|||||||
<input
|
<input
|
||||||
value={globalSearch}
|
value={globalSearch}
|
||||||
onChange={e => setGlobalSearch(e.target.value)}
|
onChange={e => setGlobalSearch(e.target.value)}
|
||||||
placeholder="Global search..."
|
placeholder={t('autofix.kd304af2e')}
|
||||||
className="pl-8 pr-2 py-2 rounded-lg border border-gray-200 text-xs focus:ring-1 focus:ring-blue-900 focus:border-transparent w-full"
|
className="pl-8 pr-2 py-2 rounded-lg border border-gray-200 text-xs focus:ring-1 focus:ring-blue-900 focus:border-transparent w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -470,27 +469,27 @@ function MatrixDetailPageInner() {
|
|||||||
{/* Small stats (CHANGED wording) */}
|
{/* Small stats (CHANGED wording) */}
|
||||||
<div className="mb-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
|
<div className="mb-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Total users fetched</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.k65e33378')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{users.length}</div>
|
<div className="text-xl font-semibold text-blue-900">{users.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Rogue users</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.kb343460d')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{rogueCount}</div>
|
<div className="text-xl font-semibold text-blue-900">{rogueCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Structure</div>
|
<div className="text-xs text-gray-500 mb-1">Structure</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">5‑ary Tree</div>
|
<div className="text-xl font-semibold text-blue-900">{t('autofix.kf3557acd')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Policy Max Depth</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.k776b751c')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{isUnlimited ? 'Unlimited' : policyMaxDepth}</div>
|
<div className="text-xl font-semibold text-blue-900">{isUnlimited ? 'Unlimited' : policyMaxDepth}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Fill %</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.k9683262f')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{fillMetrics.label}</div>
|
<div className="text-xl font-semibold text-blue-900">{fillMetrics.label}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Highest full level</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.k7f9568ec')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{fillMetrics.highestFull}</div>
|
<div className="text-xl font-semibold text-blue-900">{fillMetrics.highestFull}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -499,11 +498,11 @@ function MatrixDetailPageInner() {
|
|||||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
||||||
<div className="px-8 py-6 border-b border-gray-100">
|
<div className="px-8 py-6 border-b border-gray-100">
|
||||||
<h2 className="text-xl font-semibold text-blue-900">Matrix Tree (Unlimited Depth)</h2>
|
<h2 className="text-xl font-semibold text-blue-900">Matrix Tree (Unlimited Depth)</h2>
|
||||||
<p className="text-xs text-blue-700">Each node can hold up to 5 direct children. Depth unbounded.</p>
|
<p className="text-xs text-blue-700">{t('autofix.kab4f5159')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-8 py-6">
|
<div className="px-8 py-6">
|
||||||
{!rootNode && (
|
{!rootNode && (
|
||||||
<div className="text-xs text-gray-500 italic">Root not yet loaded.</div>
|
<div className="text-xs text-gray-500 italic">{t('autofix.k4e61bc77')}</div>
|
||||||
)}
|
)}
|
||||||
{rootNode && (
|
{rootNode && (
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
@ -516,9 +515,7 @@ function MatrixDetailPageInner() {
|
|||||||
{/* Vacancies placeholder */}
|
{/* Vacancies placeholder */}
|
||||||
<div className="rounded-2xl bg-white border border-dashed border-blue-200 shadow-sm p-6 mb-8">
|
<div className="rounded-2xl bg-white border border-dashed border-blue-200 shadow-sm p-6 mb-8">
|
||||||
<h3 className="text-lg font-semibold text-blue-900 mb-2">Vacancies</h3>
|
<h3 className="text-lg font-semibold text-blue-900 mb-2">Vacancies</h3>
|
||||||
<p className="text-sm text-blue-700">
|
<p className="text-sm text-blue-700">{t('autofix.k9b3266b5')}</p>
|
||||||
Pending backend wiring to MatrixController.listVacancies. This section will surface empty slots and allow reassignment.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Users Modal */}
|
{/* Add Users Modal */}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React, { useMemo, useState, useEffect } from 'react'
|
import React, { useMemo, useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
@ -28,6 +31,7 @@ type Matrix = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MatrixManagementPage() {
|
export default function MatrixManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const token = useAuthStore(s => s.accessToken)
|
const token = useAuthStore(s => s.accessToken)
|
||||||
@ -289,37 +293,35 @@ export default function MatrixManagementPage() {
|
|||||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Matrix Management</h1>
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.kd09be3cd')}</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">Manage matrices, see stats, and create new ones.</p>
|
<p className="text-lg text-blue-700 mt-2">{t('autofix.kdc22ad8a')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-5 w-5" />
|
<PlusIcon className="h-5 w-5" />{t('autofix.kb7849a5a')}</button>
|
||||||
Create Matrix
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||||
<div className="flex items-center gap-2 text-xs text-blue-900">
|
<div className="flex items-center gap-2 text-xs text-blue-900">
|
||||||
<span className="font-semibold">Policy filter:</span>
|
<span className="font-semibold">{t('autofix.ka72e833f')}</span>
|
||||||
<button onClick={() => setPolicyFilter('all')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='all'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>All</button>
|
<button onClick={() => setPolicyFilter('all')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='all'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>All</button>
|
||||||
<button onClick={() => setPolicyFilter('unlimited')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='unlimited'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Unlimited</button>
|
<button onClick={() => setPolicyFilter('unlimited')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='unlimited'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Unlimited</button>
|
||||||
<button onClick={() => setPolicyFilter('five')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='five'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Depth 5</button>
|
<button onClick={() => setPolicyFilter('five')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='five'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>{t('autofix.kefd5231d')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-blue-900">
|
<div className="flex items-center gap-2 text-xs text-blue-900">
|
||||||
<span className="font-semibold">Sort:</span>
|
<span className="font-semibold">{t('autofix.k0dca1445')}</span>
|
||||||
<select value={sortByPolicy} onChange={e => setSortByPolicy(e.target.value as any)} className="border rounded px-2 py-1">
|
<select value={sortByPolicy} onChange={e => setSortByPolicy(e.target.value as any)} className="border rounded px-2 py-1">
|
||||||
<option value="none">None</option>
|
<option value="none">None</option>
|
||||||
<option value="asc">Policy ↑</option>
|
<option value="asc">{t('autofix.kf7a91674')}</option>
|
||||||
<option value="desc">Policy ↓</option>
|
<option value="desc">{t('autofix.kf7a91676')}</option>
|
||||||
</select>
|
</select>
|
||||||
<select value={sortByUsers} onChange={e => setSortByUsers(e.target.value as any)} className="border rounded px-2 py-1">
|
<select value={sortByUsers} onChange={e => setSortByUsers(e.target.value as any)} className="border rounded px-2 py-1">
|
||||||
<option value="desc">Users ↓</option>
|
<option value="desc">{t('autofix.k8c3085f4')}</option>
|
||||||
<option value="asc">Users ↑</option>
|
<option value="asc">{t('autofix.k8c3085f6')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -352,7 +354,7 @@ export default function MatrixManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : matricesView.length === 0 ? (
|
) : matricesView.length === 0 ? (
|
||||||
<div className="text-sm text-gray-600">No matrices found.</div>
|
<div className="text-sm text-gray-600">{t('autofix.k0dcb69ea')}</div>
|
||||||
) : (
|
) : (
|
||||||
matricesView.map(m => (
|
matricesView.map(m => (
|
||||||
<article key={m.id} className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden flex flex-col">
|
<article key={m.id} className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden flex flex-col">
|
||||||
@ -370,7 +372,7 @@ export default function MatrixManagementPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 text-sm text-gray-700">
|
<div className="mt-4 grid grid-cols-1 gap-3 text-sm text-gray-700">
|
||||||
<div className="flex items-center gap-2" title="Users count respects each matrix’s max depth policy.">
|
<div className="flex items-center gap-2" title={t('autofix.k111c49d8')}>
|
||||||
<UsersIcon className="h-5 w-5 text-gray-500" />
|
<UsersIcon className="h-5 w-5 text-gray-500" />
|
||||||
<span className="font-medium">{m.usersCount}</span>
|
<span className="font-medium">{m.usersCount}</span>
|
||||||
<span className="text-gray-500">users</span>
|
<span className="text-gray-500">users</span>
|
||||||
@ -394,14 +396,10 @@ export default function MatrixManagementPage() {
|
|||||||
${m.status === 'active'
|
${m.status === 'active'
|
||||||
? 'border-red-300 text-red-700 hover:bg-red-50 disabled:opacity-60'
|
? 'border-red-300 text-red-700 hover:bg-red-50 disabled:opacity-60'
|
||||||
: 'border-green-300 text-green-700 hover:bg-green-50 disabled:opacity-60'}`}
|
: 'border-green-300 text-green-700 hover:bg-green-50 disabled:opacity-60'}`}
|
||||||
>
|
>{mutatingId === m.id
|
||||||
{mutatingId === m.id
|
? (m.status === 'active' ? t('autofix.k871d457e') : t('autofix.k5bcb3e1f'))
|
||||||
? (m.status === 'active' ? 'Deactivating…' : 'Activating…')
|
: (m.status === 'active' ? 'Deactivate' : 'Activate')}</button>
|
||||||
: (m.status === 'active' ? 'Deactivate' : 'Activate')}
|
<span className="text-[11px] text-gray-500">{t('autofix.k27f56959')}</span>
|
||||||
</button>
|
|
||||||
<span className="text-[11px] text-gray-500">
|
|
||||||
State change will affect add/remove operations.
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
className="text-sm font-medium text-blue-900 hover:text-blue-700"
|
className="text-sm font-medium text-blue-900 hover:text-blue-700"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -417,9 +415,7 @@ export default function MatrixManagementPage() {
|
|||||||
})
|
})
|
||||||
router.push(`/admin/matrix-management/detail?${params.toString()}`)
|
router.push(`/admin/matrix-management/detail?${params.toString()}`)
|
||||||
}}
|
}}
|
||||||
>
|
>{t('autofix.ka3c41ff8')}</button>
|
||||||
View details →
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@ -435,7 +431,7 @@ export default function MatrixManagementPage() {
|
|||||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl ring-1 ring-black/10">
|
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl ring-1 ring-black/10">
|
||||||
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
||||||
<h4 className="text-lg font-semibold text-blue-900">Create Matrix</h4>
|
<h4 className="text-lg font-semibold text-blue-900">{t('autofix.kb7849a5a')}</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setCreateOpen(false); resetForm() }}
|
onClick={() => { setCreateOpen(false); resetForm() }}
|
||||||
className="text-sm text-gray-500 hover:text-gray-700"
|
className="text-sm text-gray-500 hover:text-gray-700"
|
||||||
@ -446,20 +442,16 @@ export default function MatrixManagementPage() {
|
|||||||
<form onSubmit={handleCreate} className="p-6 space-y-5">
|
<form onSubmit={handleCreate} className="p-6 space-y-5">
|
||||||
{/* Success banner */}
|
{/* Success banner */}
|
||||||
{createSuccess && (
|
{createSuccess && (
|
||||||
<div className="rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
|
<div className="rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">{t('autofix.k5738c039')}<div className="mt-1 text-green-800">
|
||||||
Matrix created successfully.
|
<span className="font-semibold">{t('autofix.k0cdde8f8')}</span> {createSuccess.name}{' '}
|
||||||
<div className="mt-1 text-green-800">
|
<span className="font-semibold ml-3">{t('autofix.k31d46514')}</span> {createSuccess.email}
|
||||||
<span className="font-semibold">Name:</span> {createSuccess.name}{' '}
|
|
||||||
<span className="font-semibold ml-3">Top node:</span> {createSuccess.email}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 409 force prompt */}
|
{/* 409 force prompt */}
|
||||||
{forcePrompt && (
|
{forcePrompt && (
|
||||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">{t('autofix.k815ca9ba')}<div className="mt-2 flex items-center gap-2">
|
||||||
A matrix configuration already exists for this selection.
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={confirmForce}
|
onClick={confirmForce}
|
||||||
@ -482,29 +474,29 @@ export default function MatrixManagementPage() {
|
|||||||
|
|
||||||
{/* Form fields */}
|
{/* Form fields */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-1">Matrix Name</label>
|
<label className="block text-sm font-medium text-blue-900 mb-1">{t('autofix.kd04a7c59')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={createName}
|
value={createName}
|
||||||
onChange={e => setCreateName(e.target.value)}
|
onChange={e => setCreateName(e.target.value)}
|
||||||
disabled={createLoading}
|
disabled={createLoading}
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
|
||||||
placeholder="e.g., Platinum Matrix"
|
placeholder={t('autofix.k3f833ce6')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-1">Top-node Email</label>
|
<label className="block text-sm font-medium text-blue-900 mb-1">{t('autofix.k3ee27b4f')}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={createEmail}
|
value={createEmail}
|
||||||
onChange={e => setCreateEmail(e.target.value)}
|
onChange={e => setCreateEmail(e.target.value)}
|
||||||
disabled={createLoading}
|
disabled={createLoading}
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
|
||||||
placeholder="owner@example.com"
|
placeholder={t('autofix.k383672e3')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-1">Matrix Depth</label>
|
<label className="block text-sm font-medium text-blue-900 mb-1">{t('autofix.kda96f5b3')}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
@ -513,7 +505,7 @@ export default function MatrixManagementPage() {
|
|||||||
onChange={e => setCreateDepth(Number(e.target.value))}
|
onChange={e => setCreateDepth(Number(e.target.value))}
|
||||||
disabled={createLoading}
|
disabled={createLoading}
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
|
||||||
placeholder="e.g., 5"
|
placeholder={t('autofix.k8f46c81e')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Header from '../../components/nav/Header'
|
import Header from '../../components/nav/Header'
|
||||||
import Footer from '../../components/Footer'
|
import Footer from '../../components/Footer'
|
||||||
@ -12,6 +15,7 @@ import { updateNews } from './hooks/updateNews'
|
|||||||
import { deleteNews } from './hooks/deleteNews'
|
import { deleteNews } from './hooks/deleteNews'
|
||||||
|
|
||||||
export default function NewsManagementPage() {
|
export default function NewsManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { items, loading, error, refresh } = useAdminNews()
|
const { items, loading, error, refresh } = useAdminNews()
|
||||||
const [showCreate, setShowCreate] = React.useState(false)
|
const [showCreate, setShowCreate] = React.useState(false)
|
||||||
const [selected, setSelected] = React.useState<any | null>(null)
|
const [selected, setSelected] = React.useState<any | null>(null)
|
||||||
@ -25,10 +29,9 @@ export default function NewsManagementPage() {
|
|||||||
<main className="bg-white min-h-screen pb-20">
|
<main className="bg-white min-h-screen pb-20">
|
||||||
<div className="mx-auto max-w-7xl px-6 pt-8 pb-12">
|
<div className="mx-auto max-w-7xl px-6 pt-8 pb-12">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-blue-900">News Manager</h1>
|
<h1 className="text-2xl font-bold text-blue-900">{t('autofix.k471ba099')}</h1>
|
||||||
<button onClick={() => setShowCreate(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-900 text-white rounded-lg hover:bg-blue-800">
|
<button onClick={() => setShowCreate(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-900 text-white rounded-lg hover:bg-blue-800">
|
||||||
<PlusIcon className="h-5 w-5" /> Add News
|
<PlusIcon className="h-5 w-5" />{t('autofix.k75078d0b')}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="mt-4 text-red-600">{error}</div>}
|
{error && <div className="mt-4 text-red-600">{error}</div>}
|
||||||
@ -101,7 +104,7 @@ export default function NewsManagementPage() {
|
|||||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
|
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
|
||||||
<div className="px-6 pt-6">
|
<div className="px-6 pt-6">
|
||||||
<h3 className="text-lg font-semibold text-blue-900">Delete news?</h3>
|
<h3 className="text-lg font-semibold text-blue-900">{t('autofix.k088d8f6c')}</h3>
|
||||||
<p className="mt-2 text-sm text-gray-700">You are about to delete "{deleteTarget.title}". This action cannot be undone.</p>
|
<p className="mt-2 text-sm text-gray-700">You are about to delete "{deleteTarget.title}". This action cannot be undone.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
||||||
@ -137,6 +140,7 @@ function CreateNewsModal({
|
|||||||
creating: boolean
|
creating: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [title, setTitle] = React.useState('')
|
const [title, setTitle] = React.useState('')
|
||||||
const [summary, setSummary] = React.useState('')
|
const [summary, setSummary] = React.useState('')
|
||||||
const [content, setContent] = React.useState('')
|
const [content, setContent] = React.useState('')
|
||||||
@ -199,7 +203,7 @@ function CreateNewsModal({
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
|
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||||
<h2 className="text-2xl font-bold text-blue-900">Add News</h2>
|
<h2 className="text-2xl font-bold text-blue-900">{t('autofix.k75078d0b')}</h2>
|
||||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={submit} className="p-6 space-y-4">
|
<form onSubmit={submit} className="p-6 space-y-4">
|
||||||
@ -217,7 +221,7 @@ function CreateNewsModal({
|
|||||||
onChange={e=>{ setSlugTouched(true); setSlug(e.target.value) }}
|
onChange={e=>{ setSlugTouched(true); setSlug(e.target.value) }}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">Used in the URL. Auto-generated from title unless edited.</p>
|
<p className="mt-1 text-xs text-gray-500">{t('autofix.kf3b81ba3')}</p>
|
||||||
</div>
|
</div>
|
||||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
||||||
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
||||||
@ -228,8 +232,8 @@ function CreateNewsModal({
|
|||||||
{!previewUrl ? (
|
{!previewUrl ? (
|
||||||
<div className="text-center w-full px-6 py-10">
|
<div className="text-center w-full px-6 py-10">
|
||||||
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
|
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
<div className="mt-4 text-sm font-medium text-gray-700">Click to upload</div>
|
<div className="mt-4 text-sm font-medium text-gray-700">{t('autofix.kf4868273')}</div>
|
||||||
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP up to 5MB</p>
|
<p className="text-xs text-gray-500 mt-2">{t('autofix.kcd9890e5')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
|
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
|
||||||
@ -251,9 +255,7 @@ function CreateNewsModal({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={creating || !title.trim()}
|
disabled={creating || !title.trim()}
|
||||||
className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg disabled:opacity-60 disabled:cursor-not-allowed"
|
className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
>
|
>{creating ? t('autofix.k27b5b842') : t('autofix.k75078d0b')}</button>
|
||||||
{creating ? 'Creating…' : 'Add News'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -263,6 +265,7 @@ function CreateNewsModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () => void; onUpdate: (id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) => void }) {
|
function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () => void; onUpdate: (id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [title, setTitle] = React.useState(item.title)
|
const [title, setTitle] = React.useState(item.title)
|
||||||
const [summary, setSummary] = React.useState(item.summary || '')
|
const [summary, setSummary] = React.useState(item.summary || '')
|
||||||
const [content, setContent] = React.useState(item.content || '')
|
const [content, setContent] = React.useState(item.content || '')
|
||||||
@ -314,7 +317,7 @@ function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () =>
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
|
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||||
<h2 className="text-2xl font-bold text-blue-900">Edit News</h2>
|
<h2 className="text-2xl font-bold text-blue-900">{t('autofix.k73cf4fb6')}</h2>
|
||||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={submit} className="p-6 space-y-4">
|
<form onSubmit={submit} className="p-6 space-y-4">
|
||||||
@ -329,8 +332,8 @@ function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () =>
|
|||||||
{!displayUrl ? (
|
{!displayUrl ? (
|
||||||
<div className="text-center w-full px-6 py-10">
|
<div className="text-center w-full px-6 py-10">
|
||||||
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
|
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
<div className="mt-4 text-sm font-medium text-gray-700">Click to upload</div>
|
<div className="mt-4 text-sm font-medium text-gray-700">{t('autofix.kf4868273')}</div>
|
||||||
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP up to 5MB</p>
|
<p className="text-xs text-gray-500 mt-2">{t('autofix.kcd9890e5')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
|
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
|
||||||
@ -348,7 +351,7 @@ function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () =>
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
||||||
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100">Cancel</button>
|
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100">Cancel</button>
|
||||||
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Save Changes</button>
|
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">{t('autofix.k5a489751')}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,12 +12,14 @@ import {
|
|||||||
Squares2X2Icon,
|
Squares2X2Icon,
|
||||||
BanknotesIcon,
|
BanknotesIcon,
|
||||||
ClipboardDocumentListIcon,
|
ClipboardDocumentListIcon,
|
||||||
CommandLineIcon
|
CommandLineIcon,
|
||||||
|
LanguageIcon
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
import { useMemo, useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useAdminUsers } from '../hooks/useAdminUsers'
|
import { useAdminUsers } from '../hooks/useAdminUsers'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
|
import { useTranslation } from '../i18n/useTranslation'
|
||||||
|
|
||||||
// env-based feature flags
|
// env-based feature flags
|
||||||
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
|
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
|
||||||
@ -27,6 +29,7 @@ const DISPLAY_DEV_MANAGEMENT = process.env.NEXT_PUBLIC_DISPLAY_DEV_MANAGEMENT !=
|
|||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useTranslation()
|
||||||
const { userStats, isAdmin } = useAdminUsers()
|
const { userStats, isAdmin } = useAdminUsers()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const isAdminOrSuper =
|
const isAdminOrSuper =
|
||||||
@ -90,7 +93,7 @@ export default function AdminDashboardPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
|
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
|
||||||
<p className="text-blue-900">Loading...</p>
|
<p className="text-blue-900">{t('adminDashboard.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@ -104,8 +107,8 @@ export default function AdminDashboardPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
||||||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
|
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
|
<h1 className="text-2xl font-bold text-red-600 mb-2">{t('adminDashboard.accessDenied')}</h1>
|
||||||
<p className="text-gray-600">You need admin privileges to access this page.</p>
|
<p className="text-gray-600">{t('adminDashboard.accessDeniedMessage')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -120,9 +123,9 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex flex-col gap-4 mb-8">
|
<header className="flex flex-col gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Admin Dashboard</h1>
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('adminDashboard.title')}</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">
|
<p className="text-lg text-blue-700 mt-2">
|
||||||
Manage all administrative features, user management, permissions, and global settings.
|
{t('adminDashboard.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -132,10 +135,10 @@ export default function AdminDashboardPage() {
|
|||||||
<ExclamationTriangleIcon className="h-6 w-6 flex-shrink-0 text-red-500 mt-0.5" />
|
<ExclamationTriangleIcon className="h-6 w-6 flex-shrink-0 text-red-500 mt-0.5" />
|
||||||
<div className="leading-relaxed">
|
<div className="leading-relaxed">
|
||||||
<p className="font-semibold mb-0.5">
|
<p className="font-semibold mb-0.5">
|
||||||
Warning: Settings and actions below this point can have consequences for the entire system!
|
{t('adminDashboard.warningTitle')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-red-600/80 hidden sm:block">
|
<p className="text-red-600/80 hidden sm:block">
|
||||||
Manage all administrative features, user management, permissions, and global settings.
|
{t('adminDashboard.warningMessage')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -143,27 +146,27 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Stats Card */}
|
{/* Stats Card */}
|
||||||
<div className="mb-8 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6">
|
<div className="mb-8 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6">
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
<div className="text-xs text-gray-500">Total Users</div>
|
<div className="text-xs text-gray-500">{t('adminDashboard.totalUsers')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{displayStats.totalUsers}</div>
|
<div className="text-xl font-semibold text-blue-900">{displayStats.totalUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
<div className="text-xs text-gray-500">Admins</div>
|
<div className="text-xs text-gray-500">{t('adminDashboard.admins')}</div>
|
||||||
<div className="text-xl font-semibold text-indigo-700">{displayStats.adminUsers}</div>
|
<div className="text-xl font-semibold text-indigo-700">{displayStats.adminUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
<div className="text-xs text-gray-500">Active</div>
|
<div className="text-xs text-gray-500">{t('adminDashboard.active')}</div>
|
||||||
<div className="text-xl font-semibold text-green-700">{displayStats.activeUsers}</div>
|
<div className="text-xl font-semibold text-green-700">{displayStats.activeUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
<div className="text-xs text-gray-500">Pending Verification</div>
|
<div className="text-xs text-gray-500">{t('adminDashboard.pendingVerification')}</div>
|
||||||
<div className="text-xl font-semibold text-amber-700">{displayStats.verificationPending}</div>
|
<div className="text-xl font-semibold text-amber-700">{displayStats.verificationPending}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
<div className="text-xs text-gray-500">Personal</div>
|
<div className="text-xs text-gray-500">{t('adminDashboard.personal')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-700">{displayStats.personalUsers}</div>
|
<div className="text-xl font-semibold text-blue-700">{displayStats.personalUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
<div className="text-xs text-gray-500">Company</div>
|
<div className="text-xs text-gray-500">{t('adminDashboard.company')}</div>
|
||||||
<div className="text-xl font-semibold text-purple-700">{displayStats.companyUsers}</div>
|
<div className="text-xl font-semibold text-purple-700">{displayStats.companyUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -176,9 +179,9 @@ export default function AdminDashboardPage() {
|
|||||||
<Squares2X2Icon className="h-7 w-7 text-blue-600" />
|
<Squares2X2Icon className="h-7 w-7 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-blue-900">Management Shortcuts</h2>
|
<h2 className="text-lg font-semibold text-blue-900">{t('adminDashboard.managementShortcuts')}</h2>
|
||||||
<p className="text-sm text-blue-700 mt-0.5">
|
<p className="text-sm text-blue-700 mt-0.5">
|
||||||
Quick access to common admin modules.
|
{t('adminDashboard.managementShortcutsSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -205,11 +208,11 @@ export default function AdminDashboardPage() {
|
|||||||
<Squares2X2Icon className={`h-6 w-6 ${DISPLAY_MATRIX ? 'text-blue-600' : 'text-gray-400'}`} />
|
<Squares2X2Icon className={`h-6 w-6 ${DISPLAY_MATRIX ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-blue-900">Matrix Management</div>
|
<div className="text-base font-semibold text-blue-900">{t('adminDashboard.matrixManagement')}</div>
|
||||||
<div className="text-xs text-blue-700">Configure matrices and users</div>
|
<div className="text-xs text-blue-700">{t('adminDashboard.matrixManagementDesc')}</div>
|
||||||
{!DISPLAY_MATRIX && (
|
{!DISPLAY_MATRIX && (
|
||||||
<p className="mt-1 text-xs text-gray-500 italic">
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
This module is currently disabled in the system configuration.
|
{t('adminDashboard.moduleDisabled')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -243,11 +246,11 @@ export default function AdminDashboardPage() {
|
|||||||
<BanknotesIcon className={`h-6 w-6 ${DISPLAY_ABONEMENTS ? 'text-amber-600' : 'text-gray-400'}`} />
|
<BanknotesIcon className={`h-6 w-6 ${DISPLAY_ABONEMENTS ? 'text-amber-600' : 'text-gray-400'}`} />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-amber-900">Coffee Subscription Management</div>
|
<div className="text-base font-semibold text-amber-900">{t('adminDashboard.coffeeSubscriptions')}</div>
|
||||||
<div className="text-xs text-amber-700">Plans, billing and renewals</div>
|
<div className="text-xs text-amber-700">{t('adminDashboard.coffeeSubscriptionsDesc')}</div>
|
||||||
{!DISPLAY_ABONEMENTS && (
|
{!DISPLAY_ABONEMENTS && (
|
||||||
<p className="mt-1 text-xs text-gray-500 italic">
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
This module is currently disabled in the system configuration.
|
{t('adminDashboard.moduleDisabled')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -272,8 +275,8 @@ export default function AdminDashboardPage() {
|
|||||||
<ClipboardDocumentListIcon className="h-6 w-6 text-indigo-600" />
|
<ClipboardDocumentListIcon className="h-6 w-6 text-indigo-600" />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-indigo-900">Contract Management</div>
|
<div className="text-base font-semibold text-indigo-900">{t('adminDashboard.contractManagement')}</div>
|
||||||
<div className="text-xs text-indigo-700">Templates, approvals, status</div>
|
<div className="text-xs text-indigo-700">{t('adminDashboard.contractManagementDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" />
|
<ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" />
|
||||||
@ -290,8 +293,8 @@ export default function AdminDashboardPage() {
|
|||||||
<Squares2X2Icon className="h-6 w-6 text-blue-600" />
|
<Squares2X2Icon className="h-6 w-6 text-blue-600" />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-blue-900">Dashboard Management</div>
|
<div className="text-base font-semibold text-blue-900">{t('adminDashboard.dashboardManagement')}</div>
|
||||||
<div className="text-xs text-blue-700">Configure dashboard platforms</div>
|
<div className="text-xs text-blue-700">{t('adminDashboard.dashboardManagementDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
|
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
|
||||||
@ -308,13 +311,85 @@ export default function AdminDashboardPage() {
|
|||||||
<UsersIcon className="h-6 w-6 text-blue-600" />
|
<UsersIcon className="h-6 w-6 text-blue-600" />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-blue-900">User Management</div>
|
<div className="text-base font-semibold text-blue-900">{t('adminDashboard.userManagement')}</div>
|
||||||
<div className="text-xs text-blue-700">Browse, search, and manage all users</div>
|
<div className="text-xs text-blue-700">{t('adminDashboard.userManagementDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
|
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* User Verify */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/admin/user-verify')}
|
||||||
|
className="group w-full flex items-center justify-between rounded-lg border border-rose-200 bg-rose-50 hover:bg-rose-100 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-rose-100 border border-rose-200 group-hover:animate-pulse">
|
||||||
|
<ExclamationTriangleIcon className="h-6 w-6 text-rose-600" />
|
||||||
|
</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-base font-semibold text-rose-900">{t('adminDashboard.userVerify')}</div>
|
||||||
|
<div className="text-xs text-rose-700">{t('adminDashboard.userVerifyDesc')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon className="h-5 w-5 text-rose-600 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Finance Management */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/admin/finance-management')}
|
||||||
|
className="group w-full flex items-center justify-between rounded-lg border border-emerald-200 bg-emerald-50 hover:bg-emerald-100 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-emerald-100 border border-emerald-200 group-hover:animate-pulse">
|
||||||
|
<BanknotesIcon className="h-6 w-6 text-emerald-600" />
|
||||||
|
</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-base font-semibold text-emerald-900">{t('adminDashboard.financeManagement')}</div>
|
||||||
|
<div className="text-xs text-emerald-700">{t('adminDashboard.financeManagementDesc')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon className="h-5 w-5 text-emerald-600 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Pool Management */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/admin/pool-management')}
|
||||||
|
className="group w-full flex items-center justify-between rounded-lg border border-cyan-200 bg-cyan-50 hover:bg-cyan-100 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-cyan-100 border border-cyan-200 group-hover:animate-pulse">
|
||||||
|
<ServerStackIcon className="h-6 w-6 text-cyan-600" />
|
||||||
|
</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-base font-semibold text-cyan-900">{t('adminDashboard.poolManagement')}</div>
|
||||||
|
<div className="text-xs text-cyan-700">{t('adminDashboard.poolManagementDesc')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon className="h-5 w-5 text-cyan-600 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Affiliate Management */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/admin/affiliate-management')}
|
||||||
|
className="group w-full flex items-center justify-between rounded-lg border border-violet-200 bg-violet-50 hover:bg-violet-100 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-violet-100 border border-violet-200 group-hover:animate-pulse">
|
||||||
|
<UsersIcon className="h-6 w-6 text-violet-600" />
|
||||||
|
</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-base font-semibold text-violet-900">{t('adminDashboard.affiliateManagement')}</div>
|
||||||
|
<div className="text-xs text-violet-700">{t('adminDashboard.affiliateManagementDesc')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon className="h-5 w-5 text-violet-600 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* News Management */}
|
{/* News Management */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -337,11 +412,11 @@ export default function AdminDashboardPage() {
|
|||||||
<ClipboardDocumentListIcon className={`h-6 w-6 ${DISPLAY_NEWS ? 'text-green-600' : 'text-gray-400'}`} />
|
<ClipboardDocumentListIcon className={`h-6 w-6 ${DISPLAY_NEWS ? 'text-green-600' : 'text-gray-400'}`} />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-green-900">News Management</div>
|
<div className="text-base font-semibold text-green-900">{t('adminDashboard.newsManagement')}</div>
|
||||||
<div className="text-xs text-green-700">Create and manage news articles</div>
|
<div className="text-xs text-green-700">{t('adminDashboard.newsManagementDesc')}</div>
|
||||||
{!DISPLAY_NEWS && (
|
{!DISPLAY_NEWS && (
|
||||||
<p className="mt-1 text-xs text-gray-500 italic">
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
This module is currently disabled in the system configuration.
|
{t('adminDashboard.moduleDisabled')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -375,16 +450,16 @@ export default function AdminDashboardPage() {
|
|||||||
<CommandLineIcon className={`h-6 w-6 ${DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600' : 'text-gray-400'}`} />
|
<CommandLineIcon className={`h-6 w-6 ${DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600' : 'text-gray-400'}`} />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-slate-900">Dev Management</div>
|
<div className="text-base font-semibold text-slate-900">{t('adminDashboard.devManagement')}</div>
|
||||||
<div className="text-xs text-slate-700">Run SQL queries and dev tools</div>
|
<div className="text-xs text-slate-700">{t('adminDashboard.devManagementDesc')}</div>
|
||||||
{!DISPLAY_DEV_MANAGEMENT && (
|
{!DISPLAY_DEV_MANAGEMENT && (
|
||||||
<p className="mt-1 text-xs text-gray-500 italic">
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
This module is currently disabled in the system configuration.
|
{t('adminDashboard.moduleDisabled')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{DISPLAY_DEV_MANAGEMENT && !isAdminOrSuper && (
|
{DISPLAY_DEV_MANAGEMENT && !isAdminOrSuper && (
|
||||||
<p className="mt-1 text-xs text-gray-500 italic">
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
Admin access required.
|
{t('adminDashboard.adminAccessRequired')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -395,6 +470,24 @@ export default function AdminDashboardPage() {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Language Management */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/admin/language-management')}
|
||||||
|
className="group w-full flex items-center justify-between rounded-lg border border-teal-200 bg-teal-50 hover:bg-teal-100 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-teal-100 border border-teal-200 group-hover:animate-pulse">
|
||||||
|
<LanguageIcon className="h-6 w-6 text-teal-600" />
|
||||||
|
</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-base font-semibold text-teal-900">{t('adminDashboard.languageManagement')}</div>
|
||||||
|
<div className="text-xs text-teal-700">{t('adminDashboard.languageManagementDesc')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon className="h-5 w-5 text-teal-600 opacity-70 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -407,10 +500,10 @@ export default function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
Server Status & Logs
|
{t('adminDashboard.serverStatusLogs')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
System health, resource usage & recent error insights.
|
{t('adminDashboard.serverStatusLogsSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -421,20 +514,20 @@ export default function AdminDashboardPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className={`h-2.5 w-2.5 rounded-full ${serverStats.status === 'Online' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
<span className={`h-2.5 w-2.5 rounded-full ${serverStats.status === 'Online' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||||
<p className="text-base">
|
<p className="text-base">
|
||||||
<span className="font-semibold">Server Status:</span>{' '}
|
<span className="font-semibold">{t('adminDashboard.serverStatusLabel')}</span>{' '}
|
||||||
<span className={serverStats.status === 'Online' ? 'text-emerald-600 font-medium' : 'text-red-600 font-medium'}>
|
<span className={serverStats.status === 'Online' ? 'text-emerald-600 font-medium' : 'text-red-600 font-medium'}>
|
||||||
{serverStats.status === 'Online' ? 'Server Online' : 'Offline'}
|
{serverStats.status === 'Online' ? t('adminDashboard.serverOnline') : t('adminDashboard.serverOffline')}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm space-y-1 text-gray-600">
|
<div className="text-sm space-y-1 text-gray-600">
|
||||||
<p><span className="font-medium text-gray-700">Uptime:</span> {serverStats.uptime}</p>
|
<p><span className="font-medium text-gray-700">{t('adminDashboard.uptime')}</span> {serverStats.uptime}</p>
|
||||||
<p><span className="font-medium text-gray-700">CPU Usage:</span> {serverStats.cpu}</p>
|
<p><span className="font-medium text-gray-700">{t('adminDashboard.cpuUsage')}</span> {serverStats.cpu}</p>
|
||||||
<p><span className="font-medium text-gray-700">Memory Usage:</span> {serverStats.memory} GB</p>
|
<p><span className="font-medium text-gray-700">{t('adminDashboard.memoryUsage')}</span> {serverStats.memory} GB</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
<CpuChipIcon className="h-4 w-4" />
|
<CpuChipIcon className="h-4 w-4" />
|
||||||
<span>Autoscaled environment (mock)</span>
|
<span>{t('adminDashboard.autoscaledEnvironment')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -444,11 +537,11 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Logs */}
|
{/* Logs */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<h3 className="text-base font-semibold text-gray-800 mb-3">
|
<h3 className="text-base font-semibold text-gray-800 mb-3">
|
||||||
Recent Error Logs
|
{t('adminDashboard.recentErrorLogs')}
|
||||||
</h3>
|
</h3>
|
||||||
{serverStats.recentErrors.length === 0 && (
|
{serverStats.recentErrors.length === 0 && (
|
||||||
<p className="text-sm text-gray-500 italic">
|
<p className="text-sm text-gray-500 italic">
|
||||||
No recent logs.
|
{t('adminDashboard.noRecentLogs')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{/* Placeholder for future logs list */}
|
{/* Placeholder for future logs list */}
|
||||||
@ -460,7 +553,7 @@ export default function AdminDashboardPage() {
|
|||||||
// TODO: navigate to logs / monitoring page
|
// TODO: navigate to logs / monitoring page
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
>
|
>
|
||||||
View Full Logs
|
{t('adminDashboard.viewFullLogs')}
|
||||||
<ArrowRightIcon className="h-5 w-5" />
|
<ArrowRightIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
160
src/app/admin/pool-management/components/PoolManagementGrid.tsx
Normal file
160
src/app/admin/pool-management/components/PoolManagementGrid.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { AdminPool } from '../hooks/getlist'
|
||||||
|
import { translateMaybeKey } from '../utils/translateMaybeKey'
|
||||||
|
|
||||||
|
type Translator = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
pools: AdminPool[]
|
||||||
|
filteredPools: AdminPool[]
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
archiveError: string
|
||||||
|
showInactive: boolean
|
||||||
|
onManage: (pool: AdminPool) => void
|
||||||
|
onArchive: (poolId: string) => void
|
||||||
|
onActivate: (poolId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolManagementGrid({
|
||||||
|
t,
|
||||||
|
pools,
|
||||||
|
filteredPools,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
archiveError,
|
||||||
|
showInactive,
|
||||||
|
onManage,
|
||||||
|
onArchive,
|
||||||
|
onActivate,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/85 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] p-6 sm:p-7 backdrop-blur-md">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-5">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.k5857ef79')}</h2>
|
||||||
|
<span className="text-sm text-slate-600">{t('autofix.k5f4d2c11').replace('{count}', String(pools.length))}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{archiveError && (
|
||||||
|
<div className="mb-4 rounded-xl border border-red-200 bg-red-50/90 px-4 py-3 text-sm text-red-700">
|
||||||
|
{archiveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-xl border border-red-200 bg-red-50/90 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid [grid-template-columns:repeat(auto-fit,minmax(19rem,1fr))] gap-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<div key={index} className="rounded-2xl bg-white border border-slate-100 shadow-sm p-5">
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-5 w-1/2 bg-slate-200 rounded" />
|
||||||
|
<div className="h-4 w-3/4 bg-slate-200 rounded" />
|
||||||
|
<div className="h-4 w-2/3 bg-slate-100 rounded" />
|
||||||
|
<div className="h-8 w-full bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid [grid-template-columns:repeat(auto-fit,minmax(19rem,1fr))] gap-6">
|
||||||
|
{filteredPools.map((pool) => {
|
||||||
|
const isCore = pool.pool_name === 'Core'
|
||||||
|
const poolDescription = translateMaybeKey(t, pool.description, '-')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={pool.id}
|
||||||
|
className={`rounded-2xl border shadow-sm p-5 flex flex-col ${
|
||||||
|
isCore
|
||||||
|
? 'bg-gradient-to-br from-amber-50 via-white to-amber-50 border-amber-300 ring-1 ring-amber-200'
|
||||||
|
: 'bg-white border-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCore && (
|
||||||
|
<div className="self-start inline-flex items-center gap-1 rounded-full bg-amber-500 px-2.5 py-0.5 text-[10px] font-bold text-white uppercase tracking-wider shadow-sm mb-3">
|
||||||
|
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>
|
||||||
|
{t('autofix.k87e4b9a2')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className={`h-9 w-9 rounded-lg border flex items-center justify-center shrink-0 ${
|
||||||
|
isCore ? 'bg-amber-100 border-amber-300' : 'bg-slate-50 border-slate-200'
|
||||||
|
}`}>
|
||||||
|
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-slate-900'}`} />
|
||||||
|
</div>
|
||||||
|
<h3 className={`text-lg font-semibold break-words ${isCore ? 'text-amber-900' : 'text-slate-900'}`}>
|
||||||
|
{pool.pool_name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||||
|
!pool.is_active ? 'bg-slate-100 text-slate-700' : 'bg-green-100 text-green-800'
|
||||||
|
}`}>
|
||||||
|
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!pool.is_active ? 'bg-slate-400' : 'bg-green-500'}`} />
|
||||||
|
{!pool.is_active ? t('autofix.ke2a1b003') : t('autofix.k3bc84f12')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-slate-700 break-words">{poolDescription}</p>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-slate-600">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">{t('autofix.kfd227aa9')}</span>
|
||||||
|
<div className="font-medium text-slate-900">{pool.membersCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">{t('autofix.k91c8d444')}</span>
|
||||||
|
<div className="font-medium text-slate-900 break-words">
|
||||||
|
{new Date(pool.createdAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-xl bg-slate-200 text-slate-700 hover:bg-slate-300 transition"
|
||||||
|
onClick={() => onManage(pool)}
|
||||||
|
>
|
||||||
|
{t('autofix.k7d2a1190')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!pool.is_active ? (
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-xl bg-green-100 text-green-800 hover:bg-green-200 transition"
|
||||||
|
onClick={() => onActivate(pool.id)}
|
||||||
|
title={t('autofix.kd40c4f86')}
|
||||||
|
>
|
||||||
|
{t('autofix.ke697b8cb')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-xl bg-amber-100 text-amber-800 hover:bg-amber-200 transition"
|
||||||
|
onClick={() => onArchive(pool.id)}
|
||||||
|
title={t('autofix.ke19afb3d')}
|
||||||
|
>
|
||||||
|
{t('autofix.kf3b0c221')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filteredPools.length === 0 && !loading && !error && (
|
||||||
|
<div className="col-span-full text-center text-slate-500 italic py-6">
|
||||||
|
{showInactive ? t('autofix.k1e2f3a44') : t('autofix.ka8b3c104')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
type Translator = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
showInactive: boolean
|
||||||
|
onShowActive: () => void
|
||||||
|
onShowInactive: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolManagementHeader({ t, showInactive, onShowActive, onShowInactive }: Props) {
|
||||||
|
return (
|
||||||
|
<header className="rounded-[28px] border border-white/80 bg-white/85 py-8 px-6 sm:px-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] backdrop-blur-md mb-8">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
|
{t('autofix.k6f7f26a1')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mt-3 text-3xl sm:text-4xl font-extrabold text-slate-900 tracking-tight break-words">{t('autofix.k21440f8a')}</h1>
|
||||||
|
<p className="text-base sm:text-lg text-slate-600 mt-2 break-words">{t('autofix.k67391c88')}</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-600">{t('autofix.k0dd01c1c')}</span>
|
||||||
|
<button
|
||||||
|
onClick={onShowActive}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-xl transition ${!showInactive ? 'bg-slate-900 text-white' : 'bg-white text-slate-700 border border-slate-200 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
{t('autofix.k15843a06')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onShowInactive}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-xl transition ${showInactive ? 'bg-slate-900 text-white' : 'bg-white text-slate-700 border border-slate-200 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
{t('autofix.kb5e0b861')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,4 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -22,6 +25,7 @@ export default function CreateNewPoolModal({
|
|||||||
success,
|
success,
|
||||||
clearMessages
|
clearMessages
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [poolName, setPoolName] = React.useState('')
|
const [poolName, setPoolName] = React.useState('')
|
||||||
const [description, setDescription] = React.useState('')
|
const [description, setDescription] = React.useState('')
|
||||||
const [price, setPrice] = React.useState('0.00')
|
const [price, setPrice] = React.useState('0.00')
|
||||||
@ -52,11 +56,11 @@ export default function CreateNewPoolModal({
|
|||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div className="relative w-full max-w-lg mx-4 rounded-2xl bg-white shadow-xl border border-blue-100 p-6">
|
<div className="relative w-full max-w-lg mx-4 rounded-2xl bg-white shadow-xl border border-blue-100 p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-semibold text-blue-900">Create New Pool</h2>
|
<h2 className="text-xl font-semibold text-blue-900">{t('autofix.k209ba561')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => { clearMessages(); onClose(); }}
|
onClick={() => { clearMessages(); onClose(); }}
|
||||||
className="text-gray-500 hover:text-gray-700 transition text-sm"
|
className="text-gray-500 hover:text-gray-700 transition text-sm"
|
||||||
aria-label="Close"
|
aria-label={t('common.close')}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@ -88,10 +92,10 @@ export default function CreateNewPoolModal({
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Name</label>
|
<label className="block text-sm font-medium text-blue-900 mb-1">{t('autofix.kd4a0fd1e')}</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
placeholder="e.g., VIP Members"
|
placeholder={t('autofix.k0925e287')}
|
||||||
value={poolName}
|
value={poolName}
|
||||||
onChange={e => setPoolName(e.target.value)}
|
onChange={e => setPoolName(e.target.value)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
@ -99,18 +103,18 @@ export default function CreateNewPoolModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-1">Description</label>
|
<label className="block text-sm font-medium text-blue-900 mb-1">{t('autofix.k40b5c1d2')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Short description of the pool"
|
placeholder={t('autofix.kb573897d')}
|
||||||
value={description}
|
value={description}
|
||||||
onChange={e => setDescription(e.target.value)}
|
onChange={e => setDescription(e.target.value)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-1">Price per capsule (net)</label>
|
<label className="block text-sm font-medium text-blue-900 mb-1">{t('autofix.k8ef02c19')}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@ -122,29 +126,29 @@ export default function CreateNewPoolModal({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">This value is stored as net price.</p>
|
<p className="mt-1 text-xs text-gray-500">{t('autofix.k75cb45a7')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Type</label>
|
<label className="block text-sm font-medium text-blue-900 mb-1">{t('autofix.kd49dc1e1')}</label>
|
||||||
<select
|
<select
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
value={poolType}
|
value={poolType}
|
||||||
onChange={e => setPoolType(e.target.value as 'coffee' | 'other')}
|
onChange={e => setPoolType(e.target.value as 'coffee' | 'other')}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
<option value="other">Other</option>
|
<option value="other">{t('autofix.ka320df81')}</option>
|
||||||
<option value="coffee">Coffee</option>
|
<option value="coffee">{t('autofix.k2f9cd1e0')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-1">Linked Subscription</label>
|
<label className="block text-sm font-medium text-blue-900 mb-1">{t('autofix.k59422f07')}</label>
|
||||||
<select
|
<select
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
value={subscriptionCoffeeId}
|
value={subscriptionCoffeeId}
|
||||||
onChange={e => setSubscriptionCoffeeId(e.target.value)}
|
onChange={e => setSubscriptionCoffeeId(e.target.value)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
<option value="">No subscription selected (set later)</option>
|
<option value="">{t('autofix.kb8d70f41')}</option>
|
||||||
{subscriptions.map((s) => (
|
{subscriptions.map((s) => (
|
||||||
<option key={s.id} value={String(s.id)}>{s.title}</option>
|
<option key={s.id} value={String(s.id)}>{s.title}</option>
|
||||||
))}
|
))}
|
||||||
@ -157,7 +161,7 @@ export default function CreateNewPoolModal({
|
|||||||
className="px-5 py-3 text-sm font-semibold text-blue-50 rounded-lg bg-blue-900 hover:bg-blue-800 shadow inline-flex items-center gap-2 disabled:opacity-60"
|
className="px-5 py-3 text-sm font-semibold text-blue-50 rounded-lg bg-blue-900 hover:bg-blue-800 shadow inline-flex items-center gap-2 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{creating && <span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
|
{creating && <span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
|
||||||
{creating ? 'Creating...' : 'Create Pool'}
|
{creating ? t('autofix.k241a2d77') : t('autofix.kf9d2e4a0')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -165,7 +169,7 @@ export default function CreateNewPoolModal({
|
|||||||
onClick={() => { setPoolName(''); setDescription(''); setPrice('0.00'); setPoolType('other'); setSubscriptionCoffeeId(''); clearMessages(); }}
|
onClick={() => { setPoolName(''); setDescription(''); setPrice('0.00'); setPoolType('other'); setSubscriptionCoffeeId(''); clearMessages(); }}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
Reset
|
{t('autofix.k612fc0a4')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { authFetch } from '../../../utils/authFetch';
|
import { authFetch } from '../../../utils/authFetch';
|
||||||
import { log } from '../../../utils/logger';
|
import { log } from '../../../utils/logger';
|
||||||
|
import { resolvePoolDescriptionKey } from '../utils/poolDescriptionKey';
|
||||||
|
|
||||||
export type AdminPool = {
|
export type AdminPool = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -63,7 +64,11 @@ export function useAdminPools() {
|
|||||||
const mapped: AdminPool[] = apiItems.map(item => ({
|
const mapped: AdminPool[] = apiItems.map(item => ({
|
||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
||||||
description: String(item.description ?? ''),
|
description: resolvePoolDescriptionKey(
|
||||||
|
String(item.pool_name ?? 'Unnamed Pool'),
|
||||||
|
item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||||
|
String(item.description ?? '')
|
||||||
|
),
|
||||||
price: Number(item.price_net ?? item.price ?? 0),
|
price: Number(item.price_net ?? item.price ?? 0),
|
||||||
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
|
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
|
||||||
subscription_title: item.subscription_title ?? null,
|
subscription_title: item.subscription_title ?? null,
|
||||||
@ -103,7 +108,11 @@ export function useAdminPools() {
|
|||||||
setPools(apiItems.map(item => ({
|
setPools(apiItems.map(item => ({
|
||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
||||||
description: String(item.description ?? ''),
|
description: resolvePoolDescriptionKey(
|
||||||
|
String(item.pool_name ?? 'Unnamed Pool'),
|
||||||
|
item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||||
|
String(item.description ?? '')
|
||||||
|
),
|
||||||
price: Number(item.price_net ?? item.price ?? 0),
|
price: Number(item.price_net ?? item.price ?? 0),
|
||||||
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
|
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
|
||||||
subscription_title: item.subscription_title ?? null,
|
subscription_title: item.subscription_title ?? null,
|
||||||
|
|||||||
112
src/app/admin/pool-management/hooks/usePoolManagementPage.ts
Normal file
112
src/app/admin/pool-management/hooks/usePoolManagementPage.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useAuthStore from '../../../store/authStore'
|
||||||
|
import { setPoolActive, setPoolInactive } from './poolStatus'
|
||||||
|
import type { AdminPool } from './getlist'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
|
|
||||||
|
export type PoolAction = 'archive' | 'activate'
|
||||||
|
|
||||||
|
export function usePoolManagementPage({
|
||||||
|
initialPools,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
}: {
|
||||||
|
initialPools: AdminPool[]
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
refresh?: () => Promise<boolean>
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
!!user &&
|
||||||
|
((user as any)?.role === 'admin' ||
|
||||||
|
(user as any)?.userType === 'admin' ||
|
||||||
|
(user as any)?.isAdmin === true ||
|
||||||
|
(user as any)?.roles?.includes?.('admin'))
|
||||||
|
|
||||||
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
|
const [archiveError, setArchiveError] = useState('')
|
||||||
|
const [poolStatusConfirm, setPoolStatusConfirm] = useState<{ poolId: string; action: PoolAction } | null>(null)
|
||||||
|
const [poolStatusPending, setPoolStatusPending] = useState(false)
|
||||||
|
const [pools, setPools] = useState<AdminPool[]>([])
|
||||||
|
const [showInactive, setShowInactive] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !error) {
|
||||||
|
setPools(initialPools)
|
||||||
|
}
|
||||||
|
}, [initialPools, loading, error])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user === null) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (user && !isAdmin) {
|
||||||
|
router.replace('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAuthChecked(true)
|
||||||
|
}, [user, isAdmin, router])
|
||||||
|
|
||||||
|
const filteredPools = useMemo(
|
||||||
|
() => pools.filter((pool) => (showInactive ? !pool.is_active : pool.is_active)),
|
||||||
|
[pools, showInactive]
|
||||||
|
)
|
||||||
|
|
||||||
|
const requestArchive = (poolId: string) => {
|
||||||
|
setPoolStatusConfirm({ poolId, action: 'archive' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestActivate = (poolId: string) => {
|
||||||
|
setPoolStatusConfirm({ poolId, action: 'activate' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePoolStatusConfirm = () => {
|
||||||
|
if (!poolStatusPending) {
|
||||||
|
setPoolStatusConfirm(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPoolStatusChange = async () => {
|
||||||
|
if (!poolStatusConfirm) return
|
||||||
|
|
||||||
|
const { poolId, action } = poolStatusConfirm
|
||||||
|
setPoolStatusPending(true)
|
||||||
|
setArchiveError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = action === 'archive' ? await setPoolInactive(poolId) : await setPoolActive(poolId)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await refresh?.()
|
||||||
|
} else {
|
||||||
|
setArchiveError(response.message || (action === 'archive' ? t('autofix.k1a0d4f73') : t('autofix.k54a977c3')))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPoolStatusPending(false)
|
||||||
|
setPoolStatusConfirm(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
router,
|
||||||
|
authChecked,
|
||||||
|
archiveError,
|
||||||
|
poolStatusConfirm,
|
||||||
|
poolStatusPending,
|
||||||
|
pools,
|
||||||
|
showInactive,
|
||||||
|
setShowInactive,
|
||||||
|
filteredPools,
|
||||||
|
requestArchive,
|
||||||
|
requestActivate,
|
||||||
|
closePoolStatusConfirm,
|
||||||
|
confirmPoolStatusChange,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { translateMaybeKey } from '../../utils/translateMaybeKey'
|
||||||
|
|
||||||
|
type Translator = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
poolId: string
|
||||||
|
poolName: string
|
||||||
|
poolDescription: string
|
||||||
|
poolPrice: number
|
||||||
|
poolIsActive: boolean
|
||||||
|
poolCreatedAt: string
|
||||||
|
isCore: boolean
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolManageHeader({
|
||||||
|
t,
|
||||||
|
poolId,
|
||||||
|
poolName,
|
||||||
|
poolDescription,
|
||||||
|
poolPrice,
|
||||||
|
poolIsActive,
|
||||||
|
poolCreatedAt,
|
||||||
|
isCore,
|
||||||
|
onBack,
|
||||||
|
}: Props) {
|
||||||
|
const resolvedDescription = translateMaybeKey(t, poolDescription, t('autofix.kf0c9a38d'))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={`rounded-[28px] border py-8 px-6 sm:px-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] backdrop-blur-md mb-8 ${
|
||||||
|
isCore ? 'bg-gradient-to-r from-amber-50/90 to-white/90 border-amber-200' : 'bg-white/85 border-white/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCore && (
|
||||||
|
<div className="inline-flex items-center gap-1.5 rounded-full bg-amber-500 px-3 py-1 text-xs font-bold text-white uppercase tracking-wider shadow-sm mb-3">
|
||||||
|
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>
|
||||||
|
{t('autofix.k39437388')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={`h-10 w-10 rounded-lg border flex items-center justify-center shrink-0 ${
|
||||||
|
isCore ? 'bg-amber-100 border-amber-300' : 'bg-slate-50 border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-slate-900'}`} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className={`text-3xl font-extrabold tracking-tight break-words ${isCore ? 'text-amber-900' : 'text-slate-900'}`}>{poolName}</h1>
|
||||||
|
<p className={`text-sm mt-1 break-words ${isCore ? 'text-amber-700' : 'text-slate-600'}`}>
|
||||||
|
{resolvedDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-600">
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${!poolIsActive ? 'bg-slate-100 text-slate-700' : 'bg-green-100 text-green-800'}`}>
|
||||||
|
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!poolIsActive ? 'bg-slate-400' : 'bg-green-500'}`} />
|
||||||
|
{!poolIsActive ? t('autofix.ke2a1b003') : t('autofix.k3bc84f12')}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="break-words">{t('autofix.k0a7d2d1e')} EUR {Number(poolPrice || 0).toFixed(2)}{isCore ? ` ${t('autofix.k9fb4721a')}` : ''}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{t('autofix.k91c8d444')} {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-slate-500 break-all">{t('autofix.k65ad80b0')} {poolId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-white text-slate-900 border border-slate-200 px-4 py-2 text-sm font-medium hover:bg-slate-50 transition"
|
||||||
|
title={t('autofix.k6285753a')}
|
||||||
|
>
|
||||||
|
{t('autofix.k0ac84efe')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import { BanknotesIcon, CalendarDaysIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
totalAmount: number
|
||||||
|
amountThisYear: number
|
||||||
|
amountThisMonth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, iconClassName, chipClassName }: { title: string; value: string; iconClassName: string; chipClassName: string }) {
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-2xl bg-white/90 px-6 py-5 shadow-[0_20px_55px_-38px_rgba(15,23,42,0.35)] border border-white/80">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className={`rounded-md p-2 shrink-0 ${chipClassName}`}>
|
||||||
|
<BanknotesIcon className={`h-5 w-5 ${iconClassName}`} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-slate-600 break-words">{title}</p>
|
||||||
|
<p className="text-2xl font-semibold text-slate-900 break-words">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolManageStats({ t, totalAmount, amountThisYear, amountThisMonth }: Props) {
|
||||||
|
return (
|
||||||
|
<section className="grid [grid-template-columns:repeat(auto-fit,minmax(16rem,1fr))] gap-6 mb-8">
|
||||||
|
<StatCard
|
||||||
|
title={t('autofix.ke8b9f33c')}
|
||||||
|
value={`EUR ${totalAmount.toLocaleString()}`}
|
||||||
|
chipClassName="bg-slate-900"
|
||||||
|
iconClassName="text-white"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative overflow-hidden rounded-2xl bg-white/90 px-6 py-5 shadow-[0_20px_55px_-38px_rgba(15,23,42,0.35)] border border-white/80">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="rounded-md bg-amber-600 p-2 shrink-0">
|
||||||
|
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-slate-600 break-words">{t('autofix.kaa8231ec')}</p>
|
||||||
|
<p className="text-2xl font-semibold text-slate-900 break-words">EUR {amountThisYear.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative overflow-hidden rounded-2xl bg-white/90 px-6 py-5 shadow-[0_20px_55px_-38px_rgba(15,23,42,0.35)] border border-white/80">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="rounded-md bg-green-600 p-2 shrink-0">
|
||||||
|
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-slate-600 break-words">{t('autofix.k86aa4f9c')}</p>
|
||||||
|
<p className="text-2xl font-semibold text-slate-900 break-words">EUR {amountThisMonth.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
import { PlusIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { PoolUser } from '../hooks/usePoolManageState'
|
||||||
|
|
||||||
|
type Translator = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
users: PoolUser[]
|
||||||
|
membersLoading: boolean
|
||||||
|
membersError: string
|
||||||
|
removeError: string
|
||||||
|
removingMemberId: string | null
|
||||||
|
isCore: boolean
|
||||||
|
onOpenSearch: () => void
|
||||||
|
onRemove: (userId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolMembersSection({
|
||||||
|
t,
|
||||||
|
users,
|
||||||
|
membersLoading,
|
||||||
|
membersError,
|
||||||
|
removeError,
|
||||||
|
removingMemberId,
|
||||||
|
isCore,
|
||||||
|
onOpenSearch,
|
||||||
|
onRemove,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/85 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] p-6 sm:p-7 backdrop-blur-md">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.kfd227aa9')}</h2>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-700">
|
||||||
|
{users.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onOpenSearch}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-50 px-5 py-3 text-sm font-semibold shadow transition"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-5 w-5" />
|
||||||
|
{t('autofix.k750c1eb5')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{removeError && (
|
||||||
|
<div className="mb-4 rounded-xl border border-red-200 bg-red-50/90 px-4 py-3 text-sm text-red-700">
|
||||||
|
{removeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{membersLoading && <div className="text-center text-slate-500 italic py-8">{t('autofix.k5d4d494e')}</div>}
|
||||||
|
|
||||||
|
{membersError && !membersLoading && <div className="text-center text-red-600 py-8 break-words">{membersError}</div>}
|
||||||
|
|
||||||
|
{users.length === 0 && !membersLoading && !membersError && (
|
||||||
|
<div className="text-center text-slate-500 italic py-8">{t('autofix.kcbc17bbd')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.length > 0 && !membersLoading && (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-slate-200">
|
||||||
|
<table className="min-w-[760px] w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold text-slate-700">{t('autofix.k5b2c4431')}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold text-slate-700">{t('autofix.kb1438ed0')}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold text-slate-700">{t('autofix.k7bed84a7')}</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold text-slate-700">{isCore ? t('autofix.k22a3f7c1') : t('autofix.k69adf332')}</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold text-slate-700" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 bg-white">
|
||||||
|
{users.map((poolUser) => (
|
||||||
|
<tr key={poolUser.id} className="hover:bg-slate-50 transition">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="h-7 w-7 rounded-full bg-slate-100 border border-slate-200 flex items-center justify-center text-xs font-bold text-slate-800 shrink-0">
|
||||||
|
{(poolUser.name?.[0] || '?').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-slate-900 break-words">{poolUser.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600 break-all">{poolUser.email}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600 whitespace-nowrap">
|
||||||
|
{new Date(poolUser.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||||
|
poolUser.share > 0
|
||||||
|
? 'bg-green-50 text-green-700 border border-green-200'
|
||||||
|
: 'bg-slate-50 text-slate-500 border border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
EUR {poolUser.share.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(poolUser.id)}
|
||||||
|
disabled={removingMemberId === poolUser.id}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-md border border-red-200 bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{removingMemberId === poolUser.id ? t('autofix.k18fd92a1') : t('autofix.k2ee90f41')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,189 @@
|
|||||||
|
import { MagnifyingGlassIcon, UsersIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { UserCandidate } from '../hooks/usePoolManageState'
|
||||||
|
|
||||||
|
type Translator = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
searchOpen: boolean
|
||||||
|
query: string
|
||||||
|
setQuery: (value: string) => void
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
hasSearched: boolean
|
||||||
|
candidates: UserCandidate[]
|
||||||
|
selectedCandidates: Set<string>
|
||||||
|
savingMembers: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSearch: () => Promise<void>
|
||||||
|
onClear: () => void
|
||||||
|
onToggleCandidate: (id: string) => void
|
||||||
|
onAddSingle: (candidate: UserCandidate) => Promise<void>
|
||||||
|
onAddSelected: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolSearchModal({
|
||||||
|
t,
|
||||||
|
searchOpen,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
hasSearched,
|
||||||
|
candidates,
|
||||||
|
selectedCandidates,
|
||||||
|
savingMembers,
|
||||||
|
onClose,
|
||||||
|
onSearch,
|
||||||
|
onClear,
|
||||||
|
onToggleCandidate,
|
||||||
|
onAddSingle,
|
||||||
|
onAddSelected,
|
||||||
|
}: Props) {
|
||||||
|
if (!searchOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
|
||||||
|
<div className="w-full max-w-3xl max-h-[90vh] rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
||||||
|
<div className="px-6 py-5 border-b border-slate-100 flex items-center justify-between gap-3">
|
||||||
|
<h4 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.ka6be28d2')}</h4>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-md text-slate-500 hover:bg-slate-100 hover:text-slate-700 transition"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
void onSearch()
|
||||||
|
}}
|
||||||
|
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-slate-100"
|
||||||
|
>
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<div className="relative">
|
||||||
|
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={t('autofix.kb35549bb')}
|
||||||
|
className="w-full rounded-md bg-slate-50 border border-slate-300 text-sm text-slate-900 placeholder-slate-400 pl-8 pr-3 py-2 focus:ring-2 focus:ring-slate-900 focus:border-transparent transition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 md:col-span-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || query.trim().length < 3}
|
||||||
|
className="flex-1 rounded-md bg-slate-900 hover:bg-slate-800 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
|
||||||
|
>
|
||||||
|
{loading ? t('autofix.kf5d7b213') : t('common.search')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.k76f12c8a')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="px-6 pt-1 pb-3 text-right text-xs text-slate-500">{t('autofix.ke4c4a858')}</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 overflow-y-auto min-h-0 flex-1">
|
||||||
|
{error && <div className="text-sm text-red-600 mb-3 break-words">{error}</div>}
|
||||||
|
|
||||||
|
{!error && query.trim().length < 3 && (
|
||||||
|
<div className="py-8 text-sm text-slate-500 text-center">{t('autofix.kb87eb38b')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && hasSearched && loading && candidates.length === 0 && (
|
||||||
|
<ul className="space-y-0 divide-y divide-slate-200 border border-slate-200 rounded-md bg-slate-50">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<li key={index} className="animate-pulse px-4 py-3">
|
||||||
|
<div className="h-3.5 w-36 bg-slate-200 rounded" />
|
||||||
|
<div className="mt-2 h-3 w-56 bg-slate-100 rounded" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && hasSearched && !loading && candidates.length === 0 && (
|
||||||
|
<div className="py-8 text-sm text-slate-500 text-center">{t('autofix.k54f49724')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && candidates.length > 0 && (
|
||||||
|
<ul className="divide-y divide-slate-200 border border-slate-200 rounded-lg bg-white">
|
||||||
|
{candidates.map((candidate) => (
|
||||||
|
<li key={candidate.id} className="px-4 py-3 flex flex-wrap items-center justify-between gap-3 hover:bg-slate-50 transition">
|
||||||
|
<label className="min-w-0 flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900"
|
||||||
|
checked={selectedCandidates.has(candidate.id)}
|
||||||
|
onChange={() => onToggleCandidate(candidate.id)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UsersIcon className="h-4 w-4 text-slate-900" />
|
||||||
|
<span className="text-sm font-medium text-slate-900 break-words">{candidate.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-slate-600 break-all">{candidate.email}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => void onAddSingle(candidate)}
|
||||||
|
className="shrink-0 inline-flex items-center rounded-md bg-slate-900 hover:bg-slate-800 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
|
||||||
|
>
|
||||||
|
{t('autofix.k8c011ed3')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && candidates.length > 0 && (
|
||||||
|
<div className="pointer-events-none relative">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/60">
|
||||||
|
<span className="h-5 w-5 rounded-full border-2 border-slate-900 border-b-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-3 border-t border-slate-100 flex flex-wrap items-center justify-between gap-3 bg-slate-50">
|
||||||
|
<div className="text-xs text-slate-600">
|
||||||
|
{selectedCandidates.size > 0
|
||||||
|
? t('autofix.k3ab09ef0').replace('{count}', String(selectedCandidates.size))
|
||||||
|
: t('autofix.k2042d9f2')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-sm rounded-md px-4 py-2 font-medium bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.k0f13bc22')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void onAddSelected()}
|
||||||
|
disabled={selectedCandidates.size === 0 || savingMembers}
|
||||||
|
className="text-sm rounded-md px-4 py-2 font-medium bg-slate-900 text-white hover:bg-slate-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{savingMembers ? t('autofix.k89bc3412') : t('autofix.k7e44aa19')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
343
src/app/admin/pool-management/manage/hooks/usePoolManageState.ts
Normal file
343
src/app/admin/pool-management/manage/hooks/usePoolManageState.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import useAuthStore from '../../../../store/authStore'
|
||||||
|
import { AdminAPI } from '../../../../utils/api'
|
||||||
|
import { useTranslation } from '../../../../i18n/useTranslation'
|
||||||
|
|
||||||
|
export type PoolUser = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
share: number
|
||||||
|
joinedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserCandidate = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePoolManageState() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const token = useAuthStore((state) => state.accessToken)
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
!!user &&
|
||||||
|
((user as any)?.role === 'admin' ||
|
||||||
|
(user as any)?.userType === 'admin' ||
|
||||||
|
(user as any)?.isAdmin === true ||
|
||||||
|
(user as any)?.roles?.includes?.('admin'))
|
||||||
|
|
||||||
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user === null) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (user && !isAdmin) {
|
||||||
|
router.replace('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAuthChecked(true)
|
||||||
|
}, [user, isAdmin, router])
|
||||||
|
|
||||||
|
const poolId = searchParams.get('id') ?? 'pool-unknown'
|
||||||
|
const poolName = searchParams.get('pool_name') ?? t('autofix.k78dc5a11')
|
||||||
|
const poolDescription = searchParams.get('description') ?? ''
|
||||||
|
const poolPrice = parseFloat(searchParams.get('price') ?? '0')
|
||||||
|
const poolType = (searchParams.get('pool_type') as 'coffee' | 'other') || 'other'
|
||||||
|
const poolIsActive = searchParams.get('is_active') === 'true'
|
||||||
|
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<PoolUser[]>([])
|
||||||
|
const [membersLoading, setMembersLoading] = useState(false)
|
||||||
|
const [membersError, setMembersError] = useState('')
|
||||||
|
|
||||||
|
const [totalAmount, setTotalAmount] = useState(0)
|
||||||
|
const [amountThisYear, setAmountThisYear] = useState(0)
|
||||||
|
const [amountThisMonth, setAmountThisMonth] = useState(0)
|
||||||
|
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [candidates, setCandidates] = useState<UserCandidate[]>([])
|
||||||
|
const [hasSearched, setHasSearched] = useState(false)
|
||||||
|
const [selectedCandidates, setSelectedCandidates] = useState<Set<string>>(new Set())
|
||||||
|
const [savingMembers, setSavingMembers] = useState(false)
|
||||||
|
|
||||||
|
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||||
|
const [removeError, setRemoveError] = useState('')
|
||||||
|
const [removeConfirm, setRemoveConfirm] = useState<{ userId: string; label: string } | null>(null)
|
||||||
|
|
||||||
|
const isCore = useMemo(() => poolName === 'Core', [poolName])
|
||||||
|
|
||||||
|
const fetchMembers = useCallback(async () => {
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
|
||||||
|
setMembersError('')
|
||||||
|
setMembersLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await AdminAPI.getPoolMembers(token, poolId)
|
||||||
|
const rows = Array.isArray(response?.members) ? response.members : []
|
||||||
|
|
||||||
|
const mapped: PoolUser[] = rows.map((row: any) => {
|
||||||
|
const name = row.company_name
|
||||||
|
? String(row.company_name)
|
||||||
|
: [row.first_name, row.last_name].filter(Boolean).join(' ').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
name: name || String(row.email || '').trim() || t('autofix.k8dca3321'),
|
||||||
|
email: String(row.email || '').trim(),
|
||||||
|
share: Number(row.share ?? 0),
|
||||||
|
joinedAt: row.joined_at || new Date().toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setUsers(mapped)
|
||||||
|
} catch (requestError: any) {
|
||||||
|
setMembersError(requestError?.message || t('autofix.k7021ad54'))
|
||||||
|
} finally {
|
||||||
|
setMembersLoading(false)
|
||||||
|
}
|
||||||
|
}, [token, poolId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchMembers()
|
||||||
|
}, [fetchMembers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
|
const response = await fetch(`${base}/api/admin/pools/${encodeURIComponent(poolId)}/stats`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}))
|
||||||
|
|
||||||
|
if (!cancelled && response.ok && body?.success) {
|
||||||
|
setTotalAmount(Number(body.data?.total_amount ?? 0))
|
||||||
|
setAmountThisYear(Number(body.data?.amount_this_year ?? 0))
|
||||||
|
setAmountThisMonth(Number(body.data?.amount_this_month ?? 0))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stats are non-critical for page interaction.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadStats()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [token, poolId])
|
||||||
|
|
||||||
|
const doSearch = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const normalizedQuery = query.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (normalizedQuery.length < 3) {
|
||||||
|
setHasSearched(false)
|
||||||
|
setCandidates([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setError(t('autofix.k53f7e9a1'))
|
||||||
|
setHasSearched(true)
|
||||||
|
setCandidates([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasSearched(true)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await AdminAPI.getUserList(token)
|
||||||
|
const list = Array.isArray(response?.users) ? response.users : []
|
||||||
|
const existingIds = new Set(users.map((poolUser) => String(poolUser.id)))
|
||||||
|
|
||||||
|
const mapped: UserCandidate[] = list
|
||||||
|
.filter((apiUser: any) => apiUser && apiUser.role !== 'admin' && apiUser.role !== 'super_admin')
|
||||||
|
.map((apiUser: any) => {
|
||||||
|
const name = apiUser.company_name
|
||||||
|
? String(apiUser.company_name)
|
||||||
|
: [apiUser.first_name, apiUser.last_name].filter(Boolean).join(' ').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(apiUser.id),
|
||||||
|
name: name || String(apiUser.email || '').trim() || t('autofix.k8dca3321'),
|
||||||
|
email: String(apiUser.email || '').trim(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((candidate: UserCandidate) => !existingIds.has(candidate.id))
|
||||||
|
.filter((candidate: UserCandidate) => `${candidate.name} ${candidate.email}`.toLowerCase().includes(normalizedQuery))
|
||||||
|
|
||||||
|
setCandidates(mapped)
|
||||||
|
} catch (requestError: any) {
|
||||||
|
setError(requestError?.message || t('autofix.k9c4d2ab3'))
|
||||||
|
setCandidates([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [query, token, users])
|
||||||
|
|
||||||
|
const openSearch = () => {
|
||||||
|
setSearchOpen(true)
|
||||||
|
setQuery('')
|
||||||
|
setCandidates([])
|
||||||
|
setHasSearched(false)
|
||||||
|
setError('')
|
||||||
|
setSelectedCandidates(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSearch = () => {
|
||||||
|
setSearchOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearchQuery = () => {
|
||||||
|
setQuery('')
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCandidate = (id: string) => {
|
||||||
|
setSelectedCandidates((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addUserFromModal = async (candidate: UserCandidate) => {
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
|
||||||
|
setSavingMembers(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AdminAPI.addPoolMembers(token, poolId, [candidate.id])
|
||||||
|
await fetchMembers()
|
||||||
|
closeSearch()
|
||||||
|
clearSearchQuery()
|
||||||
|
setCandidates([])
|
||||||
|
setHasSearched(false)
|
||||||
|
setSelectedCandidates(new Set())
|
||||||
|
} catch (requestError: any) {
|
||||||
|
setError(requestError?.message || t('autofix.k3f7ca220'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setSavingMembers(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSelectedUsers = async () => {
|
||||||
|
if (selectedCandidates.size === 0) return
|
||||||
|
|
||||||
|
const selectedList = candidates.filter((candidate) => selectedCandidates.has(candidate.id))
|
||||||
|
|
||||||
|
if (selectedList.length === 0 || !token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
|
||||||
|
setSavingMembers(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userIds = selectedList.map((candidate) => candidate.id)
|
||||||
|
await AdminAPI.addPoolMembers(token, poolId, userIds)
|
||||||
|
await fetchMembers()
|
||||||
|
closeSearch()
|
||||||
|
clearSearchQuery()
|
||||||
|
setCandidates([])
|
||||||
|
setHasSearched(false)
|
||||||
|
setSelectedCandidates(new Set())
|
||||||
|
} catch (requestError: any) {
|
||||||
|
setError(requestError?.message || t('autofix.k90b5f8d1'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setSavingMembers(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const askRemoveMember = (userId: string) => {
|
||||||
|
const poolUser = users.find((entry) => entry.id === userId)
|
||||||
|
const label = poolUser?.name || poolUser?.email || 'this user'
|
||||||
|
setRemoveConfirm({ userId, label })
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmRemoveMember = async () => {
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown' || !removeConfirm) return
|
||||||
|
|
||||||
|
const userId = removeConfirm.userId
|
||||||
|
setRemoveError('')
|
||||||
|
setRemovingMemberId(userId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AdminAPI.removePoolMembers(token, poolId, [userId])
|
||||||
|
await fetchMembers()
|
||||||
|
} catch (requestError: any) {
|
||||||
|
setRemoveError(requestError?.message || t('autofix.k296db6a0'))
|
||||||
|
} finally {
|
||||||
|
setRemovingMemberId(null)
|
||||||
|
setRemoveConfirm(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
router,
|
||||||
|
authChecked,
|
||||||
|
poolId,
|
||||||
|
poolName,
|
||||||
|
poolDescription,
|
||||||
|
poolPrice,
|
||||||
|
poolType,
|
||||||
|
poolIsActive,
|
||||||
|
poolCreatedAt,
|
||||||
|
isCore,
|
||||||
|
users,
|
||||||
|
membersLoading,
|
||||||
|
membersError,
|
||||||
|
totalAmount,
|
||||||
|
amountThisYear,
|
||||||
|
amountThisMonth,
|
||||||
|
searchOpen,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
candidates,
|
||||||
|
hasSearched,
|
||||||
|
selectedCandidates,
|
||||||
|
savingMembers,
|
||||||
|
removingMemberId,
|
||||||
|
removeError,
|
||||||
|
removeConfirm,
|
||||||
|
setRemoveConfirm,
|
||||||
|
openSearch,
|
||||||
|
closeSearch,
|
||||||
|
clearSearchQuery,
|
||||||
|
doSearch,
|
||||||
|
toggleCandidate,
|
||||||
|
addUserFromModal,
|
||||||
|
addSelectedUsers,
|
||||||
|
askRemoveMember,
|
||||||
|
confirmRemoveMember,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,602 +1,129 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { Suspense } from 'react' // CHANGED: add Suspense
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
import React, { Suspense } from 'react'
|
||||||
import Header from '../../../components/nav/Header'
|
import Header from '../../../components/nav/Header'
|
||||||
import Footer from '../../../components/Footer'
|
import Footer from '../../../components/Footer'
|
||||||
import { UsersIcon, PlusIcon, BanknotesIcon, CalendarDaysIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import useAuthStore from '../../../store/authStore'
|
|
||||||
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
||||||
import { AdminAPI } from '../../../utils/api'
|
|
||||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'
|
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'
|
||||||
|
import { usePoolManageState } from './hooks/usePoolManageState'
|
||||||
type PoolUser = {
|
import PoolManageHeader from './components/PoolManageHeader'
|
||||||
id: string
|
import PoolManageStats from './components/PoolManageStats'
|
||||||
name: string
|
import PoolMembersSection from './components/PoolMembersSection'
|
||||||
email: string
|
import PoolSearchModal from './components/PoolSearchModal'
|
||||||
share: number
|
|
||||||
joinedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function PoolManagePageInner() {
|
function PoolManagePageInner() {
|
||||||
const router = useRouter()
|
const { t } = useTranslation()
|
||||||
const searchParams = useSearchParams()
|
const {
|
||||||
const user = useAuthStore(s => s.user)
|
router,
|
||||||
const token = useAuthStore(s => s.accessToken)
|
authChecked,
|
||||||
const isAdmin =
|
poolId,
|
||||||
!!user &&
|
poolName,
|
||||||
(
|
poolDescription,
|
||||||
(user as any)?.role === 'admin' ||
|
poolPrice,
|
||||||
(user as any)?.userType === 'admin' ||
|
poolIsActive,
|
||||||
(user as any)?.isAdmin === true ||
|
poolCreatedAt,
|
||||||
((user as any)?.roles?.includes?.('admin'))
|
isCore,
|
||||||
)
|
users,
|
||||||
|
membersLoading,
|
||||||
|
membersError,
|
||||||
|
totalAmount,
|
||||||
|
amountThisYear,
|
||||||
|
amountThisMonth,
|
||||||
|
searchOpen,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
candidates,
|
||||||
|
hasSearched,
|
||||||
|
selectedCandidates,
|
||||||
|
savingMembers,
|
||||||
|
removingMemberId,
|
||||||
|
removeError,
|
||||||
|
removeConfirm,
|
||||||
|
setRemoveConfirm,
|
||||||
|
openSearch,
|
||||||
|
closeSearch,
|
||||||
|
clearSearchQuery,
|
||||||
|
doSearch,
|
||||||
|
toggleCandidate,
|
||||||
|
addUserFromModal,
|
||||||
|
addSelectedUsers,
|
||||||
|
askRemoveMember,
|
||||||
|
confirmRemoveMember,
|
||||||
|
} = usePoolManageState()
|
||||||
|
|
||||||
// Auth gate
|
|
||||||
const [authChecked, setAuthChecked] = React.useState(false)
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (user === null) {
|
|
||||||
router.replace('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (user && !isAdmin) {
|
|
||||||
router.replace('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAuthChecked(true)
|
|
||||||
}, [user, isAdmin, router])
|
|
||||||
|
|
||||||
// Read pool data from query params with fallbacks (hooks must be before any return)
|
|
||||||
const poolId = searchParams.get('id') ?? 'pool-unknown'
|
|
||||||
const poolName = searchParams.get('pool_name') ?? 'Unnamed Pool'
|
|
||||||
const poolDescription = searchParams.get('description') ?? ''
|
|
||||||
const poolPrice = parseFloat(searchParams.get('price') ?? '0')
|
|
||||||
const poolType = searchParams.get('pool_type') as 'coffee' | 'other' || 'other'
|
|
||||||
const poolIsActive = searchParams.get('is_active') === 'true'
|
|
||||||
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
|
|
||||||
|
|
||||||
// Members (no dummy data)
|
|
||||||
const [users, setUsers] = React.useState<PoolUser[]>([])
|
|
||||||
const [membersLoading, setMembersLoading] = React.useState(false)
|
|
||||||
const [membersError, setMembersError] = React.useState<string>('')
|
|
||||||
|
|
||||||
// Stats (no dummy data)
|
|
||||||
const [totalAmount, setTotalAmount] = React.useState<number>(0)
|
|
||||||
const [amountThisYear, setAmountThisYear] = React.useState<number>(0)
|
|
||||||
const [amountThisMonth, setAmountThisMonth] = React.useState<number>(0)
|
|
||||||
|
|
||||||
// Search modal state
|
|
||||||
const [searchOpen, setSearchOpen] = React.useState(false)
|
|
||||||
const [query, setQuery] = React.useState('')
|
|
||||||
const [loading, setLoading] = React.useState(false)
|
|
||||||
const [error, setError] = React.useState<string>('')
|
|
||||||
const [candidates, setCandidates] = React.useState<Array<{ id: string; name: string; email: string }>>([])
|
|
||||||
const [hasSearched, setHasSearched] = React.useState(false)
|
|
||||||
const [selectedCandidates, setSelectedCandidates] = React.useState<Set<string>>(new Set())
|
|
||||||
const [savingMembers, setSavingMembers] = React.useState(false)
|
|
||||||
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
|
|
||||||
const [removeError, setRemoveError] = React.useState<string>('')
|
|
||||||
const [removeConfirm, setRemoveConfirm] = React.useState<{ userId: string; label: string } | null>(null)
|
|
||||||
|
|
||||||
async function fetchMembers() {
|
|
||||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
||||||
setMembersError('')
|
|
||||||
setMembersLoading(true)
|
|
||||||
try {
|
|
||||||
const resp = await AdminAPI.getPoolMembers(token, poolId)
|
|
||||||
const rows = Array.isArray(resp?.members) ? resp.members : []
|
|
||||||
const mapped: PoolUser[] = rows.map((row: any) => {
|
|
||||||
const name = row.company_name
|
|
||||||
? String(row.company_name)
|
|
||||||
: [row.first_name, row.last_name].filter(Boolean).join(' ').trim()
|
|
||||||
return {
|
|
||||||
id: String(row.id),
|
|
||||||
name: name || String(row.email || '').trim() || 'Unnamed user',
|
|
||||||
email: String(row.email || '').trim(),
|
|
||||||
share: Number(row.share ?? 0),
|
|
||||||
joinedAt: row.joined_at || new Date().toISOString()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setUsers(mapped)
|
|
||||||
} catch (e: any) {
|
|
||||||
setMembersError(e?.message || 'Failed to load pool members.')
|
|
||||||
} finally {
|
|
||||||
setMembersLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
void fetchMembers()
|
|
||||||
}, [token, poolId])
|
|
||||||
|
|
||||||
// Fetch pool inflow stats
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
||||||
let cancelled = false
|
|
||||||
async function loadStats() {
|
|
||||||
try {
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
|
||||||
const res = await fetch(`${base}/api/admin/pools/${encodeURIComponent(poolId)}/stats`, {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const body = await res.json().catch(() => ({}))
|
|
||||||
if (!cancelled && res.ok && body?.success) {
|
|
||||||
setTotalAmount(Number(body.data?.total_amount ?? 0))
|
|
||||||
setAmountThisYear(Number(body.data?.amount_this_year ?? 0))
|
|
||||||
setAmountThisMonth(Number(body.data?.amount_this_month ?? 0))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore — stats are non-critical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void loadStats()
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [token, poolId])
|
|
||||||
|
|
||||||
// Early return AFTER all hooks are declared to keep consistent order
|
|
||||||
if (!authChecked) return null
|
if (!authChecked) return null
|
||||||
|
|
||||||
async function doSearch() {
|
|
||||||
setError('')
|
|
||||||
const q = query.trim().toLowerCase()
|
|
||||||
if (q.length < 3) {
|
|
||||||
setHasSearched(false)
|
|
||||||
setCandidates([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!token) {
|
|
||||||
setError('Authentication required.')
|
|
||||||
setHasSearched(true)
|
|
||||||
setCandidates([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasSearched(true)
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const resp = await AdminAPI.getUserList(token)
|
|
||||||
const list = Array.isArray(resp?.users) ? resp.users : []
|
|
||||||
|
|
||||||
const existingIds = new Set(users.map(u => String(u.id)))
|
|
||||||
|
|
||||||
const mapped: Array<{ id: string; name: string; email: string }> = list
|
|
||||||
.filter((u: any) => u && u.role !== 'admin' && u.role !== 'super_admin')
|
|
||||||
.map((u: any) => {
|
|
||||||
const name = u.company_name
|
|
||||||
? String(u.company_name)
|
|
||||||
: [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
|
||||||
return {
|
|
||||||
id: String(u.id),
|
|
||||||
name: name || String(u.email || '').trim() || 'Unnamed user',
|
|
||||||
email: String(u.email || '').trim()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((u: { id: string; name: string; email: string }) => !existingIds.has(u.id))
|
|
||||||
.filter((u: { id: string; name: string; email: string }) => {
|
|
||||||
const hay = `${u.name} ${u.email}`.toLowerCase()
|
|
||||||
return hay.includes(q)
|
|
||||||
})
|
|
||||||
|
|
||||||
setCandidates(mapped)
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message || 'Failed to search users.')
|
|
||||||
setCandidates([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addUserFromModal(u: { id: string; name: string; email: string }) {
|
|
||||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
||||||
setSavingMembers(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
await AdminAPI.addPoolMembers(token, poolId, [u.id])
|
|
||||||
await fetchMembers()
|
|
||||||
setSearchOpen(false)
|
|
||||||
setQuery('')
|
|
||||||
setCandidates([])
|
|
||||||
setHasSearched(false)
|
|
||||||
setSelectedCandidates(new Set())
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message || 'Failed to add user.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setSavingMembers(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCandidate(id: string) {
|
|
||||||
setSelectedCandidates(prev => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addSelectedUsers() {
|
|
||||||
if (selectedCandidates.size === 0) return
|
|
||||||
const selectedList = candidates.filter(c => selectedCandidates.has(c.id))
|
|
||||||
if (selectedList.length === 0) return
|
|
||||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
||||||
setSavingMembers(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const userIds = selectedList.map(u => u.id)
|
|
||||||
await AdminAPI.addPoolMembers(token, poolId, userIds)
|
|
||||||
await fetchMembers()
|
|
||||||
setSearchOpen(false)
|
|
||||||
setQuery('')
|
|
||||||
setCandidates([])
|
|
||||||
setHasSearched(false)
|
|
||||||
setSelectedCandidates(new Set())
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message || 'Failed to add users.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setSavingMembers(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeMember(userId: string) {
|
|
||||||
const user = users.find(u => u.id === userId)
|
|
||||||
const label = user?.name || user?.email || 'this user'
|
|
||||||
setRemoveConfirm({ userId, label })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmRemoveMember() {
|
|
||||||
if (!token || !poolId || poolId === 'pool-unknown' || !removeConfirm) return
|
|
||||||
const userId = removeConfirm.userId
|
|
||||||
setRemoveError('')
|
|
||||||
setRemovingMemberId(userId)
|
|
||||||
try {
|
|
||||||
await AdminAPI.removePoolMembers(token, poolId, [userId])
|
|
||||||
await fetchMembers()
|
|
||||||
} catch (e: any) {
|
|
||||||
setRemoveError(e?.message || 'Failed to remove user from pool.')
|
|
||||||
} finally {
|
|
||||||
setRemovingMemberId(null)
|
|
||||||
setRemoveConfirm(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCore = poolName === 'Core'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageTransitionEffect>
|
<PageTransitionEffect>
|
||||||
<div className={`min-h-screen flex flex-col ${isCore ? 'bg-gradient-to-tr from-amber-50 via-white to-amber-100' : 'bg-gradient-to-tr from-blue-50 via-white to-blue-100'}`}>
|
<div className={`min-h-screen flex flex-col ${isCore ? 'bg-gradient-to-tr from-amber-50 via-white to-amber-100' : 'bg-[radial-gradient(circle_at_top_left,_rgba(59,130,246,0.14),transparent_42%),radial-gradient(circle_at_bottom_right,_rgba(14,165,233,0.12),transparent_36%),linear-gradient(135deg,#eff6ff_0%,#ffffff_44%,#dbeafe_100%)]'}`}>
|
||||||
<Header />
|
<Header />
|
||||||
{/* main wrapper: avoid high z-index stacking */}
|
<main className="flex-1 py-8 px-4 sm:px-6 xl:px-10 relative z-0">
|
||||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
|
<div className="max-w-[1820px] mx-auto relative z-0">
|
||||||
<div className="max-w-7xl mx-auto relative z-0">
|
<PoolManageHeader
|
||||||
{/* Header (remove sticky/z-10) */}
|
t={t}
|
||||||
<header className={`backdrop-blur border-b py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-3 mb-8 relative z-0 ${
|
poolId={poolId}
|
||||||
isCore ? 'bg-gradient-to-r from-amber-50/90 to-white/90 border-amber-200' : 'bg-white/90 border-blue-100'
|
poolName={poolName}
|
||||||
}`}>
|
poolDescription={poolDescription}
|
||||||
{isCore && (
|
poolPrice={poolPrice}
|
||||||
<div className="inline-flex items-center gap-1.5 self-start rounded-full bg-amber-500 px-3 py-1 text-xs font-bold text-white uppercase tracking-wider shadow-sm mb-2">
|
poolIsActive={poolIsActive}
|
||||||
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>
|
poolCreatedAt={poolCreatedAt}
|
||||||
Core Pool — 1¢ per capsule per member
|
isCore={isCore}
|
||||||
</div>
|
onBack={() => router.push('/admin/pool-management')}
|
||||||
)}
|
/>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`h-10 w-10 rounded-lg border flex items-center justify-center ${
|
|
||||||
isCore ? 'bg-amber-100 border-amber-300' : 'bg-blue-50 border-blue-200'
|
|
||||||
}`}>
|
|
||||||
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-blue-900'}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className={`text-3xl font-extrabold tracking-tight ${isCore ? 'text-amber-900' : 'text-blue-900'}`}>{poolName}</h1>
|
|
||||||
<p className={`text-sm ${isCore ? 'text-amber-700' : 'text-blue-700'}`}>
|
|
||||||
{poolDescription ? poolDescription : 'Manage users and track pool funds'}
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-600 flex-wrap">
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${!poolIsActive ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
|
|
||||||
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!poolIsActive ? 'bg-gray-400' : 'bg-green-500'}`} />
|
|
||||||
{!poolIsActive ? 'Inactive' : 'Active'}
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Price/capsule (gross): € {Number(poolPrice || 0).toFixed(2)}{isCore ? ' × each member' : ''}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="text-gray-500">ID: {poolId}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Back to Pool Management */}
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/admin/pool-management')}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-white text-blue-900 border border-blue-200 px-4 py-2 text-sm font-medium hover:bg-blue-50 transition"
|
|
||||||
title="Back to Pool Management"
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Stats (now zero until backend wired) */}
|
<PoolManageStats
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8 relative z-0">
|
t={t}
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
totalAmount={totalAmount}
|
||||||
<div className="flex items-center gap-3">
|
amountThisYear={amountThisYear}
|
||||||
<div className="rounded-md bg-blue-900 p-2">
|
amountThisMonth={amountThisMonth}
|
||||||
<BanknotesIcon className="h-5 w-5 text-white" />
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Total in Pool</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">€ {totalAmount.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="rounded-md bg-amber-600 p-2">
|
|
||||||
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">This Year</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">€ {amountThisYear.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="rounded-md bg-green-600 p-2">
|
|
||||||
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">Current Month</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">€ {amountThisMonth.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unified Members card: add button + list */}
|
<PoolMembersSection
|
||||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
|
t={t}
|
||||||
<div className="flex items-center justify-between mb-4">
|
users={users}
|
||||||
<div className="flex items-center gap-3">
|
membersLoading={membersLoading}
|
||||||
<h2 className="text-lg font-semibold text-blue-900">Members</h2>
|
membersError={membersError}
|
||||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700">
|
removeError={removeError}
|
||||||
{users.length}
|
removingMemberId={removingMemberId}
|
||||||
</span>
|
isCore={isCore}
|
||||||
</div>
|
onOpenSearch={openSearch}
|
||||||
<button
|
onRemove={askRemoveMember}
|
||||||
onClick={() => { setSearchOpen(true); setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
|
/>
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-5 w-5" />
|
|
||||||
Add User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{removeError && (
|
|
||||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{removeError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{membersLoading && (
|
|
||||||
<div className="text-center text-gray-500 italic py-8">Loading members...</div>
|
|
||||||
)}
|
|
||||||
{membersError && !membersLoading && (
|
|
||||||
<div className="text-center text-red-600 py-8">{membersError}</div>
|
|
||||||
)}
|
|
||||||
{users.length === 0 && !membersLoading && !membersError && (
|
|
||||||
<div className="text-center text-gray-500 italic py-8">No users in this pool yet.</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{users.length > 0 && !membersLoading && (
|
|
||||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">Name</th>
|
|
||||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">Email</th>
|
|
||||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">Member Since</th>
|
|
||||||
<th className="px-4 py-3 text-right font-semibold text-gray-700">{isCore ? 'Total Earned' : 'Share'}</th>
|
|
||||||
<th className="px-4 py-3 text-right font-semibold text-gray-700" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100 bg-white">
|
|
||||||
{users.map(u => (
|
|
||||||
<tr key={u.id} className="hover:bg-gray-50 transition">
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-7 w-7 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-xs font-bold text-blue-800">
|
|
||||||
{(u.name?.[0] || '?').toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-gray-900">{u.name}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-gray-600">{u.email}</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-gray-600">
|
|
||||||
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
|
||||||
u.share > 0
|
|
||||||
? 'bg-green-50 text-green-700 border border-green-200'
|
|
||||||
: 'bg-gray-50 text-gray-500 border border-gray-200'
|
|
||||||
}`}>
|
|
||||||
€ {u.share.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => removeMember(u.id)}
|
|
||||||
disabled={removingMemberId === u.id}
|
|
||||||
className="px-3 py-1.5 text-xs font-medium rounded-md border border-red-200 bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{removingMemberId === u.id ? 'Removing…' : 'Remove'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
{/* Search Modal (keep above with high z) */}
|
<PoolSearchModal
|
||||||
{searchOpen && (
|
t={t}
|
||||||
<div className="fixed inset-0 z-50">
|
searchOpen={searchOpen}
|
||||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setSearchOpen(false)} />
|
query={query}
|
||||||
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
|
setQuery={setQuery}
|
||||||
<div className="w-full max-w-2xl max-h-[90vh] rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
loading={loading}
|
||||||
{/* Header */}
|
error={error}
|
||||||
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
hasSearched={hasSearched}
|
||||||
<h4 className="text-lg font-semibold text-blue-900">Add user to pool</h4>
|
candidates={candidates}
|
||||||
<button
|
selectedCandidates={selectedCandidates}
|
||||||
onClick={() => setSearchOpen(false)}
|
savingMembers={savingMembers}
|
||||||
className="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition"
|
onClose={closeSearch}
|
||||||
aria-label="Close"
|
onSearch={doSearch}
|
||||||
>
|
onClear={clearSearchQuery}
|
||||||
<XMarkIcon className="h-5 w-5" />
|
onToggleCandidate={toggleCandidate}
|
||||||
</button>
|
onAddSingle={addUserFromModal}
|
||||||
</div>
|
onAddSelected={addSelectedUsers}
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<form
|
|
||||||
onSubmit={e => { e.preventDefault(); void doSearch(); }}
|
|
||||||
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-gray-100"
|
|
||||||
>
|
|
||||||
<div className="md:col-span-3">
|
|
||||||
<div className="relative">
|
|
||||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={e => setQuery(e.target.value)}
|
|
||||||
placeholder="Search name or email…"
|
|
||||||
className="w-full rounded-md bg-gray-50 border border-gray-300 text-sm text-gray-900 placeholder-gray-400 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent transition"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 md:col-span-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || query.trim().length < 3}
|
|
||||||
className="flex-1 rounded-md bg-blue-900 hover:bg-blue-800 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
|
|
||||||
>
|
|
||||||
{loading ? 'Searching…' : 'Search'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setQuery(''); setError(''); }}
|
|
||||||
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div className="px-6 pt-1 pb-3 text-right text-xs text-gray-500">
|
|
||||||
Min. 3 characters
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
<div className="px-6 py-4 overflow-y-auto min-h-0 flex-1">
|
|
||||||
{error && <div className="text-sm text-red-600 mb-3">{error}</div>}
|
|
||||||
{!error && query.trim().length < 3 && (
|
|
||||||
<div className="py-8 text-sm text-gray-500 text-center">
|
|
||||||
Enter at least 3 characters and click Search.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!error && hasSearched && loading && candidates.length === 0 && (
|
|
||||||
<ul className="space-y-0 divide-y divide-gray-200 border border-gray-200 rounded-md bg-gray-50">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<li key={i} className="animate-pulse px-4 py-3">
|
|
||||||
<div className="h-3.5 w-36 bg-gray-200 rounded" />
|
|
||||||
<div className="mt-2 h-3 w-56 bg-gray-100 rounded" />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{!error && hasSearched && !loading && candidates.length === 0 && (
|
|
||||||
<div className="py-8 text-sm text-gray-500 text-center">
|
|
||||||
No users match your search.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!error && candidates.length > 0 && (
|
|
||||||
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
|
|
||||||
{candidates.map(u => (
|
|
||||||
<li key={u.id} className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-gray-50 transition">
|
|
||||||
<label className="min-w-0 flex items-start gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900"
|
|
||||||
checked={selectedCandidates.has(u.id)}
|
|
||||||
onChange={() => toggleCandidate(u.id)}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<UsersIcon className="h-4 w-4 text-blue-900" />
|
|
||||||
<span className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{u.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-[11px] text-gray-600 break-all">{u.email}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={() => addUserFromModal(u)}
|
|
||||||
className="shrink-0 inline-flex items-center rounded-md bg-blue-900 hover:bg-blue-800 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{loading && candidates.length > 0 && (
|
|
||||||
<div className="pointer-events-none relative">
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60">
|
|
||||||
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="px-6 py-3 border-t border-gray-100 flex items-center justify-between bg-gray-50">
|
|
||||||
<div className="text-xs text-gray-600">
|
|
||||||
{selectedCandidates.size > 0 ? `${selectedCandidates.size} selected` : 'No users selected'}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchOpen(false)}
|
|
||||||
className="text-sm rounded-md px-4 py-2 font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 transition"
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={addSelectedUsers}
|
|
||||||
disabled={selectedCandidates.size === 0 || savingMembers}
|
|
||||||
className="text-sm rounded-md px-4 py-2 font-medium bg-blue-900 text-white hover:bg-blue-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{savingMembers ? 'Adding…' : 'Add Selected'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ConfirmActionModal
|
<ConfirmActionModal
|
||||||
open={Boolean(removeConfirm)}
|
open={Boolean(removeConfirm)}
|
||||||
pending={Boolean(removingMemberId)}
|
pending={Boolean(removingMemberId)}
|
||||||
intent="danger"
|
intent="danger"
|
||||||
title="Remove member from pool?"
|
title={t('autofix.k959fb1a6')}
|
||||||
description={`This will remove ${removeConfirm?.label || 'this user'} from the pool.`}
|
description={t('autofix.k7c40d832').replace('{label}', removeConfirm?.label || t('autofix.k74122df0'))}
|
||||||
confirmText="Remove"
|
confirmText={t('autofix.k2ee90f41')}
|
||||||
onClose={() => { if (!removingMemberId) setRemoveConfirm(null) }}
|
onClose={() => { if (!removingMemberId) setRemoveConfirm(null) }}
|
||||||
onConfirm={confirmRemoveMember}
|
onConfirm={confirmRemoveMember}
|
||||||
/>
|
/>
|
||||||
@ -605,19 +132,22 @@ function PoolManagePageInner() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CHANGED: Suspense wrapper required for useSearchParams() during prerender
|
function PoolManagePageFallback() {
|
||||||
export default function PoolManagePage() {
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#0F172A] mx-auto mb-3" />
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#0F172A] mx-auto mb-3" />
|
||||||
<p className="text-[#4A4A4A]">Loading...</p>
|
<p className="text-[#4A4A4A]">{t('autofix.k79d12c2e')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
)
|
||||||
>
|
}
|
||||||
|
|
||||||
|
export default function PoolManagePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<PoolManagePageFallback />}>
|
||||||
<PoolManagePageInner />
|
<PoolManagePageInner />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,98 +1,41 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Header from '../../components/nav/Header'
|
import Header from '../../components/nav/Header'
|
||||||
import Footer from '../../components/Footer'
|
import Footer from '../../components/Footer'
|
||||||
import { UsersIcon } from '@heroicons/react/24/outline'
|
|
||||||
import { useAdminPools } from './hooks/getlist'
|
import { useAdminPools } from './hooks/getlist'
|
||||||
import useAuthStore from '../../store/authStore'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { setPoolInactive, setPoolActive } from './hooks/poolStatus'
|
|
||||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||||
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||||
|
import { usePoolManagementPage } from './hooks/usePoolManagementPage'
|
||||||
type Pool = {
|
import PoolManagementHeader from './components/PoolManagementHeader'
|
||||||
id: string
|
import PoolManagementGrid from './components/PoolManagementGrid'
|
||||||
pool_name: string
|
|
||||||
description?: string
|
|
||||||
price?: number
|
|
||||||
pool_type?: 'coffee' | 'other'
|
|
||||||
is_active?: boolean
|
|
||||||
membersCount: number
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PoolManagementPage() {
|
export default function PoolManagementPage() {
|
||||||
const router = useRouter()
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [archiveError, setArchiveError] = React.useState<string>('')
|
|
||||||
const [poolStatusConfirm, setPoolStatusConfirm] = React.useState<{ poolId: string; action: 'archive' | 'activate' } | null>(null)
|
|
||||||
const [poolStatusPending, setPoolStatusPending] = React.useState(false)
|
|
||||||
|
|
||||||
// Replace local fetch with hook
|
|
||||||
const { pools: initialPools, loading, error, refresh } = useAdminPools()
|
const { pools: initialPools, loading, error, refresh } = useAdminPools()
|
||||||
const [pools, setPools] = React.useState<Pool[]>([])
|
const {
|
||||||
const [showInactive, setShowInactive] = React.useState(false)
|
router,
|
||||||
|
authChecked,
|
||||||
React.useEffect(() => {
|
archiveError,
|
||||||
if (!loading && !error) {
|
poolStatusConfirm,
|
||||||
setPools(initialPools)
|
poolStatusPending,
|
||||||
}
|
pools,
|
||||||
}, [initialPools, loading, error])
|
showInactive,
|
||||||
|
setShowInactive,
|
||||||
const filteredPools = pools.filter(p => showInactive ? !p.is_active : p.is_active)
|
filteredPools,
|
||||||
|
requestArchive,
|
||||||
async function handleArchive(poolId: string) {
|
requestActivate,
|
||||||
setPoolStatusConfirm({ poolId, action: 'archive' })
|
closePoolStatusConfirm,
|
||||||
}
|
confirmPoolStatusChange,
|
||||||
|
} = usePoolManagementPage({
|
||||||
async function handleSetActive(poolId: string) {
|
initialPools,
|
||||||
setPoolStatusConfirm({ poolId, action: 'activate' })
|
loading,
|
||||||
}
|
error,
|
||||||
|
refresh,
|
||||||
async function confirmPoolStatusChange() {
|
})
|
||||||
if (!poolStatusConfirm) return
|
|
||||||
const { poolId, action } = poolStatusConfirm
|
|
||||||
setPoolStatusPending(true)
|
|
||||||
setArchiveError('')
|
|
||||||
try {
|
|
||||||
const res = action === 'archive' ? await setPoolInactive(poolId) : await setPoolActive(poolId)
|
|
||||||
if (res.ok) {
|
|
||||||
await refresh?.()
|
|
||||||
} else {
|
|
||||||
setArchiveError(res.message || (action === 'archive' ? 'Failed to deactivate pool.' : 'Failed to activate pool.'))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setPoolStatusPending(false)
|
|
||||||
setPoolStatusConfirm(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = useAuthStore(s => s.user)
|
|
||||||
const isAdmin =
|
|
||||||
!!user &&
|
|
||||||
(
|
|
||||||
(user as any)?.role === 'admin' ||
|
|
||||||
(user as any)?.userType === 'admin' ||
|
|
||||||
(user as any)?.isAdmin === true ||
|
|
||||||
((user as any)?.roles?.includes?.('admin'))
|
|
||||||
)
|
|
||||||
|
|
||||||
// NEW: block rendering until we decide access
|
|
||||||
const [authChecked, setAuthChecked] = React.useState(false)
|
|
||||||
React.useEffect(() => {
|
|
||||||
// When user is null -> unauthenticated; undefined means not loaded yet (store default may be null in this app).
|
|
||||||
if (user === null) {
|
|
||||||
router.replace('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (user && !isAdmin) {
|
|
||||||
router.replace('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// user exists and is admin
|
|
||||||
setAuthChecked(true)
|
|
||||||
}, [user, isAdmin, router])
|
|
||||||
|
|
||||||
// Early return: render nothing until authorized, prevents any flash
|
// Early return: render nothing until authorized, prevents any flash
|
||||||
if (!authChecked) return null
|
if (!authChecked) return null
|
||||||
@ -100,113 +43,26 @@ export default function PoolManagementPage() {
|
|||||||
// Remove Access Denied overlay; render normal content
|
// Remove Access Denied overlay; render normal content
|
||||||
return (
|
return (
|
||||||
<PageTransitionEffect>
|
<PageTransitionEffect>
|
||||||
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
|
<div className="min-h-screen flex flex-col bg-[radial-gradient(circle_at_top_left,_rgba(59,130,246,0.14),transparent_42%),radial-gradient(circle_at_bottom_right,_rgba(14,165,233,0.12),transparent_36%),linear-gradient(135deg,#eff6ff_0%,#ffffff_44%,#dbeafe_100%)]">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
|
<main className="flex-1 py-8 px-4 sm:px-6 xl:px-10 relative z-0">
|
||||||
<div className="max-w-7xl mx-auto relative z-0">
|
<div className="max-w-[1820px] mx-auto relative z-0">
|
||||||
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8 relative z-0">
|
<PoolManagementHeader
|
||||||
<div className="flex items-center justify-between">
|
t={t}
|
||||||
<div>
|
showInactive={showInactive}
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Pool Management</h1>
|
onShowActive={() => setShowInactive(false)}
|
||||||
<p className="text-lg text-blue-700 mt-2">Manage system pools and members.</p>
|
onShowInactive={() => setShowInactive(true)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-gray-600">Show:</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowInactive(false)}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition ${!showInactive ? 'bg-blue-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
|
||||||
>
|
|
||||||
Active Pools
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowInactive(true)}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition ${showInactive ? 'bg-blue-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
|
||||||
>
|
|
||||||
Inactive Pools
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Pools List card */}
|
<PoolManagementGrid
|
||||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
|
t={t}
|
||||||
<div className="flex items-center justify-between mb-4">
|
pools={pools}
|
||||||
<h2 className="text-lg font-semibold text-blue-900">Existing Pools</h2>
|
filteredPools={filteredPools}
|
||||||
<span className="text-sm text-gray-600">{pools.length} total</span>
|
loading={loading}
|
||||||
</div>
|
error={error}
|
||||||
|
archiveError={archiveError}
|
||||||
{/* Show archive errors */}
|
showInactive={showInactive}
|
||||||
{archiveError && (
|
onManage={(pool) => {
|
||||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{archiveError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{loading ? (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<div key={i} className="rounded-2xl bg-white border border-gray-100 shadow p-5">
|
|
||||||
<div className="animate-pulse space-y-3">
|
|
||||||
<div className="h-5 w-1/2 bg-gray-200 rounded" />
|
|
||||||
<div className="h-4 w-3/4 bg-gray-200 rounded" />
|
|
||||||
<div className="h-4 w-2/3 bg-gray-100 rounded" />
|
|
||||||
<div className="h-8 w-full bg-gray-100 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{filteredPools.map(pool => {
|
|
||||||
const isCore = pool.pool_name === 'Core'
|
|
||||||
return (
|
|
||||||
<article key={pool.id} className={`rounded-2xl border shadow p-5 flex flex-col relative z-0 ${
|
|
||||||
isCore
|
|
||||||
? 'bg-gradient-to-br from-amber-50 via-white to-amber-50 border-amber-300 ring-1 ring-amber-200'
|
|
||||||
: 'bg-white border-gray-100'
|
|
||||||
}`}>
|
|
||||||
{isCore && (
|
|
||||||
<div className="absolute -top-2.5 left-4 inline-flex items-center gap-1 rounded-full bg-amber-500 px-2.5 py-0.5 text-[10px] font-bold text-white uppercase tracking-wider shadow-sm">
|
|
||||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>
|
|
||||||
Core Pool
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`h-9 w-9 rounded-lg border flex items-center justify-center ${
|
|
||||||
isCore ? 'bg-amber-100 border-amber-300' : 'bg-blue-50 border-blue-200'
|
|
||||||
}`}>
|
|
||||||
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-blue-900'}`} />
|
|
||||||
</div>
|
|
||||||
<h3 className={`text-lg font-semibold ${isCore ? 'text-amber-900' : 'text-blue-900'}`}>{pool.pool_name}</h3>
|
|
||||||
</div>
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${!pool.is_active ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
|
|
||||||
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!pool.is_active ? 'bg-gray-400' : 'bg-green-500'}`} />
|
|
||||||
{!pool.is_active ? 'Inactive' : 'Active'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-gray-700">{pool.description || '-'}</p>
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-gray-600">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Members</span>
|
|
||||||
<div className="font-medium text-gray-900">{pool.membersCount}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Created</span>
|
|
||||||
<div className="font-medium text-gray-900">
|
|
||||||
{new Date(pool.createdAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
|
|
||||||
onClick={() => {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
id: String(pool.id),
|
id: String(pool.id),
|
||||||
pool_name: pool.pool_name ?? '',
|
pool_name: pool.pool_name ?? '',
|
||||||
@ -218,38 +74,9 @@ export default function PoolManagementPage() {
|
|||||||
})
|
})
|
||||||
router.push(`/admin/pool-management/manage?${params.toString()}`)
|
router.push(`/admin/pool-management/manage?${params.toString()}`)
|
||||||
}}
|
}}
|
||||||
>
|
onArchive={requestArchive}
|
||||||
Manage
|
onActivate={requestActivate}
|
||||||
</button>
|
/>
|
||||||
{!pool.is_active ? (
|
|
||||||
<button
|
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-green-100 text-green-800 hover:bg-green-200 transition"
|
|
||||||
onClick={() => handleSetActive(pool.id)}
|
|
||||||
title="Activate this pool"
|
|
||||||
>
|
|
||||||
Set Active
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-amber-100 text-amber-800 hover:bg-amber-200 transition"
|
|
||||||
onClick={() => handleArchive(pool.id)}
|
|
||||||
title="Archive this pool"
|
|
||||||
>
|
|
||||||
Archive
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{filteredPools.length === 0 && !loading && !error && (
|
|
||||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
|
||||||
{showInactive ? 'No inactive pools found.' : 'No active pools found.'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ -257,14 +84,14 @@ export default function PoolManagementPage() {
|
|||||||
open={Boolean(poolStatusConfirm)}
|
open={Boolean(poolStatusConfirm)}
|
||||||
pending={poolStatusPending}
|
pending={poolStatusPending}
|
||||||
intent={poolStatusConfirm?.action === 'archive' ? 'danger' : 'default'}
|
intent={poolStatusConfirm?.action === 'archive' ? 'danger' : 'default'}
|
||||||
title={poolStatusConfirm?.action === 'archive' ? 'Archive pool?' : 'Activate pool?'}
|
title={poolStatusConfirm?.action === 'archive' ? t('autofix.k0b84e6aa') : t('autofix.k9ad214be')}
|
||||||
description={
|
description={
|
||||||
poolStatusConfirm?.action === 'archive'
|
poolStatusConfirm?.action === 'archive'
|
||||||
? 'Users will no longer be able to join or use this pool while archived.'
|
? t('autofix.k3fe81c2a')
|
||||||
: 'This pool will be active again and available for use.'
|
: t('autofix.k1d6c33f1')
|
||||||
}
|
}
|
||||||
confirmText={poolStatusConfirm?.action === 'archive' ? 'Archive' : 'Set Active'}
|
confirmText={poolStatusConfirm?.action === 'archive' ? t('autofix.kf3b0c221') : t('autofix.k8fa13d9b')}
|
||||||
onClose={() => { if (!poolStatusPending) setPoolStatusConfirm(null) }}
|
onClose={closePoolStatusConfirm}
|
||||||
onConfirm={confirmPoolStatusChange}
|
onConfirm={confirmPoolStatusChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
27
src/app/admin/pool-management/utils/poolDescriptionKey.ts
Normal file
27
src/app/admin/pool-management/utils/poolDescriptionKey.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
export function resolvePoolDescriptionKey(
|
||||||
|
poolName?: string,
|
||||||
|
poolType?: 'coffee' | 'other',
|
||||||
|
rawDescription?: string
|
||||||
|
): string {
|
||||||
|
const raw = (rawDescription || '').trim()
|
||||||
|
|
||||||
|
// Keep existing translation keys untouched.
|
||||||
|
if (raw.includes('.') && /^[A-Za-z0-9_.-]+$/.test(raw)) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = (poolName || '').trim().toLowerCase()
|
||||||
|
if (normalizedName === 'core') {
|
||||||
|
return 'autofix.kcf73e90d'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (poolType === 'coffee') {
|
||||||
|
return 'autofix.k20f6ac90'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (poolType === 'other') {
|
||||||
|
return 'autofix.k4bc91d55'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'autofix.kf0c9a38d'
|
||||||
|
}
|
||||||
14
src/app/admin/pool-management/utils/translateMaybeKey.ts
Normal file
14
src/app/admin/pool-management/utils/translateMaybeKey.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
export function translateMaybeKey(t: Translator, value?: string, fallback = ''): string {
|
||||||
|
if (!value) return fallback
|
||||||
|
|
||||||
|
const candidate = value.trim()
|
||||||
|
if (!candidate) return fallback
|
||||||
|
|
||||||
|
const looksLikeKey = candidate.includes('.') && /^[A-Za-z0-9_.-]+$/.test(candidate)
|
||||||
|
if (!looksLikeKey) return value
|
||||||
|
|
||||||
|
const translated = t(candidate)
|
||||||
|
return translated && translated !== candidate ? translated : value
|
||||||
|
}
|
||||||
@ -1,4 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import Cropper from 'react-easy-crop'
|
import Cropper from 'react-easy-crop'
|
||||||
import { Point, Area } from 'react-easy-crop'
|
import { Point, Area } from 'react-easy-crop'
|
||||||
@ -11,6 +14,7 @@ interface ImageCropModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageCropModal({ isOpen, imageSrc, onClose, onCropComplete }: ImageCropModalProps) {
|
export default function ImageCropModal({ isOpen, imageSrc, onClose, onCropComplete }: ImageCropModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
||||||
@ -70,7 +74,7 @@ export default function ImageCropModal({ isOpen, imageSrc, onClose, onCropComple
|
|||||||
<div className="relative w-full max-w-4xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
|
<div className="relative w-full max-w-4xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-white">
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-white">
|
||||||
<h2 className="text-xl font-semibold text-blue-900">Crop & Adjust Image</h2>
|
<h2 className="text-xl font-semibold text-blue-900">{t('autofix.k8f528877')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-500 hover:text-gray-700 transition"
|
className="text-gray-500 hover:text-gray-700 transition"
|
||||||
@ -120,9 +124,7 @@ export default function ImageCropModal({ isOpen, imageSrc, onClose, onCropComple
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
||||||
>
|
>{t('autofix.kef1656df')}</button>
|
||||||
Apply Crop
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import ImageCropModal from '../components/ImageCropModal';
|
import ImageCropModal from '../components/ImageCropModal';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
|
||||||
export default function CreateSubscriptionPage() {
|
export default function CreateSubscriptionPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { createProduct } = useCoffeeManagement();
|
const { createProduct } = useCoffeeManagement();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -24,6 +27,9 @@ export default function CreateSubscriptionPage() {
|
|||||||
const [showCropModal, setShowCropModal] = useState(false);
|
const [showCropModal, setShowCropModal] = useState(false);
|
||||||
const [currency, setCurrency] = useState('EUR');
|
const [currency, setCurrency] = useState('EUR');
|
||||||
const [isFeatured, setIsFeatured] = useState(false);
|
const [isFeatured, setIsFeatured] = useState(false);
|
||||||
|
// Gallery images (multi-upload, no crop)
|
||||||
|
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
|
||||||
|
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
|
||||||
// Fixed billing defaults (locked: month / 1)
|
// Fixed billing defaults (locked: month / 1)
|
||||||
const billingInterval: 'month' = 'month';
|
const billingInterval: 'month' = 'month';
|
||||||
const intervalCount: number = 1;
|
const intervalCount: number = 1;
|
||||||
@ -39,7 +45,8 @@ export default function CreateSubscriptionPage() {
|
|||||||
currency,
|
currency,
|
||||||
is_featured: isFeatured,
|
is_featured: isFeatured,
|
||||||
state: state === 'available',
|
state: state === 'available',
|
||||||
pictureFile
|
pictureFile,
|
||||||
|
pictureFiles: galleryFiles.length ? galleryFiles : undefined,
|
||||||
});
|
});
|
||||||
router.push('/admin/subscriptions');
|
router.push('/admin/subscriptions');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -52,8 +59,9 @@ export default function CreateSubscriptionPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
|
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
|
||||||
|
galleryPreviews.forEach(u => URL.revokeObjectURL(u));
|
||||||
};
|
};
|
||||||
}, []);
|
}, [previewUrl, originalImageSrc, galleryPreviews]);
|
||||||
|
|
||||||
function handleSelectFile(file?: File) {
|
function handleSelectFile(file?: File) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@ -68,6 +76,7 @@ export default function CreateSubscriptionPage() {
|
|||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
|
||||||
// Create object URL for cropping
|
// Create object URL for cropping
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
setOriginalImageSrc(url);
|
setOriginalImageSrc(url);
|
||||||
@ -80,38 +89,82 @@ export default function CreateSubscriptionPage() {
|
|||||||
setPictureFile(croppedFile);
|
setPictureFile(croppedFile);
|
||||||
|
|
||||||
// Create preview URL
|
// Create preview URL
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
const url = URL.createObjectURL(croppedBlob);
|
const url = URL.createObjectURL(croppedBlob);
|
||||||
setPreviewUrl(url);
|
setPreviewUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAddGalleryFiles(files: FileList | null) {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
const newFiles: File[] = [];
|
||||||
|
const newPreviews: string[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
if (!allowed.includes(file.type)) {
|
||||||
|
setError(`"${file.name}" is not a valid image type (JPG, PNG, WebP only).`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
setError(`"${file.name}" exceeds the 10MB limit.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newFiles.push(file);
|
||||||
|
newPreviews.push(URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
setGalleryFiles(prev => [...prev, ...newFiles]);
|
||||||
|
setGalleryPreviews(prev => [...prev, ...newPreviews]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveGalleryImage(index: number) {
|
||||||
|
setGalleryPreviews(prev => {
|
||||||
|
URL.revokeObjectURL(prev[index]);
|
||||||
|
return prev.filter((_, i) => i !== index);
|
||||||
|
});
|
||||||
|
setGalleryFiles(prev => prev.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetThumbnailFromGallery(index: number) {
|
||||||
|
const source = galleryFiles[index];
|
||||||
|
if (!source) return;
|
||||||
|
setError(null);
|
||||||
|
const thumbFile = new File([source], source.name, { type: source.type });
|
||||||
|
setPictureFile(thumbFile);
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
const thumbPreview = URL.createObjectURL(source);
|
||||||
|
setPreviewUrl(thumbPreview);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
|
||||||
{/* Header */}
|
{/* Header card */}
|
||||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Create Coffee</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900">{t('autofix.kaa30f0cd')}</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">Add a new coffee.</p>
|
<p className="mt-1 text-sm text-slate-500">{t('autofix.kf72d41db')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/subscriptions"
|
<Link
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
href="/admin/subscriptions"
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
Back to list
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
{t('autofix.kd8a5ad17')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg">
|
{/* Form card */}
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<form onSubmit={onCreate} className="space-y-8">
|
<form onSubmit={onCreate} className="space-y-8">
|
||||||
{/* Picture Upload moved to top */}
|
|
||||||
|
{/* Thumbnail */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Picture</label>
|
<label className="block text-sm font-semibold text-slate-700 mb-1">Thumbnail</label>
|
||||||
<p className="text-xs text-gray-600 mb-3">Upload an image and crop it to fit the coffee thumbnail (16:9 aspect ratio, 144px height)</p>
|
<p className="text-xs text-slate-500 mb-3">Single image used as the card thumbnail. You can crop it after selecting (16:9, sent as <code className="rounded bg-slate-100 px-1 py-0.5 text-slate-600">picture</code>).</p>
|
||||||
<div
|
<div
|
||||||
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
|
className="relative flex justify-center items-center rounded-2xl border-2 border-dashed border-slate-200 bg-slate-50 cursor-pointer overflow-hidden transition hover:border-slate-400 hover:bg-slate-100"
|
||||||
style={{ minHeight: '400px' }}
|
style={{ minHeight: '400px' }}
|
||||||
onClick={() => document.getElementById('file-upload')?.click()}
|
onClick={() => document.getElementById('file-upload')?.click()}
|
||||||
onDragOver={e => e.preventDefault()}
|
onDragOver={e => e.preventDefault()}
|
||||||
@ -122,43 +175,32 @@ export default function CreateSubscriptionPage() {
|
|||||||
>
|
>
|
||||||
{!previewUrl && (
|
{!previewUrl && (
|
||||||
<div className="text-center w-full px-6 py-10">
|
<div className="text-center w-full px-6 py-10">
|
||||||
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-blue-400" />
|
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-slate-300" />
|
||||||
<div className="mt-4 text-base font-medium text-blue-700">
|
<div className="mt-4 text-base font-medium text-slate-600">
|
||||||
<span>Click or drag and drop an image here</span>
|
<span>{t('autofix.k6ee0a1b6')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p>
|
<p className="text-sm text-slate-500 mt-2">{t('autofix.k80ac9651')}</p>
|
||||||
<p className="text-xs text-gray-500 mt-2">You'll be able to crop and adjust the image after uploading</p>
|
<p className="text-xs text-slate-400 mt-2">{t('autofix.k41ab9eb6')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-gray-100 p-6">
|
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-slate-100 p-6">
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
className="max-h-[380px] max-w-full object-contain rounded-lg shadow-lg"
|
className="max-h-[380px] max-w-full object-contain rounded-xl shadow-lg"
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-4 right-4 flex gap-2">
|
<div className="absolute top-4 right-4 flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={e => {
|
onClick={e => { e.stopPropagation(); setShowCropModal(true); }}
|
||||||
e.stopPropagation();
|
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-semibold text-slate-800 shadow hover:bg-white transition"
|
||||||
setShowCropModal(true);
|
>{t('autofix.k73d1d7d7')}</button>
|
||||||
}}
|
|
||||||
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-blue-900 shadow hover:bg-white transition"
|
|
||||||
>
|
|
||||||
Edit Crop
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={e => {
|
onClick={e => { e.stopPropagation(); setPictureFile(undefined); setPreviewUrl(null); }}
|
||||||
e.stopPropagation();
|
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-semibold text-rose-600 shadow hover:bg-white transition"
|
||||||
setPictureFile(undefined);
|
>Remove</button>
|
||||||
setPreviewUrl(null);
|
|
||||||
}}
|
|
||||||
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-red-600 shadow hover:bg-white transition"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -173,85 +215,158 @@ export default function CreateSubscriptionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title moved above description */}
|
{/* Gallery Images */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="title" className="block text-sm font-medium text-blue-900">Title</label>
|
<label className="block text-sm font-semibold text-slate-700 mb-1">{t('autofix.ka219f1d9')}</label>
|
||||||
|
<p className="text-xs text-slate-500 mb-3">
|
||||||
|
Upload additional product images (JPG, PNG, WebP · max 10 MB each). These are sent as <code className="rounded bg-slate-100 px-1 py-0.5 text-slate-600">pictures</code>{t('autofix.k2992fa62')}<code className="rounded bg-slate-100 px-1 py-0.5 text-slate-600">pictureUrls</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Gallery grid */}
|
||||||
|
{galleryPreviews.length > 0 && (
|
||||||
|
<div className="mb-3 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
|
||||||
|
{galleryPreviews.map((url, i) => (
|
||||||
|
<div key={i} className="relative group rounded-xl overflow-hidden border border-slate-200 bg-slate-50 aspect-video">
|
||||||
|
<img src={url} alt={`Gallery ${i + 1}`} className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSetThumbnailFromGallery(i)}
|
||||||
|
className="absolute top-1 right-1 rounded-md bg-white/95 px-2 py-1 text-[10px] font-semibold text-slate-700 shadow hover:bg-white transition"
|
||||||
|
>{t('autofix.k6c1bb40b')}</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveGalleryImage(i)}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition"
|
||||||
|
aria-label="Remove image"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="absolute bottom-1 left-1 rounded bg-black/50 px-1.5 py-0.5 text-[10px] text-white font-medium">#{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add images button */}
|
||||||
|
<label
|
||||||
|
htmlFor="gallery-upload"
|
||||||
|
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-600 hover:border-slate-400 hover:bg-slate-100 transition"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{galleryFiles.length > 0 ? `Add more (${galleryFiles.length} selected)` : 'Add gallery images'}
|
||||||
|
<input
|
||||||
|
id="gallery-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => handleAddGalleryFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="title" className="block text-sm font-semibold text-slate-700 mb-1">Title</label>
|
||||||
<input
|
<input
|
||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
|
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={e => setTitle(e.target.value)}
|
onChange={e => setTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description now after title */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="description" className="block text-sm font-medium text-blue-900">Description</label>
|
<label htmlFor="description" className="block text-sm font-semibold text-slate-700 mb-1">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
name="description"
|
name="description"
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
|
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Describe the product"
|
placeholder={t('autofix.k3477c83a')}
|
||||||
value={description}
|
value={description}
|
||||||
onChange={e => setDescription(e.target.value)}
|
onChange={e => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-600">Shown to users in the shop and checkout.</p>
|
<p className="mt-1 text-xs text-slate-500">{t('autofix.k0affa826')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="price" className="block text-sm font-medium text-blue-900">Price</label>
|
<label htmlFor="price" className="block text-sm font-semibold text-slate-700 mb-1">Price per pack</label>
|
||||||
<input
|
<input
|
||||||
id="price"
|
id="price"
|
||||||
name="price"
|
name="price"
|
||||||
required
|
required
|
||||||
min={0.01}
|
min={0.01}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400"
|
|
||||||
placeholder="0.00"
|
|
||||||
type="number"
|
type="number"
|
||||||
|
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
placeholder="0.00"
|
||||||
value={price}
|
value={price}
|
||||||
onChange={e => {
|
onChange={e => setPrice(e.target.value)}
|
||||||
const val = e.target.value;
|
onBlur={e => { const n = parseFloat(e.target.value); if (!isNaN(n)) setPrice(n.toFixed(2)); }}
|
||||||
setPrice(val);
|
|
||||||
}}
|
|
||||||
onBlur={e => {
|
|
||||||
const num = parseFloat(e.target.value);
|
|
||||||
if (!isNaN(num)) {
|
|
||||||
setPrice(num.toFixed(2));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Enter the gross price for one pack. The system converts it to the internal per-capsule value automatically.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Currency */}
|
{/* Currency */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="currency" className="block text-sm font-medium text-blue-900">Currency (e.g., EUR)</label>
|
<label htmlFor="currency" className="block text-sm font-semibold text-slate-700 mb-1">Currency (e.g., EUR)</label>
|
||||||
<input id="currency" name="currency" required maxLength={3} pattern="[A-Za-z]{3}" className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black placeholder:text-gray-400" placeholder="EUR" value={currency} onChange={e => setCurrency(e.target.value.toUpperCase().slice(0,3))} />
|
<input
|
||||||
|
id="currency"
|
||||||
|
name="currency"
|
||||||
|
required
|
||||||
|
maxLength={3}
|
||||||
|
pattern="[A-Za-z]{3}"
|
||||||
|
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
placeholder="EUR"
|
||||||
|
value={currency}
|
||||||
|
onChange={e => setCurrency(e.target.value.toUpperCase().slice(0, 3))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Featured */}
|
{/* Featured */}
|
||||||
<div className="flex items-center gap-2 mt-6">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<input id="featured" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} />
|
<input
|
||||||
<label htmlFor="featured" className="text-sm font-medium text-blue-900">Featured</label>
|
id="featured"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900"
|
||||||
|
checked={isFeatured}
|
||||||
|
onChange={e => setIsFeatured(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="featured" className="text-sm font-semibold text-slate-700">Featured</label>
|
||||||
</div>
|
</div>
|
||||||
{/* Subscription Billing (Locked) + Availability */}
|
|
||||||
|
{/* Billing + Availability */}
|
||||||
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div className="sm:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900">Subscription Billing</label>
|
<label className="block text-sm font-semibold text-slate-700 mb-1">{t('autofix.ka3ee9ded')}</label>
|
||||||
<p className="mt-1 text-xs text-gray-600">Fixed monthly subscription billing (interval count = 1). These settings are locked.</p>
|
<p className="text-xs text-slate-500 mb-2">Fixed monthly subscription billing (interval count = 1). These settings are locked.</p>
|
||||||
<div className="mt-2 flex gap-4">
|
<div className="flex gap-3">
|
||||||
<input disabled value={billingInterval} className="w-40 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
|
<input disabled value={billingInterval} className="w-40 rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500" />
|
||||||
<input disabled value={intervalCount} className="w-24 rounded-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
|
<input disabled value={intervalCount} className="w-24 rounded-xl border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="availability" className="block text-sm font-medium text-blue-900">Availability</label>
|
<label htmlFor="availability" className="block text-sm font-semibold text-slate-700 mb-1">Availability</label>
|
||||||
<select id="availability" name="availability" required className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black" value={state} onChange={e => setState(e.target.value as any)}>
|
<select
|
||||||
|
id="availability"
|
||||||
|
name="availability"
|
||||||
|
required
|
||||||
|
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
value={state}
|
||||||
|
onChange={e => setState(e.target.value as any)}
|
||||||
|
>
|
||||||
<option value="available">Available</option>
|
<option value="available">Available</option>
|
||||||
<option value="unavailable">Unavailable</option>
|
<option value="unavailable">Unavailable</option>
|
||||||
</select>
|
</select>
|
||||||
@ -259,20 +374,30 @@ export default function CreateSubscriptionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-end gap-x-4">
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700">
|
<Link
|
||||||
|
href="/admin/subscriptions"
|
||||||
|
className="rounded-xl border border-slate-200 bg-white px-5 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<button type="submit" className="inline-flex justify-center rounded-lg bg-blue-900 px-5 py-3 text-sm font-semibold text-blue-50 shadow hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900 transition">
|
<button
|
||||||
Create Coffee
|
type="submit"
|
||||||
|
className="inline-flex items-center rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.kaa30f0cd')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image Crop Modal */}
|
{/* Image Crop Modal */}
|
||||||
|
|||||||
@ -1,23 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import PageLayout from '../../../../components/PageLayout';
|
import PageLayout from '../../../../components/PageLayout';
|
||||||
import useCoffeeManagement, { CoffeeItem } from '../../hooks/useCoffeeManagement';
|
import useCoffeeManagement, { CoffeeItem } from '../../hooks/useCoffeeManagement';
|
||||||
import { PhotoIcon } from '@heroicons/react/24/solid';
|
import { PhotoIcon } from '@heroicons/react/24/solid';
|
||||||
|
import ImageCropModal from '../../components/ImageCropModal';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../../i18n/useTranslation';
|
||||||
|
|
||||||
export default function EditSubscriptionPage() {
|
export default function EditSubscriptionPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// next/navigation app router dynamic param
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const idParam = params?.id;
|
const idParam = params?.id;
|
||||||
const id = typeof idParam === 'string' ? parseInt(idParam, 10) : Array.isArray(idParam) ? parseInt(idParam[0], 10) : NaN;
|
const id = typeof idParam === 'string' ? parseInt(idParam, 10) : Array.isArray(idParam) ? parseInt(idParam[0], 10) : NaN;
|
||||||
|
|
||||||
const { listProducts, updateProduct } = useCoffeeManagement();
|
const { listProducts, updateProduct, getProductPictures, updateProductPictures } = useCoffeeManagement();
|
||||||
|
|
||||||
const [item, setItem] = useState<CoffeeItem | null>(null);
|
const [item, setItem] = useState<CoffeeItem | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [gallerySaving, setGallerySaving] = useState(false);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
@ -26,11 +31,24 @@ export default function EditSubscriptionPage() {
|
|||||||
const [currency, setCurrency] = useState('EUR');
|
const [currency, setCurrency] = useState('EUR');
|
||||||
const [isFeatured, setIsFeatured] = useState(false);
|
const [isFeatured, setIsFeatured] = useState(false);
|
||||||
const [state, setState] = useState(true);
|
const [state, setState] = useState(true);
|
||||||
|
|
||||||
|
// Thumbnail state
|
||||||
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
|
const [pictureFile, setPictureFile] = useState<File | undefined>(undefined);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
|
||||||
|
const [showCropModal, setShowCropModal] = useState(false);
|
||||||
const [removeExistingPicture, setRemoveExistingPicture] = useState(false);
|
const [removeExistingPicture, setRemoveExistingPicture] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// Gallery state
|
||||||
|
const [existingPictures, setExistingPictures] = useState<{ id: number; url: string }[]>([]);
|
||||||
|
const [galleryLoading, setGalleryLoading] = useState(false);
|
||||||
|
const [pendingRemoveIds, setPendingRemoveIds] = useState<number[]>([]);
|
||||||
|
const [newGalleryFiles, setNewGalleryFiles] = useState<File[]>([]);
|
||||||
|
const [newGalleryPreviews, setNewGalleryPreviews] = useState<string[]>([]);
|
||||||
|
const galleryInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// ── Load product + gallery
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
async function load() {
|
async function load() {
|
||||||
@ -40,7 +58,7 @@ export default function EditSubscriptionPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const all = await listProducts();
|
const [all] = await Promise.all([listProducts()]);
|
||||||
const found = all.find((p: CoffeeItem) => p.id === id) || null;
|
const found = all.find((p: CoffeeItem) => p.id === id) || null;
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
if (!found) {
|
if (!found) {
|
||||||
@ -53,7 +71,6 @@ export default function EditSubscriptionPage() {
|
|||||||
setCurrency(found.currency || 'EUR');
|
setCurrency(found.currency || 'EUR');
|
||||||
setIsFeatured(!!found.is_featured);
|
setIsFeatured(!!found.is_featured);
|
||||||
setState(!!found.state);
|
setState(!!found.state);
|
||||||
setRemoveExistingPicture(false);
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (active) setError(e?.message ?? 'Failed to load subscription');
|
if (active) setError(e?.message ?? 'Failed to load subscription');
|
||||||
@ -66,14 +83,113 @@ export default function EditSubscriptionPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
// Load gallery pictures once the item is set
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || Number.isNaN(id)) return;
|
||||||
|
let active = true;
|
||||||
|
setGalleryLoading(true);
|
||||||
|
getProductPictures(id)
|
||||||
|
.then(pics => { if (active) setExistingPictures(pics); })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => { if (active) setGalleryLoading(false); });
|
||||||
|
return () => { active = false; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// Cleanup preview URLs
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
|
||||||
|
newGalleryPreviews.forEach(u => URL.revokeObjectURL(u));
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Thumbnail handlers
|
||||||
|
function handleSelectThumbnail(file?: File) {
|
||||||
|
if (!file) return;
|
||||||
|
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
if (!allowed.includes(file.type)) { setError('Invalid image type. Allowed: JPG, PNG, WebP'); return; }
|
||||||
|
if (file.size > 10 * 1024 * 1024) { setError('Image exceeds 10MB limit'); return; }
|
||||||
|
setError(null);
|
||||||
|
if (originalImageSrc) URL.revokeObjectURL(originalImageSrc);
|
||||||
|
setOriginalImageSrc(URL.createObjectURL(file));
|
||||||
|
setShowCropModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCropComplete(croppedBlob: Blob) {
|
||||||
|
const croppedFile = new File([croppedBlob], 'cropped-image.jpg', { type: 'image/jpeg' });
|
||||||
|
setPictureFile(croppedFile);
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
setPreviewUrl(URL.createObjectURL(croppedBlob));
|
||||||
|
setRemoveExistingPicture(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gallery handlers
|
||||||
|
function handleAddGalleryFiles(files: FileList | null) {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
const newFiles: File[] = [];
|
||||||
|
const newPreviews: string[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
if (!allowed.includes(file.type)) { setError(`"${file.name}" is not a valid image type.`); continue; }
|
||||||
|
if (file.size > 10 * 1024 * 1024) { setError(`"${file.name}" exceeds 10MB limit.`); continue; }
|
||||||
|
newFiles.push(file);
|
||||||
|
newPreviews.push(URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
setNewGalleryFiles(prev => [...prev, ...newFiles]);
|
||||||
|
setNewGalleryPreviews(prev => [...prev, ...newPreviews]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveNewGalleryImage(index: number) {
|
||||||
|
setNewGalleryPreviews(prev => { URL.revokeObjectURL(prev[index]); return prev.filter((_, i) => i !== index); });
|
||||||
|
setNewGalleryFiles(prev => prev.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRemoveExistingPicture(picId: number) {
|
||||||
|
setPendingRemoveIds(prev =>
|
||||||
|
prev.includes(picId) ? prev.filter(x => x !== picId) : [...prev, picId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save gallery changes
|
||||||
|
async function handleSaveGallery() {
|
||||||
|
if (!item) return;
|
||||||
|
const hasChanges = pendingRemoveIds.length > 0 || newGalleryFiles.length > 0;
|
||||||
|
if (!hasChanges) return;
|
||||||
|
setGallerySaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await updateProductPictures(item.id, {
|
||||||
|
removeIds: pendingRemoveIds.length > 0 ? pendingRemoveIds : undefined,
|
||||||
|
addFiles: newGalleryFiles.length > 0 ? newGalleryFiles : undefined,
|
||||||
|
});
|
||||||
|
// Refresh existing pictures
|
||||||
|
const refreshed = await getProductPictures(item.id);
|
||||||
|
setExistingPictures(refreshed);
|
||||||
|
setPendingRemoveIds([]);
|
||||||
|
newGalleryPreviews.forEach(u => URL.revokeObjectURL(u));
|
||||||
|
setNewGalleryFiles([]);
|
||||||
|
setNewGalleryPreviews([]);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? 'Gallery update failed');
|
||||||
|
} finally {
|
||||||
|
setGallerySaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Submit main form
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const numericPrice = Number(price);
|
const numericPrice = Number(price);
|
||||||
if (!Number.isFinite(numericPrice) || numericPrice < 0) {
|
if (!Number.isFinite(numericPrice) || numericPrice < 0) {
|
||||||
setError('Price must be a valid non-negative number');
|
setError('Price must be a valid non-negative number');
|
||||||
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await updateProduct(item.id, {
|
await updateProduct(item.id, {
|
||||||
@ -86,203 +202,281 @@ export default function EditSubscriptionPage() {
|
|||||||
pictureFile,
|
pictureFile,
|
||||||
removePicture: removeExistingPicture && !pictureFile ? true : false,
|
removePicture: removeExistingPicture && !pictureFile ? true : false,
|
||||||
});
|
});
|
||||||
|
// Also save gallery changes on submit if pending
|
||||||
|
if (pendingRemoveIds.length > 0 || newGalleryFiles.length > 0) {
|
||||||
|
await updateProductPictures(item.id, {
|
||||||
|
removeIds: pendingRemoveIds.length > 0 ? pendingRemoveIds : undefined,
|
||||||
|
addFiles: newGalleryFiles.length > 0 ? newGalleryFiles : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
router.push('/admin/subscriptions');
|
router.push('/admin/subscriptions');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message ?? 'Update failed');
|
setError(e?.message ?? 'Update failed');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const existingThumbnail = item?.pictureUrl || null;
|
||||||
if (pictureFile) {
|
const showThumb = previewUrl || (!removeExistingPicture && existingThumbnail);
|
||||||
const url = URL.createObjectURL(pictureFile);
|
|
||||||
setPreviewUrl(url);
|
|
||||||
return () => URL.revokeObjectURL(url);
|
|
||||||
} else {
|
|
||||||
setPreviewUrl(null);
|
|
||||||
}
|
|
||||||
}, [pictureFile]);
|
|
||||||
|
|
||||||
function handleSelectFile(file?: File) {
|
const galleryHasChanges = pendingRemoveIds.length > 0 || newGalleryFiles.length > 0;
|
||||||
if (!file) return;
|
|
||||||
const allowed = ['image/jpeg','image/png','image/webp'];
|
|
||||||
if (!allowed.includes(file.type)) {
|
|
||||||
setError('Invalid image type. Allowed: JPG, PNG, WebP');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
setError('Image exceeds 10MB limit');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
setPictureFile(file);
|
|
||||||
setRemoveExistingPicture(false); // selecting new overrides removal flag
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
{showCropModal && originalImageSrc && (
|
||||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
<ImageCropModal
|
||||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
isOpen={showCropModal}
|
||||||
<div className="flex items-center justify-between">
|
imageSrc={originalImageSrc}
|
||||||
|
onClose={() => setShowCropModal(false)}
|
||||||
|
onCropComplete={handleCropComplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-w-455 mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
|
|
||||||
|
{/* Header card */}
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Edit Coffee</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900">{t('autofix.kb06fa395')}</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">Update details of the coffee.</p>
|
<p className="mt-1 text-sm text-slate-500">{t('autofix.kb9e483c4')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/subscriptions"
|
<Link
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
href="/admin/subscriptions"
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
Back to list
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
{t('autofix.kd8a5ad17')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
|
{/* Error / loading states */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="rounded-md bg-blue-50 p-4 text-blue-700 text-sm mb-6">Loading subscription…</div>
|
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur text-sm text-slate-500">
|
||||||
|
{t('autofix.k2d0798a6')}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && !loading && (
|
{error && !loading && (
|
||||||
<div className="rounded-md bg-red-50 p-4 text-red-700 text-sm mb-6">{error}</div>
|
<div className="rounded-[28px] border border-red-100 bg-red-50 px-6 py-4 text-sm text-red-700 shadow-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && item && (
|
{!loading && item && (
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg">
|
<>
|
||||||
|
{/* Main form card */}
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-blue-900">Title</label>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
|
|
||||||
value={title}
|
|
||||||
onChange={e => setTitle(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-blue-900">Price</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={0.01}
|
|
||||||
required
|
|
||||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
|
|
||||||
value={price}
|
|
||||||
onChange={e => setPrice(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-blue-900">Currency</label>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
maxLength={3}
|
|
||||||
pattern="[A-Za-z]{3}"
|
|
||||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
|
|
||||||
value={currency}
|
|
||||||
onChange={e => setCurrency(e.target.value.toUpperCase().slice(0,3))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 mt-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input id="featured" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} />
|
|
||||||
<label htmlFor="featured" className="text-sm font-medium text-blue-900">Featured</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input id="enabled" type="checkbox" className="h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900" checked={state} onChange={e => setState(e.target.checked)} />
|
|
||||||
<label htmlFor="enabled" className="text-sm font-medium text-blue-900">Enabled</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900">Description</label>
|
<label className="block text-sm font-semibold text-slate-700 mb-1">Thumbnail</label>
|
||||||
<textarea
|
<p className="text-xs text-slate-500 mb-3">Single image used as the card thumbnail. Click to replace (crop available). Sent as <code className="rounded bg-slate-100 px-1 py-0.5 text-slate-600">picture</code>.</p>
|
||||||
required
|
|
||||||
rows={4}
|
|
||||||
className="mt-1 block w-full rounded-lg border-gray-300 shadow focus:border-blue-900 focus:ring-blue-900 px-4 py-3 text-black"
|
|
||||||
value={description}
|
|
||||||
onChange={e => setDescription(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-2">Picture (optional)</label>
|
|
||||||
<p className="text-xs text-gray-600 mb-3">Upload an image to replace the current picture (16:9 aspect ratio recommended)</p>
|
|
||||||
<div
|
<div
|
||||||
className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-blue-300 bg-blue-50 cursor-pointer overflow-hidden transition hover:border-blue-400 hover:bg-blue-100"
|
className="relative flex justify-center items-center rounded-2xl border-2 border-dashed border-slate-200 bg-slate-50 cursor-pointer overflow-hidden transition hover:border-slate-400 hover:bg-slate-100"
|
||||||
style={{ minHeight: '400px' }}
|
style={{ minHeight: '340px' }}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
onDragOver={e => e.preventDefault()}
|
onDragOver={e => e.preventDefault()}
|
||||||
onDrop={e => {
|
onDrop={e => { e.preventDefault(); if (e.dataTransfer.files?.[0]) handleSelectThumbnail(e.dataTransfer.files[0]); }}
|
||||||
e.preventDefault();
|
|
||||||
if (e.dataTransfer.files?.[0]) handleSelectFile(e.dataTransfer.files[0]);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{!previewUrl && !item.pictureUrl && (
|
{!showThumb && (
|
||||||
<div className="text-center w-full px-6 py-10">
|
<div className="text-center w-full px-6 py-10">
|
||||||
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-blue-400" />
|
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-slate-300" />
|
||||||
<div className="mt-4 text-base font-medium text-blue-700">
|
<div className="mt-4 text-base font-medium text-slate-600"><span>{t('autofix.k2e43a9c4')}</span></div>
|
||||||
<span>Click or drag and drop a new image here</span>
|
<p className="text-sm text-slate-500 mt-2">{t('autofix.k80ac9651')}</p>
|
||||||
</div>
|
|
||||||
<p className="text-sm text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(previewUrl || (!removeExistingPicture && item.pictureUrl)) && (
|
{showThumb && (
|
||||||
<div className="relative w-full h-full min-h-[400px] flex items-center justify-center bg-gray-100 p-6">
|
<div className="relative flex h-full min-h-85 w-full items-center justify-center bg-slate-100 p-6">
|
||||||
<img
|
<img
|
||||||
src={previewUrl || item.pictureUrl || ''}
|
src={previewUrl || existingThumbnail || ''}
|
||||||
alt={previewUrl ? "Preview" : item.title}
|
alt={previewUrl ? 'Preview' : item.title}
|
||||||
className="max-h-[380px] max-w-full object-contain rounded-lg shadow-lg"
|
className="max-h-80 max-w-full object-contain rounded-xl shadow-lg"
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-4 right-4">
|
<div className="absolute top-4 right-4 flex gap-2">
|
||||||
<button
|
{previewUrl && (
|
||||||
type="button"
|
<button type="button" onClick={e => { e.stopPropagation(); setShowCropModal(true); }}
|
||||||
onClick={e => {
|
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-semibold text-slate-800 shadow hover:bg-white transition">
|
||||||
e.stopPropagation();
|
{t('autofix.k73d1d7d7')}
|
||||||
if (previewUrl) {
|
</button>
|
||||||
setPictureFile(undefined);
|
)}
|
||||||
setPreviewUrl(null);
|
<button type="button"
|
||||||
} else if (item.pictureUrl) {
|
onClick={e => { e.stopPropagation(); if (previewUrl) { setPictureFile(undefined); setPreviewUrl(null); } else { setRemoveExistingPicture(true); } }}
|
||||||
setRemoveExistingPicture(true);
|
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-semibold text-rose-600 shadow hover:bg-white transition">
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="bg-white/90 backdrop-blur px-3 py-1.5 rounded-lg text-sm font-medium text-red-600 shadow hover:bg-white transition"
|
|
||||||
>
|
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{removeExistingPicture && !previewUrl && (
|
<input ref={fileInputRef} type="file" accept="image/*" className="hidden"
|
||||||
<div className="text-center w-full px-6 py-10">
|
onChange={e => handleSelectThumbnail(e.target.files?.[0])} />
|
||||||
<PhotoIcon aria-hidden="true" className="mx-auto h-16 w-16 text-gray-400" />
|
|
||||||
<div className="mt-4 text-base font-medium text-gray-600">
|
|
||||||
<span>Image removed - Click to upload a new one</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-2">PNG, JPG, WebP up to 10MB</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={e => handleSelectFile(e.target.files?.[0])}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-x-4">
|
{/* Title */}
|
||||||
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700">
|
<div>
|
||||||
|
<label htmlFor="title" className="block text-sm font-semibold text-slate-700 mb-1">Title</label>
|
||||||
|
<input id="title" required
|
||||||
|
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
value={title} onChange={e => setTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-semibold text-slate-700 mb-1">Description</label>
|
||||||
|
<textarea id="description" required rows={4}
|
||||||
|
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
value={description} onChange={e => setDescription(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
{/* Price */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="price" className="block text-sm font-semibold text-slate-700 mb-1">Price per pack</label>
|
||||||
|
<input id="price" type="number" min={0} step={0.01} required
|
||||||
|
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
value={price} onChange={e => setPrice(e.target.value)}
|
||||||
|
onBlur={e => { const n = parseFloat(e.target.value); if (!isNaN(n)) setPrice(n.toFixed(2)); }} />
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Admin input is handled per pack. The backend continues storing the internal per-capsule value automatically.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Currency */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="currency" className="block text-sm font-semibold text-slate-700 mb-1">Currency (e.g., EUR)</label>
|
||||||
|
<input id="currency" required maxLength={3} pattern="[A-Za-z]{3}" placeholder="EUR"
|
||||||
|
className="block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
value={currency} onChange={e => setCurrency(e.target.value.toUpperCase().slice(0, 3))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input id="featured" type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900"
|
||||||
|
checked={isFeatured} onChange={e => setIsFeatured(e.target.checked)} />
|
||||||
|
<label htmlFor="featured" className="text-sm font-semibold text-slate-700">Featured</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enabled */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input id="enabled" type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900"
|
||||||
|
checked={state} onChange={e => setState(e.target.checked)} />
|
||||||
|
<label htmlFor="enabled" className="text-sm font-semibold text-slate-700">Enabled</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-x-4 pt-2 border-t border-slate-100">
|
||||||
|
<Link href="/admin/subscriptions" className="text-sm font-semibold text-slate-600 hover:text-slate-900 transition">
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<button type="submit" className="inline-flex justify-center rounded-lg bg-blue-900 px-5 py-3 text-sm font-semibold text-blue-50 shadow hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-900 transition">
|
<button type="submit" disabled={saving}
|
||||||
Save Changes
|
className="inline-flex justify-center items-center gap-2 rounded-xl bg-slate-900 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-900 transition disabled:opacity-60">
|
||||||
|
{saving && <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" /></svg>}
|
||||||
|
{t('autofix.k5a489751')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gallery management card */}
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-slate-900">{t('autofix.ka219f1d9')}</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{t('autofix.k68ef9d49')}<span className="font-semibold text-slate-700">{t('autofix.k0496e5cc')}</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{galleryHasChanges && (
|
||||||
|
<button type="button" onClick={handleSaveGallery} disabled={gallerySaving}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-slate-700 transition disabled:opacity-60">
|
||||||
|
{gallerySaving && <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" /></svg>}
|
||||||
|
Save Gallery
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing pictures */}
|
||||||
|
{galleryLoading ? (
|
||||||
|
<p className="text-sm text-slate-500">{t('autofix.k8811e423')}</p>
|
||||||
|
) : existingPictures.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">{t('autofix.kddb4db62')}</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
|
||||||
|
{existingPictures.map(pic => {
|
||||||
|
const markedForRemoval = pendingRemoveIds.includes(pic.id);
|
||||||
|
return (
|
||||||
|
<div key={pic.id} className={`relative group rounded-xl overflow-hidden border aspect-video transition ${markedForRemoval ? 'border-rose-400 opacity-50' : 'border-slate-200 bg-slate-50'}`}>
|
||||||
|
<img src={pic.url} alt="" className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleRemoveExistingPicture(pic.id)}
|
||||||
|
className={`absolute inset-0 flex items-center justify-center transition ${markedForRemoval ? 'bg-rose-500/30 opacity-100' : 'bg-black/40 opacity-0 group-hover:opacity-100'}`}
|
||||||
|
aria-label={markedForRemoval ? 'Undo remove' : 'Mark for removal'}
|
||||||
|
>
|
||||||
|
{markedForRemoval ? (
|
||||||
|
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{markedForRemoval && (
|
||||||
|
<span className="absolute top-1 left-1 rounded bg-rose-500 px-1.5 py-0.5 text-[10px] text-white font-semibold">Remove</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-400">{t('autofix.k09e4edde')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New images to upload */}
|
||||||
|
{newGalleryPreviews.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">{t('autofix.kad44a0f7')}</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
|
||||||
|
{newGalleryPreviews.map((url, i) => (
|
||||||
|
<div key={i} className="relative group rounded-xl overflow-hidden border border-emerald-300 bg-slate-50 aspect-video">
|
||||||
|
<img src={url} alt={`New ${i + 1}`} className="w-full h-full object-cover" />
|
||||||
|
<button type="button" onClick={() => handleRemoveNewGalleryImage(i)}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition"
|
||||||
|
aria-label="Remove">
|
||||||
|
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="absolute bottom-1 left-1 rounded bg-emerald-500/80 px-1.5 py-0.5 text-[10px] text-white font-semibold">New</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add images button */}
|
||||||
|
<label htmlFor="gallery-edit-upload"
|
||||||
|
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-600 hover:border-slate-400 hover:bg-slate-100 transition">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{newGalleryFiles.length > 0 ? `Add more (${newGalleryFiles.length} queued)` : 'Add images'}
|
||||||
|
<input id="gallery-edit-upload" ref={galleryInputRef} type="file" accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple className="hidden" onChange={e => handleAddGalleryFiles(e.target.files)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{pendingRemoveIds.length > 0 && (
|
||||||
|
<p className="text-xs text-rose-600">
|
||||||
|
{pendingRemoveIds.length} image{pendingRemoveIds.length > 1 ? 's' : ''} marked for removal. Click <span className="font-semibold">{t('autofix.k0496e5cc')}</span>{t('autofix.kaabd9a3d')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import useAuthStore from '../../../store/authStore';
|
import useAuthStore from '../../../store/authStore';
|
||||||
|
import { CAPSULES_PER_PACK } from '../../../coffee-abonnements/lib/orderRules';
|
||||||
|
|
||||||
export type CoffeeItem = {
|
export type CoffeeItem = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -14,6 +15,7 @@ export type CoffeeItem = {
|
|||||||
original_filename?: string|null;
|
original_filename?: string|null;
|
||||||
state: boolean;
|
state: boolean;
|
||||||
pictureUrl?: string | null;
|
pictureUrl?: string | null;
|
||||||
|
pictureUrls?: string[] | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
@ -62,7 +64,7 @@ export default function useCoffeeManagement() {
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
try { return JSON.parse(text) as T; } catch { return {} as T; }
|
try { return JSON.parse(text) as T; } catch { return {} as T; }
|
||||||
},
|
},
|
||||||
[base]
|
[base, getState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const listProducts = useCallback(async (): Promise<CoffeeItem[]> => {
|
const listProducts = useCallback(async (): Promise<CoffeeItem[]> => {
|
||||||
@ -71,7 +73,7 @@ export default function useCoffeeManagement() {
|
|||||||
return data.map((r: any) => ({
|
return data.map((r: any) => ({
|
||||||
...r,
|
...r,
|
||||||
id: Number(r.id),
|
id: Number(r.id),
|
||||||
price: r.price != null && r.price !== '' ? Number(r.price) : 0,
|
price: r.price != null && r.price !== '' ? Number(r.price) * CAPSULES_PER_PACK : 0,
|
||||||
interval_count: r.interval_count != null && r.interval_count !== '' ? Number(r.interval_count) : null,
|
interval_count: r.interval_count != null && r.interval_count !== '' ? Number(r.interval_count) : null,
|
||||||
state: !!r.state,
|
state: !!r.state,
|
||||||
})) as CoffeeItem[];
|
})) as CoffeeItem[];
|
||||||
@ -85,19 +87,45 @@ export default function useCoffeeManagement() {
|
|||||||
is_featured?: boolean;
|
is_featured?: boolean;
|
||||||
state?: boolean;
|
state?: boolean;
|
||||||
pictureFile?: File;
|
pictureFile?: File;
|
||||||
|
pictureFiles?: File[];
|
||||||
}): Promise<CoffeeItem> => {
|
}): Promise<CoffeeItem> => {
|
||||||
const fd = new FormData();
|
const appendBaseFields = (fd: FormData) => {
|
||||||
fd.append('title', payload.title);
|
fd.append('title', payload.title);
|
||||||
fd.append('description', payload.description);
|
fd.append('description', payload.description);
|
||||||
fd.append('price', String(payload.price));
|
fd.append('price', String(payload.price / CAPSULES_PER_PACK));
|
||||||
if (payload.currency) fd.append('currency', payload.currency);
|
if (payload.currency) fd.append('currency', payload.currency);
|
||||||
if (typeof payload.is_featured === 'boolean') fd.append('is_featured', String(payload.is_featured));
|
if (typeof payload.is_featured === 'boolean') fd.append('is_featured', String(payload.is_featured));
|
||||||
if (typeof payload.state === 'boolean') fd.append('state', String(payload.state));
|
if (typeof payload.state === 'boolean') fd.append('state', String(payload.state));
|
||||||
// Fixed billing defaults
|
// Fixed billing defaults
|
||||||
fd.append('billing_interval', 'month');
|
fd.append('billing_interval', 'month');
|
||||||
fd.append('interval_count', '1');
|
fd.append('interval_count', '1');
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLegacyFormData = () => {
|
||||||
|
const legacy = new FormData();
|
||||||
|
appendBaseFields(legacy);
|
||||||
|
const fallbackPicture = payload.pictureFile ?? payload.pictureFiles?.[0];
|
||||||
|
if (fallbackPicture) legacy.append('picture', fallbackPicture);
|
||||||
|
return legacy;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMultipleGalleryImages = (payload.pictureFiles?.length ?? 0) > 1;
|
||||||
|
|
||||||
|
if (hasMultipleGalleryImages) {
|
||||||
|
const fd = new FormData();
|
||||||
|
appendBaseFields(fd);
|
||||||
if (payload.pictureFile) fd.append('picture', payload.pictureFile);
|
if (payload.pictureFile) fd.append('picture', payload.pictureFile);
|
||||||
return authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: fd });
|
for (const f of payload.pictureFiles!) fd.append('pictures', f);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: fd });
|
||||||
|
} catch {
|
||||||
|
// Compatibility fallback for deployments that only support legacy single-file payloads.
|
||||||
|
return authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: createLegacyFormData() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return authorizedFetch<CoffeeItem>('/api/admin/coffee', { method: 'POST', body: createLegacyFormData() });
|
||||||
}, [authorizedFetch]);
|
}, [authorizedFetch]);
|
||||||
|
|
||||||
const updateProduct = useCallback(async (id: number, payload: Partial<{
|
const updateProduct = useCallback(async (id: number, payload: Partial<{
|
||||||
@ -113,7 +141,7 @@ export default function useCoffeeManagement() {
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
if (payload.title !== undefined) fd.append('title', String(payload.title));
|
if (payload.title !== undefined) fd.append('title', String(payload.title));
|
||||||
if (payload.description !== undefined) fd.append('description', String(payload.description));
|
if (payload.description !== undefined) fd.append('description', String(payload.description));
|
||||||
if (payload.price !== undefined) fd.append('price', String(payload.price));
|
if (payload.price !== undefined) fd.append('price', String(payload.price / CAPSULES_PER_PACK));
|
||||||
if (payload.currency !== undefined) fd.append('currency', payload.currency);
|
if (payload.currency !== undefined) fd.append('currency', payload.currency);
|
||||||
if (payload.is_featured !== undefined) fd.append('is_featured', String(payload.is_featured));
|
if (payload.is_featured !== undefined) fd.append('is_featured', String(payload.is_featured));
|
||||||
if (payload.state !== undefined) fd.append('state', String(payload.state));
|
if (payload.state !== undefined) fd.append('state', String(payload.state));
|
||||||
@ -136,11 +164,40 @@ export default function useCoffeeManagement() {
|
|||||||
return authorizedFetch<{success?: boolean}>(`/api/admin/coffee/${id}`, { method: 'DELETE' });
|
return authorizedFetch<{success?: boolean}>(`/api/admin/coffee/${id}`, { method: 'DELETE' });
|
||||||
}, [authorizedFetch]);
|
}, [authorizedFetch]);
|
||||||
|
|
||||||
|
const getProductPictures = useCallback(async (id: number): Promise<{ id: number; url: string }[]> => {
|
||||||
|
const data = await authorizedFetch<any>(`/api/admin/coffee/${id}/pictures`, { method: 'GET' });
|
||||||
|
// Normalize response: may be { pictures: [{id, url}] } or { pictureUrls: string[] }
|
||||||
|
if (Array.isArray(data?.pictures)) {
|
||||||
|
return data.pictures.map((p: any) => ({ id: Number(p.id), url: p.url ?? p.pictureUrl ?? p.src ?? '' }));
|
||||||
|
}
|
||||||
|
if (Array.isArray(data?.pictureUrls)) {
|
||||||
|
return data.pictureUrls.map((url: string, i: number) => ({ id: i, url }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [authorizedFetch]);
|
||||||
|
|
||||||
|
const updateProductPictures = useCallback(async (
|
||||||
|
id: number,
|
||||||
|
opts: { addFiles?: File[]; removeIds?: number[]; replaceAll?: boolean }
|
||||||
|
): Promise<any> => {
|
||||||
|
const fd = new FormData();
|
||||||
|
if (opts.replaceAll) fd.append('replaceAll', 'true');
|
||||||
|
if (opts.removeIds && opts.removeIds.length > 0) {
|
||||||
|
fd.append('removePictureIds', JSON.stringify(opts.removeIds));
|
||||||
|
}
|
||||||
|
if (opts.addFiles) {
|
||||||
|
for (const f of opts.addFiles) fd.append('pictures', f);
|
||||||
|
}
|
||||||
|
return authorizedFetch<any>(`/api/admin/coffee/${id}/pictures`, { method: 'PATCH', body: fd });
|
||||||
|
}, [authorizedFetch]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listProducts,
|
listProducts,
|
||||||
createProduct,
|
createProduct,
|
||||||
updateProduct,
|
updateProduct,
|
||||||
setProductState,
|
setProductState,
|
||||||
deleteProduct,
|
deleteProduct,
|
||||||
|
getProductPictures,
|
||||||
|
updateProductPictures,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,15 @@ import { PhotoIcon } from '@heroicons/react/24/solid';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import PageLayout from '../../components/PageLayout';
|
import PageLayout from '../../components/PageLayout';
|
||||||
import useCoffeeManagement, { CoffeeItem } from './hooks/useCoffeeManagement';
|
import useCoffeeManagement, { CoffeeItem } from './hooks/useCoffeeManagement';
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
|
|
||||||
import useCoffeeShippingFees, {
|
import useCoffeeShippingFees, {
|
||||||
CoffeeShippingFee,
|
CoffeeShippingFee,
|
||||||
CoffeeShippingFeePieceCount,
|
CoffeeShippingFeePieceCount,
|
||||||
} from './hooks/useCoffeeShippingFees';
|
} from './hooks/useCoffeeShippingFees';
|
||||||
|
|
||||||
export default function AdminSubscriptionsPage() {
|
export default function AdminSubscriptionsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { listProducts, setProductState, deleteProduct } = useCoffeeManagement();
|
const { listProducts, setProductState, deleteProduct } = useCoffeeManagement();
|
||||||
const { listShippingFees, updateShippingFee } = useCoffeeShippingFees();
|
const { listShippingFees, updateShippingFee } = useCoffeeShippingFees();
|
||||||
|
|
||||||
@ -134,51 +137,51 @@ export default function AdminSubscriptionsPage() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<CoffeeItem | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<CoffeeItem | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="max-w-455 mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-6 px-6 rounded-2xl shadow-lg mb-8">
|
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Coffees</h1>
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
<p className="text-lg text-blue-700 mt-2">Manage all coffees.</p>
|
Admin
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-2xl font-bold text-slate-900 tracking-tight">Coffees</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">{t('autofix.k875f4054')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/subscriptions/createSubscription"
|
href="/admin/subscriptions/createSubscription"
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition self-start sm:self-auto"
|
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-white px-4 py-2.5 text-sm font-semibold shadow transition self-start sm:self-auto"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
Create Coffee
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{t('autofix.kaa30f0cd')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 ring-1 ring-inset ring-red-200">{error}</div>
|
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Shipping Fees */}
|
{/* Shipping Fees */}
|
||||||
<section className="mb-8 rounded-2xl border border-gray-100 bg-white shadow-lg p-6">
|
<section className="rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] p-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4 mb-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-blue-900">Shipping Fees (ABO)</h2>
|
<h2 className="text-lg font-semibold text-slate-900">Shipping Fees (ABO)</h2>
|
||||||
<p className="mt-1 text-sm text-gray-600">Edit the shipping prices for 60 and 120 pieces.</p>
|
<p className="mt-1 text-sm text-slate-500">{t('autofix.k027bd82e')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center rounded-lg bg-gray-50 px-4 py-2 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 hover:bg-gray-100 shadow transition self-start"
|
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 shadow-sm transition"
|
||||||
onClick={loadShippingFees}
|
onClick={loadShippingFees}
|
||||||
disabled={shippingFeesLoading}
|
disabled={shippingFeesLoading}
|
||||||
>
|
>{shippingFeesLoading ? t('autofix.k14a4b43e') : 'Refresh'}</button>
|
||||||
{shippingFeesLoading ? 'Refreshing…' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shippingFeesError && (
|
{shippingFeesError && (
|
||||||
<div className="mt-4 rounded-md bg-red-50 p-3 text-sm text-red-700 ring-1 ring-inset ring-red-200">{shippingFeesError}</div>
|
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{shippingFeesError}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-5 grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{([60, 120] as CoffeeShippingFeePieceCount[]).map((pieceCount) => {
|
{([60, 120] as CoffeeShippingFeePieceCount[]).map((pieceCount) => {
|
||||||
const saving = shippingFeeSaving[pieceCount];
|
const saving = shippingFeeSaving[pieceCount];
|
||||||
const savedAt = shippingFeeSavedAt[pieceCount];
|
const savedAt = shippingFeeSavedAt[pieceCount];
|
||||||
@ -189,14 +192,14 @@ export default function AdminSubscriptionsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pieceCount}
|
key={pieceCount}
|
||||||
className="rounded-xl border border-gray-100 bg-white ring-1 ring-inset ring-gray-100 p-4"
|
className="rounded-2xl border border-slate-200 bg-white/80 p-4"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-sm font-semibold text-gray-900">{pieceCount} pieces</div>
|
<div className="text-sm font-semibold text-slate-900 wrap-break-word">{pieceCount} pieces</div>
|
||||||
{typeof current?.price === 'number' && Number.isFinite(current.price) ? (
|
{typeof current?.price === 'number' && Number.isFinite(current.price) ? (
|
||||||
<div className="text-xs text-gray-500">Current: €{formatPriceDraft(current.price)}</div>
|
<div className="text-xs text-slate-500 wrap-break-word">Current: €{formatPriceDraft(current.price)}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{savedAt ? (
|
{savedAt ? (
|
||||||
<div className="text-xs text-emerald-700 bg-emerald-50 ring-1 ring-inset ring-emerald-200 px-2 py-0.5 rounded-full">
|
<div className="text-xs text-emerald-700 bg-emerald-50 ring-1 ring-inset ring-emerald-200 px-2 py-0.5 rounded-full">
|
||||||
@ -205,19 +208,19 @@ export default function AdminSubscriptionsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{fieldError ? (
|
{fieldError ? (
|
||||||
<div className="mt-2 text-xs text-red-700">{fieldError}</div>
|
<div className="mt-2 text-xs text-rose-700">{fieldError}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 text-xs text-gray-500">Enter a price in EUR (≥ 0).</div>
|
<div className="mt-2 text-xs text-slate-400">Enter a price in EUR (≥ 0).</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-gray-500">€</span>
|
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-slate-400">€</span>
|
||||||
<input
|
<input
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className={`w-40 rounded-lg border px-8 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-200 ${
|
className={`w-40 rounded-xl border px-8 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent ${
|
||||||
fieldError ? 'border-red-300 ring-1 ring-red-200' : 'border-gray-300'
|
fieldError ? 'border-rose-300' : 'border-slate-200'
|
||||||
}`}
|
}`}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -229,16 +232,14 @@ export default function AdminSubscriptionsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-semibold shadow transition ${
|
className={`inline-flex items-center rounded-xl px-4 py-2 text-sm font-semibold shadow-sm transition ${
|
||||||
saving
|
saving
|
||||||
? 'bg-gray-200 text-gray-600 cursor-not-allowed'
|
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||||
: 'bg-blue-900 text-blue-50 hover:bg-blue-800'
|
: 'bg-slate-900 text-white hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => saveShippingFee(pieceCount)}
|
onClick={() => saveShippingFee(pieceCount)}
|
||||||
>
|
>{saving ? t('autofix.kac6cedc7') : 'Save'}</button>
|
||||||
{saving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -247,55 +248,57 @@ export default function AdminSubscriptionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="col-span-full text-sm text-gray-700">Loading…</div>
|
<div className="col-span-full text-sm text-slate-500 py-4">{t('autofix.k832387c5')}</div>
|
||||||
)}
|
)}
|
||||||
{!loading && items.map(item => (
|
{!loading && items.map(item => (
|
||||||
<div key={item.id} className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 flex flex-col gap-3 hover:shadow-xl transition">
|
<div key={item.id} className="rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] p-5 flex flex-col gap-3 hover:shadow-[0_28px_72px_-38px_rgba(15,23,42,0.38)] transition">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h3 className="text-xl font-semibold text-blue-900">{item.title}</h3>
|
<h3 className="text-base font-semibold text-slate-900 leading-snug">{item.title}</h3>
|
||||||
{availabilityBadge(!!item.state)}
|
<span className={`shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${item.state ? 'bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200' : 'bg-slate-100 text-slate-500 ring-1 ring-inset ring-slate-200'}`}>
|
||||||
|
{item.state ? 'Available' : 'Unavailable'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 w-full h-40 rounded-xl ring-1 ring-gray-200 overflow-hidden flex items-center justify-center bg-gray-50">
|
<div className="w-full h-36 rounded-xl border border-slate-100 overflow-hidden flex items-center justify-center bg-slate-50">
|
||||||
{item.pictureUrl ? (
|
{item.pictureUrl ? (
|
||||||
<img src={item.pictureUrl} alt={item.title} className="w-full h-full object-cover" />
|
<img src={item.pictureUrl} alt={item.title} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<PhotoIcon className="w-12 h-12 text-gray-300" />
|
<PhotoIcon className="w-10 h-10 text-slate-200" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-gray-800 line-clamp-4">{item.description}</p>
|
<p className="text-sm text-slate-600 line-clamp-3">{item.description}</p>
|
||||||
<dl className="mt-4 grid grid-cols-1 gap-y-2 text-sm">
|
<dl className="grid grid-cols-1 gap-y-1 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-gray-500">Price</dt>
|
<dt className="text-xs text-slate-400">Price per pack</dt>
|
||||||
<dd className="font-medium text-gray-900">
|
<dd className="font-semibold text-slate-900">
|
||||||
{item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)}
|
{item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{item.billing_interval && item.interval_count ? (
|
{item.billing_interval && item.interval_count ? (
|
||||||
<div className="text-gray-600">
|
<div className="text-xs text-slate-400">
|
||||||
<span className="text-xs">Subscription billing: {item.billing_interval} (x{item.interval_count})</span>
|
Subscription: {item.billing_interval} × {item.interval_count}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</dl>
|
</dl>
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-auto flex flex-wrap gap-2 pt-1">
|
||||||
<button
|
<button
|
||||||
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-medium shadow transition
|
className={`inline-flex items-center rounded-xl px-3 py-1.5 text-xs font-semibold transition ${
|
||||||
${item.state
|
item.state
|
||||||
? 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200 hover:bg-amber-100'
|
? 'bg-amber-50 text-amber-700 hover:bg-amber-100'
|
||||||
: 'bg-blue-900 text-blue-50 hover:bg-blue-800'}`}
|
: 'bg-slate-900 text-white hover:bg-slate-800'}`}
|
||||||
onClick={async () => { await setProductState(item.id, !item.state); await load(); }}
|
onClick={async () => { await setProductState(item.id, !item.state); await load(); }}
|
||||||
>
|
>
|
||||||
{item.state ? 'Disable' : 'Enable'}
|
{item.state ? 'Disable' : 'Enable'}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/subscriptions/edit/${item.id}`}
|
href={`/admin/subscriptions/edit/${item.id}`}
|
||||||
className="inline-flex items-center rounded-lg bg-indigo-50 px-4 py-2 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-200 hover:bg-indigo-100 shadow transition"
|
className="inline-flex items-center rounded-xl bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-200 transition"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center rounded-lg bg-red-50 px-4 py-2 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-200 hover:bg-red-100 shadow transition"
|
className="inline-flex items-center rounded-xl bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 transition"
|
||||||
onClick={() => setDeleteTarget(item)}
|
onClick={() => setDeleteTarget(item)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@ -304,28 +307,28 @@ export default function AdminSubscriptionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!loading && !items.length && (
|
{!loading && !items.length && (
|
||||||
<div className="col-span-full py-8 text-center text-sm text-gray-500">No subscriptions found.</div>
|
<div className="col-span-full py-10 text-center text-sm text-slate-400">{t('autofix.k8c75468c')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Confirm Delete Modal */}
|
{/* Confirm Delete Modal */}
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-50">
|
||||||
<div className="absolute inset-0 bg-black/30" onClick={() => setDeleteTarget(null)} />
|
<div className="absolute inset-0 bg-black/35 backdrop-blur-sm" onClick={() => setDeleteTarget(null)} />
|
||||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
|
<div className="w-full max-w-md rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)]">
|
||||||
<div className="px-6 pt-6">
|
<div className="px-6 pt-6">
|
||||||
<h3 className="text-lg font-semibold text-blue-900">Delete coffee?</h3>
|
<h3 className="text-lg font-semibold text-slate-900">{t('autofix.kddd4832f')}</h3>
|
||||||
<p className="mt-2 text-sm text-gray-700">You are about to delete the coffee “{deleteTarget.title}”. This action cannot be undone.</p>
|
<p className="mt-2 text-sm text-slate-600">You are about to delete the coffee "{deleteTarget.title}". This action cannot be undone.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
className="inline-flex items-center rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition"
|
||||||
onClick={() => setDeleteTarget(null)}
|
onClick={() => setDeleteTarget(null)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-500 shadow"
|
className="inline-flex items-center rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-500 shadow transition"
|
||||||
onClick={async () => { await deleteProduct(deleteTarget.id); setDeleteTarget(null); await load(); }}
|
onClick={async () => { await deleteProduct(deleteTarget.id); setDeleteTarget(null); await load(); }}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@ -336,7 +339,6 @@ export default function AdminSubscriptionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type TFunction = (key: string) => string
|
||||||
|
|
||||||
|
export function UserManagementInitialLoading({ t }: { t: TFunction }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)] flex items-center justify-center">
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur text-center">
|
||||||
|
<div className="h-12 w-12 rounded-full border-2 border-slate-900 border-b-transparent animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-slate-800 font-medium">{t('autofix.kfd6e0974')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserManagementAccessDenied({ t }: { t: TFunction }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)] flex items-center justify-center px-4">
|
||||||
|
<div className="mx-auto w-full max-w-xl rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
|
<div className="text-center">
|
||||||
|
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold text-red-600 mb-2">{t('autofix.k26fbc186')}</h1>
|
||||||
|
<p className="text-slate-600">{t('autofix.k661c032b')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { UserRole, UserStatus, UserType } from '../hooks/useUserManagementPageState'
|
||||||
|
import {
|
||||||
|
getUserStatusLabel,
|
||||||
|
getUserTypeLabel,
|
||||||
|
getUserRoleLabel,
|
||||||
|
type ManagedUserType,
|
||||||
|
type ManagedUserRole,
|
||||||
|
} from '../constants/userStatusPresentation'
|
||||||
|
|
||||||
|
type TFunction = (key: string) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: TFunction
|
||||||
|
search: string
|
||||||
|
setSearch: (value: string) => void
|
||||||
|
fType: 'all' | UserType
|
||||||
|
setFType: (value: 'all' | UserType) => void
|
||||||
|
fStatus: 'all' | UserStatus
|
||||||
|
setFStatus: (value: 'all' | UserStatus) => void
|
||||||
|
fRole: 'all' | UserRole
|
||||||
|
setFRole: (value: 'all' | UserRole) => void
|
||||||
|
statusOptions: UserStatus[]
|
||||||
|
typeOptions: UserType[]
|
||||||
|
roleOptions: UserRole[]
|
||||||
|
onSubmit: () => void
|
||||||
|
onExportCsv: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeLabel(t: TFunction, value: UserType): string {
|
||||||
|
return getUserTypeLabel(t, value as ManagedUserType)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(t: TFunction, value: UserStatus): string {
|
||||||
|
if (value === 'disabled') return t('autofix.k2fc06d90')
|
||||||
|
return getUserStatusLabel(t, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleLabel(t: TFunction, value: UserRole): string {
|
||||||
|
return getUserRoleLabel(t, value as ManagedUserRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManagementFilters({
|
||||||
|
t,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
fType,
|
||||||
|
setFType,
|
||||||
|
fStatus,
|
||||||
|
setFStatus,
|
||||||
|
fRole,
|
||||||
|
setFRole,
|
||||||
|
statusOptions,
|
||||||
|
typeOptions,
|
||||||
|
roleOptions,
|
||||||
|
onSubmit,
|
||||||
|
onExportCsv,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onSubmit()
|
||||||
|
}}
|
||||||
|
className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-5"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.kd1f35ccf')}</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="sr-only">{t('autofix.k91a76444')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder={t('autofix.k8b71f0c7')}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-3 pl-10 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={fType}
|
||||||
|
onChange={(event) => setFType(event.target.value as 'all' | UserType)}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k10e2568f')}</option>
|
||||||
|
{typeOptions.map((type) => (
|
||||||
|
<option key={type} value={type}>{getTypeLabel(t, type)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={fStatus}
|
||||||
|
onChange={(event) => setFStatus(event.target.value as 'all' | UserStatus)}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k2e8f3110')}</option>
|
||||||
|
{statusOptions.map((status) => (
|
||||||
|
<option key={status} value={status}>{getStatusLabel(t, status)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={fRole}
|
||||||
|
onChange={(event) => setFRole(event.target.value as 'all' | UserRole)}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k110bae43')}</option>
|
||||||
|
{roleOptions.map((role) => (
|
||||||
|
<option key={role} value={role}>{getRoleLabel(t, role)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExportCsv}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-50 transition"
|
||||||
|
title={t('autofix.k1387f81e')}
|
||||||
|
>
|
||||||
|
{t('autofix.k1521a376')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.k3ae7a0c0')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
type TFunction = (key: string) => string
|
||||||
|
|
||||||
|
export default function UserManagementHeader({ t }: { t: TFunction }) {
|
||||||
|
return (
|
||||||
|
<header className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
|
{t('autofix.k6f4e16a2')}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-3xl sm:text-4xl font-extrabold text-slate-900 tracking-tight break-words">
|
||||||
|
{t('autofix.k1af97a07')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm sm:text-base text-slate-600 mt-2 break-words">
|
||||||
|
{t('autofix.k79e1c459')}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
type TFunction = (key: string) => string
|
||||||
|
|
||||||
|
type Stats = {
|
||||||
|
total: number
|
||||||
|
admins: number
|
||||||
|
guests: number
|
||||||
|
personal: number
|
||||||
|
company: number
|
||||||
|
active: number
|
||||||
|
pending: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, valueClassName }: { label: string; value: number; valueClassName?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-white/80 bg-white/90 p-5 shadow-[0_24px_70px_-44px_rgba(15,23,42,0.3)] backdrop-blur text-center">
|
||||||
|
<div className="text-xs text-slate-500">{label}</div>
|
||||||
|
<div className={`text-2xl font-semibold mt-1 ${valueClassName || 'text-slate-900'}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManagementStats({ t, stats }: { t: TFunction; stats: Stats }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-5">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.k20a59c89')}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-900 hover:bg-amber-100 transition"
|
||||||
|
onClick={() => window.location.assign('/admin/user-verify')}
|
||||||
|
>
|
||||||
|
{t('autofix.k2f78fabe')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid [grid-template-columns:repeat(auto-fit,minmax(10rem,1fr))] gap-4">
|
||||||
|
<StatCard label={t('autofix.kb324fb25')} value={stats.total} />
|
||||||
|
<StatCard label={t('autofix.k107562d0')} value={stats.admins} valueClassName="text-indigo-700" />
|
||||||
|
<StatCard label={t('autofix.k1da4ef38')} value={stats.guests} valueClassName="text-amber-700" />
|
||||||
|
<StatCard label={t('autofix.kf1882b08')} value={stats.personal} valueClassName="text-sky-700" />
|
||||||
|
<StatCard label={t('autofix.k56f0ef1f')} value={stats.company} valueClassName="text-violet-700" />
|
||||||
|
<StatCard label={t('autofix.kf6afbb1f')} value={stats.active} valueClassName="text-green-700" />
|
||||||
|
<StatCard label={t('autofix.k8f278f58')} value={stats.pending} valueClassName="text-amber-700" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
src/app/admin/user-management/components/UserManagementTable.tsx
Normal file
194
src/app/admin/user-management/components/UserManagementTable.tsx
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { PencilSquareIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { UserRole, UserRow, UserStatus, UserType } from '../hooks/useUserManagementPageState'
|
||||||
|
import {
|
||||||
|
getUserStatusBadgeClass,
|
||||||
|
getUserStatusLabel,
|
||||||
|
getUserTypeBadgeClass,
|
||||||
|
getUserTypeLabel,
|
||||||
|
getUserRoleBadgeClass,
|
||||||
|
getUserRoleLabel,
|
||||||
|
type ManagedUserStatus,
|
||||||
|
type ManagedUserType,
|
||||||
|
type ManagedUserRole,
|
||||||
|
} from '../constants/userStatusPresentation'
|
||||||
|
|
||||||
|
type TFunction = (key: string) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: TFunction
|
||||||
|
loading: boolean
|
||||||
|
users: UserRow[]
|
||||||
|
totalFiltered: number
|
||||||
|
page: number
|
||||||
|
totalPages: number
|
||||||
|
onPageChange: (nextPage: number) => void
|
||||||
|
onEdit: (id: string) => void
|
||||||
|
normalizeStatus: (status: string) => UserStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(text: string, color: 'blue' | 'amber' | 'green' | 'gray' | 'rose' | 'indigo' | 'violet') {
|
||||||
|
const base = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide'
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
blue: 'bg-sky-100 text-sky-700',
|
||||||
|
amber: 'bg-amber-100 text-amber-700',
|
||||||
|
green: 'bg-green-100 text-green-700',
|
||||||
|
gray: 'bg-slate-100 text-slate-700',
|
||||||
|
rose: 'bg-rose-100 text-rose-700',
|
||||||
|
indigo: 'bg-indigo-100 text-indigo-700',
|
||||||
|
violet: 'bg-violet-100 text-violet-700',
|
||||||
|
}
|
||||||
|
return <span className={`${base} ${colorMap[color]}`}>{text}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status: UserStatus, t: TFunction) {
|
||||||
|
if (status === 'disabled') return badge(t('autofix.k2fc06d90'), 'gray')
|
||||||
|
const managedStatus = status as ManagedUserStatus
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide border ${getUserStatusBadgeClass(managedStatus)}`}
|
||||||
|
>
|
||||||
|
{getUserStatusLabel(t, managedStatus)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadge(type: UserType, t: TFunction) {
|
||||||
|
const managedType = type as ManagedUserType
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide ${getUserTypeBadgeClass(managedType)}`}>
|
||||||
|
{getUserTypeLabel(t, managedType)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleBadge(role: UserRole, t: TFunction) {
|
||||||
|
const managedRole = role as ManagedUserRole
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide ${getUserRoleBadgeClass(managedRole)}`}>
|
||||||
|
{getUserRoleLabel(t, managedRole)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManagementTable({
|
||||||
|
t,
|
||||||
|
loading,
|
||||||
|
users,
|
||||||
|
totalFiltered,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
onEdit,
|
||||||
|
normalizeStatus,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.k10ccb626')}</h2>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{t('autofix.k2e41c8dc').replace('{current}', String(users.length)).replace('{total}', String(totalFiltered))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-slate-200/70 bg-white/70 p-1">
|
||||||
|
<table className="min-w-full text-sm rounded-xl overflow-hidden">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-50 text-left text-slate-900">
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.k91f49568')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.kec4fe9ef')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.k81c0b74b')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.ked760737')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.kf123704b')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.kb24782ec')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.k0afbbac4')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2 text-slate-700">
|
||||||
|
<div className="h-4 w-4 rounded-full border-2 border-slate-900 border-b-transparent animate-spin" />
|
||||||
|
<span>{t('autofix.k7fa2c4af')}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center text-sm text-slate-600">{t('autofix.k748bf541')}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
users.map((user) => {
|
||||||
|
const displayName = user.user_type === 'company'
|
||||||
|
? user.company_name || t('autofix.k835f1c86')
|
||||||
|
: `${user.first_name || t('autofix.k76870ea8')} ${user.last_name || t('autofix.k2bf5e6ec')}`
|
||||||
|
|
||||||
|
const initials = user.user_type === 'company'
|
||||||
|
? (user.company_name?.[0] || 'C').toUpperCase()
|
||||||
|
: `${user.first_name?.[0] || 'U'}${user.last_name?.[0] || 'U'}`.toUpperCase()
|
||||||
|
|
||||||
|
const createdDate = new Date(user.created_at).toLocaleDateString()
|
||||||
|
const lastLoginDate = user.last_login_at ? new Date(user.last_login_at).toLocaleDateString() : t('autofix.k768f3f4a')
|
||||||
|
const normalizedStatus = normalizeStatus(user.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={user.id} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-slate-900 to-slate-700 text-white text-xs font-semibold shadow">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-900 leading-tight">{displayName}</div>
|
||||||
|
<div className="text-[11px] text-slate-600">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">{typeBadge(user.user_type, t)}</td>
|
||||||
|
<td className="px-4 py-4">{statusBadge(normalizedStatus, t)}</td>
|
||||||
|
<td className="px-4 py-4">{roleBadge(user.role, t)}</td>
|
||||||
|
<td className="px-4 py-4 text-slate-900">{createdDate}</td>
|
||||||
|
<td className="px-4 py-4 text-slate-600 italic">{lastLoginDate}</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(user.id.toString())}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-slate-50 hover:bg-slate-100 text-slate-900 px-3 py-2 text-xs font-medium transition"
|
||||||
|
>
|
||||||
|
<PencilSquareIcon className="h-4 w-4" />
|
||||||
|
{t('autofix.k9f8d7a4f')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<div className="text-xs text-slate-600">
|
||||||
|
{t('autofix.kf03c39b7').replace('{page}', String(page)).replace('{pages}', String(totalPages)).replace('{total}', String(totalFiltered))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-lg border border-slate-200 bg-white hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('autofix.kdb27a82d')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page === totalPages}
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-lg border border-slate-200 bg-white hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('autofix.ka8ea17b8')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
export type ManagedUserStatus = 'active' | 'pending' | 'suspended' | 'inactive' | 'archived'
|
||||||
|
export type ManagedUserType = 'personal' | 'company'
|
||||||
|
export type ManagedUserRole = 'user' | 'admin' | 'guest' | 'super_admin'
|
||||||
|
|
||||||
|
export const USER_STATUS_FILTER_OPTIONS: ManagedUserStatus[] = [
|
||||||
|
'active',
|
||||||
|
'pending',
|
||||||
|
'suspended',
|
||||||
|
'inactive',
|
||||||
|
'archived',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getUserStatusLabelKey(status: ManagedUserStatus): string {
|
||||||
|
if (status === 'active') return 'autofix.kf6afbb1f'
|
||||||
|
if (status === 'pending') return 'autofix.k8f278f58'
|
||||||
|
if (status === 'suspended') return 'autofix.k18bf2a04'
|
||||||
|
if (status === 'inactive') return 'autofix.k2fc06d90'
|
||||||
|
return 'autofix.k9129ea6f'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserStatusLabel(t: (key: string) => string, status: ManagedUserStatus): string {
|
||||||
|
return t(getUserStatusLabelKey(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserStatusBadgeClass(status: ManagedUserStatus): string {
|
||||||
|
if (status === 'active') return 'bg-green-100 text-green-800 border-green-200'
|
||||||
|
if (status === 'pending') return 'bg-amber-100 text-amber-800 border-amber-200'
|
||||||
|
if (status === 'suspended') return 'bg-rose-100 text-rose-800 border-rose-200'
|
||||||
|
return 'bg-slate-100 text-slate-800 border-slate-200'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserTypeLabelKey(type: ManagedUserType): string {
|
||||||
|
return type === 'personal' ? 'autofix.kf9463361' : 'autofix.k7eedf98b'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserTypeLabel(t: (key: string) => string, type: ManagedUserType): string {
|
||||||
|
return t(getUserTypeLabelKey(type))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserTypeBadgeClass(type: ManagedUserType): string {
|
||||||
|
return type === 'personal' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRoleLabelKey(role: ManagedUserRole): string {
|
||||||
|
if (role === 'admin') return 'autofix.k03f9899f'
|
||||||
|
if (role === 'guest') return 'autofix.kdcdca454'
|
||||||
|
if (role === 'super_admin') return 'userDetailModal.superAdmin'
|
||||||
|
return 'autofix.k2bf5e6ec'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRoleLabel(t: (key: string) => string, role: ManagedUserRole): string {
|
||||||
|
return t(getUserRoleLabelKey(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRoleBadgeClass(role: ManagedUserRole): string {
|
||||||
|
if (role === 'admin' || role === 'super_admin') return 'bg-indigo-100 text-indigo-700'
|
||||||
|
if (role === 'guest') return 'bg-amber-100 text-amber-700'
|
||||||
|
return 'bg-slate-100 text-slate-700'
|
||||||
|
}
|
||||||
@ -0,0 +1,225 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useAdminUsers } from '../../../hooks/useAdminUsers'
|
||||||
|
import { AdminAPI } from '../../../utils/api'
|
||||||
|
import useAuthStore from '../../../store/authStore'
|
||||||
|
import { USER_STATUS_FILTER_OPTIONS } from '../constants/userStatusPresentation'
|
||||||
|
|
||||||
|
export type UserType = 'personal' | 'company'
|
||||||
|
export type UserStatus = 'active' | 'pending' | 'disabled' | 'inactive' | 'suspended' | 'archived'
|
||||||
|
export type UserRole = 'user' | 'admin' | 'guest'
|
||||||
|
|
||||||
|
export interface UserRow {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
user_type: UserType
|
||||||
|
role: UserRole
|
||||||
|
created_at: string
|
||||||
|
last_login_at: string | null
|
||||||
|
status: string
|
||||||
|
is_admin_verified: number
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
company_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_OPTIONS: UserStatus[] = [...USER_STATUS_FILTER_OPTIONS]
|
||||||
|
export const TYPE_OPTIONS: UserType[] = ['personal', 'company']
|
||||||
|
export const ROLE_OPTIONS: UserRole[] = ['user', 'admin', 'guest']
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
function normalizeStatus(status: string): UserStatus {
|
||||||
|
const allowed: UserStatus[] = ['active', 'pending', 'suspended', 'inactive', 'archived']
|
||||||
|
return allowed.includes(status as UserStatus) ? (status as UserStatus) : 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCsvValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '""'
|
||||||
|
const escaped = String(value).replace(/"/g, '""')
|
||||||
|
return `"${escaped}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserManagementPageState() {
|
||||||
|
const { isAdmin } = useAdminUsers()
|
||||||
|
const token = useAuthStore((state) => state.accessToken)
|
||||||
|
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
const [users, setUsers] = useState<UserRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [fType, setFType] = useState<'all' | UserType>('all')
|
||||||
|
const [fStatus, setFStatus] = useState<'all' | UserStatus>('all')
|
||||||
|
const [fRole, setFRole] = useState<'all' | UserRole>('all')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchAllUsers = useCallback(async () => {
|
||||||
|
if (!token || !isAdmin) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await AdminAPI.getUserList(token)
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to fetch users')
|
||||||
|
}
|
||||||
|
setUsers(response.users || [])
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to fetch users'
|
||||||
|
setError(message)
|
||||||
|
console.error('useUserManagementPageState.fetchAllUsers error:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [token, isAdmin])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isClient && token && isAdmin) {
|
||||||
|
void fetchAllUsers()
|
||||||
|
}
|
||||||
|
}, [isClient, token, isAdmin, fetchAllUsers])
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
return users.filter((user) => {
|
||||||
|
const firstName = user.first_name || ''
|
||||||
|
const lastName = user.last_name || ''
|
||||||
|
const companyName = user.company_name || ''
|
||||||
|
const fullName = user.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
||||||
|
const normalizedStatus = normalizeStatus(user.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
(fType === 'all' || user.user_type === fType) &&
|
||||||
|
(fStatus === 'all' || normalizedStatus === fStatus) &&
|
||||||
|
(fRole === 'all' || user.role === fRole) &&
|
||||||
|
(!search.trim() ||
|
||||||
|
user.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
fullName.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [users, search, fType, fStatus, fRole])
|
||||||
|
|
||||||
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(filteredUsers.length / PAGE_SIZE)), [filteredUsers.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages)
|
||||||
|
}
|
||||||
|
}, [page, totalPages])
|
||||||
|
|
||||||
|
const currentUsers = useMemo(() => {
|
||||||
|
const start = (page - 1) * PAGE_SIZE
|
||||||
|
return filteredUsers.slice(start, start + PAGE_SIZE)
|
||||||
|
}, [filteredUsers, page])
|
||||||
|
|
||||||
|
const stats = useMemo(
|
||||||
|
() => ({
|
||||||
|
total: users.length,
|
||||||
|
admins: users.filter((user) => user.role === 'admin').length,
|
||||||
|
guests: users.filter((user) => user.role === 'guest').length,
|
||||||
|
personal: users.filter((user) => user.user_type === 'personal').length,
|
||||||
|
company: users.filter((user) => user.user_type === 'company').length,
|
||||||
|
active: users.filter((user) => normalizeStatus(user.status) === 'active').length,
|
||||||
|
pending: users.filter((user) => normalizeStatus(user.status) === 'pending').length,
|
||||||
|
}),
|
||||||
|
[users]
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportCsv = () => {
|
||||||
|
const headers = [
|
||||||
|
'ID',
|
||||||
|
'Email',
|
||||||
|
'Type',
|
||||||
|
'Role',
|
||||||
|
'Status',
|
||||||
|
'Admin Verified',
|
||||||
|
'First Name',
|
||||||
|
'Last Name',
|
||||||
|
'Company Name',
|
||||||
|
'Created At',
|
||||||
|
'Last Login At',
|
||||||
|
]
|
||||||
|
|
||||||
|
const rows = filteredUsers.map((user) => {
|
||||||
|
return [
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
user.user_type,
|
||||||
|
user.role,
|
||||||
|
normalizeStatus(user.status),
|
||||||
|
user.is_admin_verified === 1 ? 'yes' : 'no',
|
||||||
|
user.first_name || '',
|
||||||
|
user.last_name || '',
|
||||||
|
user.company_name || '',
|
||||||
|
new Date(user.created_at).toISOString(),
|
||||||
|
user.last_login_at ? new Date(user.last_login_at).toISOString() : '',
|
||||||
|
]
|
||||||
|
.map(toCsvValue)
|
||||||
|
.join(',')
|
||||||
|
})
|
||||||
|
|
||||||
|
const csv = [headers.join(','), ...rows].join('\r\n')
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = `users_${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
anchor.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openUserDetail = (userId: string) => {
|
||||||
|
setSelectedUserId(userId)
|
||||||
|
setIsDetailModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeUserDetail = () => {
|
||||||
|
setIsDetailModalOpen(false)
|
||||||
|
setSelectedUserId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isClient,
|
||||||
|
isAdmin,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
stats,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
fType,
|
||||||
|
setFType,
|
||||||
|
fStatus,
|
||||||
|
setFStatus,
|
||||||
|
fRole,
|
||||||
|
setFRole,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
totalPages,
|
||||||
|
filteredUsers,
|
||||||
|
currentUsers,
|
||||||
|
fetchAllUsers,
|
||||||
|
applyFilters,
|
||||||
|
exportCsv,
|
||||||
|
isDetailModalOpen,
|
||||||
|
selectedUserId,
|
||||||
|
openUserDetail,
|
||||||
|
closeUserDetail,
|
||||||
|
normalizeStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,524 +1,127 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
import PageLayout from '../../components/PageLayout'
|
import PageLayout from '../../components/PageLayout'
|
||||||
import UserDetailModal from '../../components/UserDetailModal'
|
import UserDetailModal from '../../components/UserDetailModal'
|
||||||
|
import UserManagementHeader from './components/UserManagementHeader'
|
||||||
|
import UserManagementStats from './components/UserManagementStats'
|
||||||
|
import UserManagementFilters from './components/UserManagementFilters'
|
||||||
|
import UserManagementTable from './components/UserManagementTable'
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
UserManagementAccessDenied,
|
||||||
PencilSquareIcon,
|
UserManagementInitialLoading,
|
||||||
ExclamationTriangleIcon
|
} from './components/UserManagementAccessStates'
|
||||||
} from '@heroicons/react/24/outline'
|
import {
|
||||||
import { useAdminUsers } from '../../hooks/useAdminUsers'
|
ROLE_OPTIONS,
|
||||||
import { AdminAPI } from '../../utils/api'
|
STATUS_OPTIONS,
|
||||||
import useAuthStore from '../../store/authStore'
|
TYPE_OPTIONS,
|
||||||
|
useUserManagementPageState,
|
||||||
type UserType = 'personal' | 'company'
|
} from './hooks/useUserManagementPageState'
|
||||||
type UserStatus = 'active' | 'pending' | 'disabled' | 'inactive' | 'suspended' | 'archived'
|
|
||||||
type UserRole = 'user' | 'admin' | 'guest'
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: number
|
|
||||||
email: string
|
|
||||||
user_type: UserType
|
|
||||||
role: UserRole
|
|
||||||
created_at: string
|
|
||||||
last_login_at: string | null
|
|
||||||
status: string
|
|
||||||
is_admin_verified: number
|
|
||||||
first_name?: string
|
|
||||||
last_name?: string
|
|
||||||
company_name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUSES: UserStatus[] = ['active','pending','disabled','inactive']
|
|
||||||
const TYPES: UserType[] = ['personal','company']
|
|
||||||
const ROLES: UserRole[] = ['user','admin','guest']
|
|
||||||
|
|
||||||
export default function AdminUserManagementPage() {
|
export default function AdminUserManagementPage() {
|
||||||
const { isAdmin } = useAdminUsers()
|
const { t } = useTranslation()
|
||||||
const token = useAuthStore(state => state.accessToken)
|
const {
|
||||||
const [isClient, setIsClient] = useState(false)
|
isClient,
|
||||||
|
isAdmin,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
stats,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
fType,
|
||||||
|
setFType,
|
||||||
|
fStatus,
|
||||||
|
setFStatus,
|
||||||
|
fRole,
|
||||||
|
setFRole,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
totalPages,
|
||||||
|
filteredUsers,
|
||||||
|
currentUsers,
|
||||||
|
fetchAllUsers,
|
||||||
|
applyFilters,
|
||||||
|
exportCsv,
|
||||||
|
isDetailModalOpen,
|
||||||
|
selectedUserId,
|
||||||
|
openUserDetail,
|
||||||
|
closeUserDetail,
|
||||||
|
normalizeStatus,
|
||||||
|
} = useUserManagementPageState()
|
||||||
|
|
||||||
// State for all users (not just pending)
|
|
||||||
const [allUsers, setAllUsers] = useState<User[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Handle client-side mounting
|
|
||||||
useEffect(() => {
|
|
||||||
setIsClient(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Fetch all users from backend
|
|
||||||
const fetchAllUsers = useCallback(async () => {
|
|
||||||
if (!token || !isAdmin) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await AdminAPI.getUserList(token)
|
|
||||||
if (response.success) {
|
|
||||||
setAllUsers(response.users || [])
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || 'Failed to fetch users')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch users'
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('AdminUserManagement.fetchAllUsers error:', err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [token, isAdmin])
|
|
||||||
|
|
||||||
// Load users on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (isClient && isAdmin && token) {
|
|
||||||
fetchAllUsers()
|
|
||||||
}
|
|
||||||
}, [fetchAllUsers, isClient])
|
|
||||||
|
|
||||||
// Filter hooks - must be declared before conditional returns
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [fType, setFType] = useState<'all'|UserType>('all')
|
|
||||||
const [fStatus, setFStatus] = useState<'all'|UserStatus>('all')
|
|
||||||
const [fRole, setFRole] = useState<'all'|UserRole>('all')
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const PAGE_SIZE = 10
|
|
||||||
|
|
||||||
// Modal state
|
|
||||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
return allUsers.filter(u => {
|
|
||||||
const firstName = u.first_name || ''
|
|
||||||
const lastName = u.last_name || ''
|
|
||||||
const companyName = u.company_name || ''
|
|
||||||
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
|
||||||
|
|
||||||
// Use backend status directly for filtering
|
|
||||||
const allowedStatuses: UserStatus[] = ['pending','active','suspended','inactive','archived']
|
|
||||||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
|
||||||
|
|
||||||
return (
|
|
||||||
(fType === 'all' || u.user_type === fType) &&
|
|
||||||
(fStatus === 'all' || userStatus === fStatus) &&
|
|
||||||
(fRole === 'all' || u.role === fRole) &&
|
|
||||||
(
|
|
||||||
!search.trim() ||
|
|
||||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
fullName.toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [allUsers, search, fType, fStatus, fRole])
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
|
||||||
const current = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
|
||||||
|
|
||||||
// Move stats calculation above all conditional returns to avoid hook order errors
|
|
||||||
const stats = useMemo(() => ({
|
|
||||||
total: allUsers.length,
|
|
||||||
admins: allUsers.filter(u => u.role === 'admin').length,
|
|
||||||
guests: allUsers.filter(u => u.role === 'guest').length,
|
|
||||||
personal: allUsers.filter(u => u.user_type === 'personal').length,
|
|
||||||
company: allUsers.filter(u => u.user_type === 'company').length,
|
|
||||||
active: allUsers.filter(u => u.status === 'active').length,
|
|
||||||
pending: allUsers.filter(u => u.status === 'pending').length,
|
|
||||||
}), [allUsers])
|
|
||||||
|
|
||||||
// Show loading during SSR/initial client render
|
|
||||||
if (!isClient) {
|
if (!isClient) {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
<UserManagementInitialLoading t={t} />
|
||||||
<div className="text-center">
|
|
||||||
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
|
|
||||||
<p className="text-blue-900">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access check (only after client-side hydration)
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
<UserManagementAccessDenied t={t} />
|
||||||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
|
||||||
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
|
|
||||||
<p className="text-gray-600">You need admin privileges to access this page.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyFilter = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: CSV export utilities (exports all filtered results, not only current page)
|
|
||||||
const toCsvValue = (v: unknown) => {
|
|
||||||
if (v === null || v === undefined) return '""'
|
|
||||||
const s = String(v).replace(/"/g, '""')
|
|
||||||
return `"${s}"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportCsv = () => {
|
|
||||||
const headers = [
|
|
||||||
'ID','Email','Type','Role','Status','Admin Verified',
|
|
||||||
'First Name','Last Name','Company Name','Created At','Last Login At'
|
|
||||||
]
|
|
||||||
const rows = filtered.map(u => {
|
|
||||||
// Use backend status directly
|
|
||||||
const allowedStatuses: UserStatus[] = ['active','pending','suspended','inactive','archived']
|
|
||||||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
|
||||||
return [
|
|
||||||
u.id,
|
|
||||||
u.email,
|
|
||||||
u.user_type,
|
|
||||||
u.role,
|
|
||||||
userStatus,
|
|
||||||
u.is_admin_verified === 1 ? 'yes' : 'no',
|
|
||||||
u.first_name || '',
|
|
||||||
u.last_name || '',
|
|
||||||
u.company_name || '',
|
|
||||||
new Date(u.created_at).toISOString(),
|
|
||||||
u.last_login_at ? new Date(u.last_login_at).toISOString() : ''
|
|
||||||
].map(toCsvValue).join(',')
|
|
||||||
})
|
|
||||||
const csv = [headers.join(','), ...rows].join('\r\n')
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `users_${new Date().toISOString().slice(0,10)}.csv`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
a.remove()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const badge = (text: string, color: 'blue'|'amber'|'green'|'gray'|'rose'|'indigo'|'purple') => {
|
|
||||||
const base = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide'
|
|
||||||
const map: Record<string,string> = {
|
|
||||||
blue: 'bg-blue-100 text-blue-700',
|
|
||||||
amber: 'bg-amber-100 text-amber-700',
|
|
||||||
green: 'bg-green-100 text-green-700',
|
|
||||||
gray: 'bg-gray-100 text-gray-700',
|
|
||||||
rose: 'bg-rose-100 text-rose-700',
|
|
||||||
indigo: 'bg-indigo-100 text-indigo-700',
|
|
||||||
purple: 'bg-purple-100 text-purple-700'
|
|
||||||
}
|
|
||||||
return <span className={`${base} ${map[color]}`}>{text}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusBadge = (s: UserStatus) =>
|
|
||||||
s==='active' ? badge('Active','green')
|
|
||||||
: s==='pending' ? badge('Pending','amber')
|
|
||||||
: s==='suspended' ? badge('Suspended','rose')
|
|
||||||
: s==='archived' ? badge('Archived','gray')
|
|
||||||
: s==='inactive' ? badge('Inactive','gray')
|
|
||||||
: badge('Unknown','gray')
|
|
||||||
|
|
||||||
const typeBadge = (t: UserType) =>
|
|
||||||
t==='personal' ? badge('Personal','blue') : badge('Company','purple')
|
|
||||||
|
|
||||||
const roleBadge = (r: UserRole) =>
|
|
||||||
r==='admin' ? badge('Admin','indigo') : r==='guest' ? badge('Guest','amber') : badge('User','gray')
|
|
||||||
|
|
||||||
// Action handler for opening edit modal
|
|
||||||
const onEdit = (id: string) => {
|
|
||||||
setSelectedUserId(id)
|
|
||||||
setIsDetailModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
||||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
{/* Header */}
|
<UserManagementHeader t={t} />
|
||||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">User Management</h1>
|
|
||||||
<p className="text-lg text-blue-700 mt-2">
|
|
||||||
Manage all users, view statistics, and handle verification.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Statistic Section + Verify Button */}
|
<UserManagementStats t={t} stats={stats} />
|
||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center gap-6">
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-7 gap-6 flex-1">
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Total Users</div>
|
|
||||||
<div className="text-xl font-semibold text-blue-900">{stats.total}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Admins</div>
|
|
||||||
<div className="text-xl font-semibold text-indigo-700">{stats.admins}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Guests</div>
|
|
||||||
<div className="text-xl font-semibold text-amber-700">{stats.guests}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Personal</div>
|
|
||||||
<div className="text-xl font-semibold text-blue-700">{stats.personal}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Company</div>
|
|
||||||
<div className="text-xl font-semibold text-purple-700">{stats.company}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Active</div>
|
|
||||||
<div className="text-xl font-semibold text-green-700">{stats.active}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Pending</div>
|
|
||||||
<div className="text-xl font-semibold text-amber-700">{stats.pending}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-amber-100 hover:bg-amber-200 border border-amber-200 text-amber-800 text-base font-semibold px-5 py-3 shadow transition"
|
|
||||||
onClick={() => window.location.href = '/admin/user-verify'}
|
|
||||||
>
|
|
||||||
Go to User Verification
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl border border-red-300 bg-red-50 text-red-700 px-6 py-5 flex gap-3 items-start mb-8 shadow">
|
<div className="rounded-2xl border border-red-200 bg-red-50/90 px-4 py-3 text-sm text-red-800 backdrop-blur-sm">
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
<p className="font-semibold">{t('autofix.kbbefb159')}</p>
|
||||||
<div>
|
<p className="text-red-700 mt-0.5">{error}</p>
|
||||||
<p className="font-semibold">Error loading users</p>
|
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
|
||||||
<button
|
<button
|
||||||
onClick={fetchAllUsers}
|
onClick={() => {
|
||||||
|
void fetchAllUsers()
|
||||||
|
}}
|
||||||
className="mt-2 text-sm underline hover:no-underline"
|
className="mt-2 text-sm underline hover:no-underline"
|
||||||
>
|
>
|
||||||
Try again
|
{t('autofix.k3b7dd87a')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filter Card */}
|
<UserManagementFilters
|
||||||
<form
|
t={t}
|
||||||
onSubmit={applyFilter}
|
search={search}
|
||||||
className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 px-8 py-8 flex flex-col gap-6 mb-8"
|
setSearch={setSearch}
|
||||||
>
|
fType={fType}
|
||||||
<h2 className="text-lg font-semibold text-blue-900">
|
setFType={setFType}
|
||||||
Search & Filter Users
|
fStatus={fStatus}
|
||||||
</h2>
|
setFStatus={setFStatus}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
fRole={fRole}
|
||||||
{/* Search */}
|
setFRole={setFRole}
|
||||||
<div className="md:col-span-2">
|
statusOptions={STATUS_OPTIONS}
|
||||||
<label className="sr-only">Search</label>
|
typeOptions={TYPE_OPTIONS}
|
||||||
<div className="relative">
|
roleOptions={ROLE_OPTIONS}
|
||||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-blue-300" />
|
onSubmit={applyFilters}
|
||||||
<input
|
onExportCsv={exportCsv}
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder="Email, name, company..."
|
|
||||||
className="w-full rounded-lg border border-gray-300 pl-10 pr-3 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Type */}
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={fType}
|
|
||||||
onChange={e => setFType(e.target.value as any)}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">All Types</option>
|
|
||||||
<option value="personal">Personal</option>
|
|
||||||
<option value="company">Company</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/* Status */}
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={fStatus}
|
|
||||||
onChange={e => setFStatus(e.target.value as any)}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">All Status</option>
|
|
||||||
{STATUSES.map(s => <option key={s} value={s}>{s[0].toUpperCase()+s.slice(1)}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/* Role */}
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={fRole}
|
|
||||||
onChange={e => setFRole(e.target.value as any)}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">All Roles</option>
|
|
||||||
{ROLES.map(r => <option key={r} value={r}>{r[0].toUpperCase()+r.slice(1)}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={exportCsv}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-blue-900 text-sm font-semibold px-5 py-3 shadow transition"
|
|
||||||
title="Export all filtered users to CSV"
|
|
||||||
>
|
|
||||||
Export all users as CSV
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 text-sm font-semibold px-5 py-3 shadow transition"
|
|
||||||
>
|
|
||||||
Filter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Users Table */}
|
<UserManagementTable
|
||||||
<div className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 overflow-hidden mb-8">
|
t={t}
|
||||||
<div className="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
|
loading={loading}
|
||||||
<div className="text-lg font-semibold text-blue-900">
|
users={currentUsers}
|
||||||
All Users
|
totalFiltered={filteredUsers.length}
|
||||||
</div>
|
page={page}
|
||||||
<div className="text-xs text-gray-500">
|
totalPages={totalPages}
|
||||||
Showing {current.length} of {filtered.length} users
|
onPageChange={setPage}
|
||||||
</div>
|
onEdit={openUserDetail}
|
||||||
</div>
|
normalizeStatus={normalizeStatus}
|
||||||
<div className="overflow-x-auto">
|
/>
|
||||||
<table className="min-w-full divide-y divide-gray-100 text-sm">
|
|
||||||
<thead className="bg-blue-50 text-blue-900 font-medium">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left">User</th>
|
|
||||||
<th className="px-4 py-3 text-left">Type</th>
|
|
||||||
<th className="px-4 py-3 text-left">Status</th>
|
|
||||||
<th className="px-4 py-3 text-left">Role</th>
|
|
||||||
<th className="px-4 py-3 text-left">Created</th>
|
|
||||||
<th className="px-4 py-3 text-left">Last Login</th>
|
|
||||||
<th className="px-4 py-3 text-left">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{loading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-4 py-10 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<div className="h-4 w-4 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
|
||||||
<span className="text-sm text-blue-900">Loading users...</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : current.map(u => {
|
|
||||||
const displayName = u.user_type === 'company'
|
|
||||||
? u.company_name || 'Unknown Company'
|
|
||||||
: `${u.first_name || 'Unknown'} ${u.last_name || 'User'}`
|
|
||||||
|
|
||||||
const initials = u.user_type === 'company'
|
|
||||||
? (u.company_name?.[0] || 'C').toUpperCase()
|
|
||||||
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
|
|
||||||
|
|
||||||
// Use backend status directly for display to avoid desync
|
|
||||||
const allowedStatuses: UserStatus[] = ['active','pending','suspended','inactive','archived']
|
|
||||||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
|
||||||
|
|
||||||
const createdDate = new Date(u.created_at).toLocaleDateString()
|
|
||||||
const lastLoginDate = u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={u.id} className="hover:bg-blue-50">
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-900 to-blue-700 text-white text-xs font-semibold shadow">
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-blue-900 leading-tight">
|
|
||||||
{displayName}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-blue-700">
|
|
||||||
{u.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">{typeBadge(u.user_type)}</td>
|
|
||||||
<td className="px-4 py-4">{statusBadge(userStatus)}</td>
|
|
||||||
<td className="px-4 py-4">{roleBadge(u.role)}</td>
|
|
||||||
<td className="px-4 py-4 text-blue-900">{createdDate}</td>
|
|
||||||
<td className="px-4 py-4 text-blue-700 italic">
|
|
||||||
{lastLoginDate}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onEdit(u.id.toString())}
|
|
||||||
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 text-blue-900 px-3 py-2 text-xs font-medium transition"
|
|
||||||
>
|
|
||||||
<PencilSquareIcon className="h-4 w-4" /> Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{current.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">
|
|
||||||
No users match current filters.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-8 py-6 bg-blue-50 border-t border-blue-100">
|
|
||||||
<div className="text-xs text-blue-700">
|
|
||||||
Page {page} of {totalPages} ({filtered.length} total users)
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
disabled={page===1}
|
|
||||||
onClick={() => setPage(p => Math.max(1,p-1))}
|
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
‹ Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
disabled={page===totalPages}
|
|
||||||
onClick={() => setPage(p => Math.min(totalPages,p+1))}
|
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next ›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Detail Modal */}
|
|
||||||
<UserDetailModal
|
<UserDetailModal
|
||||||
isOpen={isDetailModalOpen}
|
isOpen={isDetailModalOpen}
|
||||||
onClose={() => {
|
onClose={closeUserDetail}
|
||||||
setIsDetailModalOpen(false)
|
|
||||||
setSelectedUserId(null)
|
|
||||||
}}
|
|
||||||
userId={selectedUserId}
|
userId={selectedUserId}
|
||||||
onUserUpdated={fetchAllUsers}
|
onUserUpdated={fetchAllUsers}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
export function UserVerifyInitialLoading({ t }: { t: Translator }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.12),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.12),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#eef2ff_100%)]">
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/85 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] backdrop-blur">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="h-12 w-12 rounded-full border-2 border-slate-900 border-b-transparent animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-slate-900">{t('autofix.k1e4d7a90')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserVerifyAccessDenied({ t }: { t: Translator }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.12),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.12),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#eef2ff_100%)]">
|
||||||
|
<div className="mx-auto w-full max-w-xl rounded-[28px] border border-white/80 bg-white/85 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] p-8 backdrop-blur">
|
||||||
|
<div className="text-center">
|
||||||
|
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold text-red-600 mb-2 break-words">{t('autofix.k26fbc186')}</h1>
|
||||||
|
<p className="text-slate-600 break-words">{t('autofix.k661c032b')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
151
src/app/admin/user-verify/components/UserVerifyFilters.tsx
Normal file
151
src/app/admin/user-verify/components/UserVerifyFilters.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type {
|
||||||
|
StatusFilter,
|
||||||
|
UserRole,
|
||||||
|
UserType,
|
||||||
|
VerificationReadyFilter,
|
||||||
|
} from '../hooks/useUserVerifyPageState'
|
||||||
|
|
||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
search: string
|
||||||
|
setSearch: (value: string) => void
|
||||||
|
fType: 'all' | UserType
|
||||||
|
setFType: (value: 'all' | UserType) => void
|
||||||
|
fRole: 'all' | UserRole
|
||||||
|
setFRole: (value: 'all' | UserRole) => void
|
||||||
|
fReady: VerificationReadyFilter
|
||||||
|
setFReady: (value: VerificationReadyFilter) => void
|
||||||
|
fStatus: StatusFilter
|
||||||
|
setFStatus: (value: StatusFilter) => void
|
||||||
|
perPage: number
|
||||||
|
setPerPage: (value: number) => void
|
||||||
|
setPage: (page: number) => void
|
||||||
|
onSubmit: (event: React.FormEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserVerifyFilters({
|
||||||
|
t,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
fType,
|
||||||
|
setFType,
|
||||||
|
fRole,
|
||||||
|
setFRole,
|
||||||
|
fReady,
|
||||||
|
setFReady,
|
||||||
|
fStatus,
|
||||||
|
setFStatus,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
setPage,
|
||||||
|
onSubmit,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="rounded-[28px] border border-white/80 bg-white/85 px-6 sm:px-8 py-7 flex flex-col gap-6 mb-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.k85c66f50')}</h2>
|
||||||
|
<div className="flex flex-wrap gap-4 items-end">
|
||||||
|
<div className="min-w-[18rem] flex-[3_1_28rem]">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.k3f4f2b01')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
placeholder={t('autofix.k8b71f0c7')}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white pl-10 pr-3 py-3 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900/20 focus:border-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[13rem] flex-[1.3_1_16rem]">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.k577a012c')}</label>
|
||||||
|
<select
|
||||||
|
value={fType}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFType(event.target.value as 'all' | UserType)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k10e2568f')}</option>
|
||||||
|
<option value="personal">{t('autofix.k8b2f1c77')}</option>
|
||||||
|
<option value="company">{t('autofix.k6c3d4e55')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[13rem] flex-[1.1_1_16rem]">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.k8f1a2c34')}</label>
|
||||||
|
<select
|
||||||
|
value={fRole}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFRole(event.target.value as 'all' | UserRole)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k110bae43')}</option>
|
||||||
|
<option value="user">{t('autofix.k9d0a7b42')}</option>
|
||||||
|
<option value="admin">{t('autofix.k2a6c8d90')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[18rem] flex-[2_1_22rem]">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.k0efd830c')}</label>
|
||||||
|
<select
|
||||||
|
value={fReady}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFReady(event.target.value as VerificationReadyFilter)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k7ab45054')}</option>
|
||||||
|
<option value="ready">{t('autofix.kf27e4502')}</option>
|
||||||
|
<option value="not_ready">{t('autofix.k4e0c889b')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[13rem] flex-[1_1_14rem]">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.k7e2d9a10')}</label>
|
||||||
|
<select
|
||||||
|
value={fStatus}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFStatus(event.target.value as StatusFilter)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k0f1fc266')}</option>
|
||||||
|
<option value="pending">{t('autofix.k1b3d5f78')}</option>
|
||||||
|
<option value="active">{t('autofix.k4c7e9a21')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[11rem] basis-full w-full">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.kd2e35b08')}</label>
|
||||||
|
<select
|
||||||
|
value={perPage}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPerPage(parseInt(event.target.value, 10))
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900"
|
||||||
|
>
|
||||||
|
{[5, 10, 15, 20].map((n) => (
|
||||||
|
<option key={n} value={n}>{n}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
type ErrorCardProps = {
|
||||||
|
t: Translator
|
||||||
|
error: string
|
||||||
|
onRetry: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserVerifyHeader({ t }: { t: Translator }) {
|
||||||
|
return (
|
||||||
|
<header className="rounded-[28px] border border-white/80 bg-white/85 py-8 px-6 sm:px-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] backdrop-blur-md mb-6">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
|
{t('autofix.k5b2c8d67')}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-3xl sm:text-4xl font-extrabold text-slate-900 tracking-tight break-words">{t('autofix.kccde6d86')}</h1>
|
||||||
|
<p className="text-base sm:text-lg text-slate-600 mt-2 break-words">{t('autofix.k5614c806')}</p>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserVerifyErrorCard({ t, error, onRetry }: ErrorCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-red-300 bg-red-50/90 text-red-700 px-6 py-5 flex gap-3 items-start mb-6 shadow-[0_16px_40px_-28px_rgba(185,28,28,0.4)] backdrop-blur-sm">
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{t('autofix.k62d12fab')}</p>
|
||||||
|
<p className="text-sm text-red-600 break-words">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-2 text-sm underline hover:no-underline"
|
||||||
|
>
|
||||||
|
{t('autofix.k3b7dd87a')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
187
src/app/admin/user-verify/components/UserVerifyUsersTable.tsx
Normal file
187
src/app/admin/user-verify/components/UserVerifyUsersTable.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { EyeIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { PendingUser } from '../../../utils/api'
|
||||||
|
|
||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
loading: boolean
|
||||||
|
current: PendingUser[]
|
||||||
|
filteredLength: number
|
||||||
|
page: number
|
||||||
|
totalPages: number
|
||||||
|
onPageChange: (next: number) => void
|
||||||
|
onViewUser: (id: string | number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(template: string, values: Record<string, string | number>) {
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, token: string) => String(values[token] ?? ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeBase = 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium'
|
||||||
|
|
||||||
|
const badge = (text: string, color: string) => (
|
||||||
|
<span className={`${badgeBase} ${color}`}>{text}</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeBadge = (type: 'personal' | 'company', t: Translator) =>
|
||||||
|
type === 'personal'
|
||||||
|
? badge(t('autofix.k8b2f1c77'), 'bg-blue-100 text-blue-700')
|
||||||
|
: badge(t('autofix.k6c3d4e55'), 'bg-purple-100 text-purple-700')
|
||||||
|
|
||||||
|
const roleBadge = (role: 'user' | 'admin', t: Translator) =>
|
||||||
|
role === 'admin'
|
||||||
|
? badge(t('autofix.k2a6c8d90'), 'bg-indigo-100 text-indigo-700')
|
||||||
|
: badge(t('autofix.k9d0a7b42'), 'bg-gray-100 text-gray-700')
|
||||||
|
|
||||||
|
const statusBadge = (status: PendingUser['status'], t: Translator) =>
|
||||||
|
status === 'pending'
|
||||||
|
? badge(t('autofix.k1b3d5f78'), 'bg-amber-100 text-amber-700')
|
||||||
|
: badge(t('autofix.k4c7e9a21'), 'bg-green-100 text-green-700')
|
||||||
|
|
||||||
|
const verificationStatusBadge = (user: PendingUser, t: Translator) => {
|
||||||
|
const completedSteps = [
|
||||||
|
user.email_verified === 1,
|
||||||
|
user.profile_completed === 1,
|
||||||
|
user.documents_uploaded === 1,
|
||||||
|
user.contract_signed === 1,
|
||||||
|
].filter(Boolean).length
|
||||||
|
|
||||||
|
const totalSteps = 4
|
||||||
|
if (completedSteps === totalSteps) {
|
||||||
|
return badge(t('autofix.k5d8a1c63'), 'bg-green-100 text-green-700')
|
||||||
|
}
|
||||||
|
|
||||||
|
return badge(`${completedSteps}/${totalSteps} ${t('autofix.k7a4e2b19')}`, 'bg-gray-100 text-gray-700')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserVerifyUsersTable({
|
||||||
|
t,
|
||||||
|
loading,
|
||||||
|
current,
|
||||||
|
filteredLength,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
onViewUser,
|
||||||
|
}: Props) {
|
||||||
|
const summaryText = formatMessage(t('autofix.k1f8c4a52'), {
|
||||||
|
current: current.length,
|
||||||
|
total: filteredLength,
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginationText = formatMessage(t('autofix.k9b5d2e70'), {
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total: filteredLength,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/85 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] overflow-hidden mb-8 backdrop-blur-md">
|
||||||
|
<div className="px-6 sm:px-8 py-6 border-b border-slate-100 flex items-center justify-between gap-3">
|
||||||
|
<div className="text-lg font-semibold text-slate-900 break-words">{t('autofix.k0da2c941')}</div>
|
||||||
|
<div className="text-xs text-slate-500 break-words">{summaryText}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-slate-100 text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-900 font-medium">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k7c1e5b40')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k8d4a2f16')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k3e9b6c12')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k7e2d9a10')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k8f1a2c34')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k9a5c1e68')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k2f6d9a33')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="h-4 w-4 rounded-full border-2 border-slate-900 border-b-transparent animate-spin" />
|
||||||
|
<span className="text-sm text-slate-900">{t('autofix.k7fa2c4af')}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
current.map((user) => {
|
||||||
|
const displayName =
|
||||||
|
user.user_type === 'company'
|
||||||
|
? user.company_name || t('autofix.k2d7f4a81')
|
||||||
|
: `${user.first_name || t('autofix.k9f3a1e74')} ${user.last_name || t('autofix.k9d0a7b42')}`
|
||||||
|
|
||||||
|
const initials =
|
||||||
|
user.user_type === 'company'
|
||||||
|
? (user.company_name?.[0] || 'C').toUpperCase()
|
||||||
|
: `${user.first_name?.[0] || 'U'}${user.last_name?.[0] || 'U'}`.toUpperCase()
|
||||||
|
|
||||||
|
const createdDate = new Date(user.created_at).toLocaleDateString()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={user.id} className="hover:bg-slate-50/80 transition-colors">
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-slate-900 to-slate-700 text-white text-xs font-semibold shadow shrink-0">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-slate-900 leading-tight break-words">{displayName}</div>
|
||||||
|
<div className="text-[11px] text-slate-600 break-all">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">{typeBadge(user.user_type, t)}</td>
|
||||||
|
<td className="px-4 py-4">{verificationStatusBadge(user, t)}</td>
|
||||||
|
<td className="px-4 py-4">{statusBadge(user.status, t)}</td>
|
||||||
|
<td className="px-4 py-4">{roleBadge(user.role, t)}</td>
|
||||||
|
<td className="px-4 py-4 text-slate-900">{createdDate}</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onViewUser(user.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-white hover:bg-slate-100 text-slate-900 px-3 py-2 text-xs font-medium transition"
|
||||||
|
>
|
||||||
|
<EyeIcon className="h-4 w-4" /> {t('autofix.k1c7b4e52')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center text-sm text-slate-700">
|
||||||
|
{t('autofix.kb4aba3dc')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-6 sm:px-8 py-6 bg-slate-50 border-t border-slate-100">
|
||||||
|
<div className="text-xs text-slate-700 break-words">{paginationText}</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-xl border border-slate-300 bg-white hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('autofix.kdb27a82d')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page === totalPages}
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-xl border border-slate-300 bg-white hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('autofix.ka8ea17b8')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
src/app/admin/user-verify/hooks/useUserVerifyPageState.ts
Normal file
95
src/app/admin/user-verify/hooks/useUserVerifyPageState.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||||
|
import type { PendingUser } from '../../../utils/api'
|
||||||
|
|
||||||
|
export type UserType = 'personal' | 'company'
|
||||||
|
export type UserRole = 'user' | 'admin'
|
||||||
|
export type VerificationReadyFilter = 'all' | 'ready' | 'not_ready'
|
||||||
|
export type StatusFilter = 'all' | 'pending' | 'active'
|
||||||
|
|
||||||
|
export function useUserVerifyPageState(pendingUsers: PendingUser[]) {
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [fType, setFType] = useState<'all' | UserType>('all')
|
||||||
|
const [fRole, setFRole] = useState<'all' | UserRole>('all')
|
||||||
|
const [fReady, setFReady] = useState<VerificationReadyFilter>('all')
|
||||||
|
const [fStatus, setFStatus] = useState<StatusFilter>('all')
|
||||||
|
const [perPage, setPerPage] = useState(10)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return pendingUsers.filter((u) => {
|
||||||
|
const firstName = u.first_name || ''
|
||||||
|
const lastName = u.last_name || ''
|
||||||
|
const companyName = u.company_name || ''
|
||||||
|
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
||||||
|
const isReadyToVerify =
|
||||||
|
u.email_verified === 1 &&
|
||||||
|
u.profile_completed === 1 &&
|
||||||
|
u.documents_uploaded === 1 &&
|
||||||
|
u.contract_signed === 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
(fType === 'all' || u.user_type === fType) &&
|
||||||
|
(fRole === 'all' || u.role === fRole) &&
|
||||||
|
(fStatus === 'all' || u.status === fStatus) &&
|
||||||
|
(fReady === 'all' ||
|
||||||
|
(fReady === 'ready' && isReadyToVerify) ||
|
||||||
|
(fReady === 'not_ready' && !isReadyToVerify)) &&
|
||||||
|
(!search.trim() ||
|
||||||
|
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
fullName.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [pendingUsers, search, fType, fRole, fReady, fStatus])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
|
||||||
|
const current = filtered.slice((page - 1) * perPage, page * perPage)
|
||||||
|
|
||||||
|
const applyFilters = (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openUserDetail = (id: string | number) => {
|
||||||
|
setSelectedUserId(String(id))
|
||||||
|
setIsDetailModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeUserDetail = () => {
|
||||||
|
setIsDetailModalOpen(false)
|
||||||
|
setSelectedUserId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isClient,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
fType,
|
||||||
|
setFType,
|
||||||
|
fRole,
|
||||||
|
setFRole,
|
||||||
|
fReady,
|
||||||
|
setFReady,
|
||||||
|
fStatus,
|
||||||
|
setFStatus,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
filtered,
|
||||||
|
current,
|
||||||
|
totalPages,
|
||||||
|
applyFilters,
|
||||||
|
isDetailModalOpen,
|
||||||
|
selectedUserId,
|
||||||
|
openUserDetail,
|
||||||
|
closeUserDetail,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,131 +1,57 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import PageLayout from '../../components/PageLayout'
|
import PageLayout from '../../components/PageLayout'
|
||||||
import UserDetailModal from '../../components/UserDetailModal'
|
import UserDetailModal from '../../components/UserDetailModal'
|
||||||
import {
|
|
||||||
MagnifyingGlassIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
EyeIcon
|
|
||||||
} from '@heroicons/react/24/outline'
|
|
||||||
import { useAdminUsers } from '../../hooks/useAdminUsers'
|
import { useAdminUsers } from '../../hooks/useAdminUsers'
|
||||||
import { PendingUser } from '../../utils/api'
|
import { UserVerifyAccessDenied, UserVerifyInitialLoading } from './components/UserVerifyAccessStates'
|
||||||
|
import { UserVerifyErrorCard, UserVerifyHeader } from './components/UserVerifyHeaderAndError'
|
||||||
type UserType = 'personal' | 'company'
|
import UserVerifyFilters from './components/UserVerifyFilters'
|
||||||
type UserRole = 'user' | 'admin'
|
import UserVerifyUsersTable from './components/UserVerifyUsersTable'
|
||||||
type VerificationReadyFilter = 'all' | 'ready' | 'not_ready'
|
import { useUserVerifyPageState } from './hooks/useUserVerifyPageState'
|
||||||
type StatusFilter = 'all' | 'pending' | 'active'
|
|
||||||
|
|
||||||
export default function AdminUserVerifyPage() {
|
export default function AdminUserVerifyPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
pendingUsers,
|
pendingUsers,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
fetchPendingUsers
|
fetchPendingUsers,
|
||||||
} = useAdminUsers()
|
} = useAdminUsers()
|
||||||
const [isClient, setIsClient] = useState(false)
|
const {
|
||||||
|
isClient,
|
||||||
// Handle client-side mounting
|
search,
|
||||||
useEffect(() => {
|
setSearch,
|
||||||
setIsClient(true)
|
fType,
|
||||||
}, [])
|
setFType,
|
||||||
const [search, setSearch] = useState('')
|
fRole,
|
||||||
const [fType, setFType] = useState<'all' | UserType>('all')
|
setFRole,
|
||||||
const [fRole, setFRole] = useState<'all' | UserRole>('all')
|
fReady,
|
||||||
const [fReady, setFReady] = useState<VerificationReadyFilter>('all')
|
setFReady,
|
||||||
const [fStatus, setFStatus] = useState<StatusFilter>('all')
|
fStatus,
|
||||||
const [perPage, setPerPage] = useState(10)
|
setFStatus,
|
||||||
const [page, setPage] = useState(1)
|
perPage,
|
||||||
|
setPerPage,
|
||||||
// All computations must be after hooks but before conditional returns
|
page,
|
||||||
const filtered = useMemo(() => {
|
setPage,
|
||||||
return pendingUsers.filter(u => {
|
filtered,
|
||||||
const firstName = u.first_name || ''
|
current,
|
||||||
const lastName = u.last_name || ''
|
totalPages,
|
||||||
const companyName = u.company_name || ''
|
applyFilters,
|
||||||
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
isDetailModalOpen,
|
||||||
const isReadyToVerify = u.email_verified === 1 && u.profile_completed === 1 &&
|
selectedUserId,
|
||||||
u.documents_uploaded === 1 && u.contract_signed === 1
|
openUserDetail,
|
||||||
|
closeUserDetail,
|
||||||
return (
|
} = useUserVerifyPageState(pendingUsers)
|
||||||
(fType === 'all' || u.user_type === fType) &&
|
|
||||||
(fRole === 'all' || u.role === fRole) &&
|
|
||||||
(fStatus === 'all' || u.status === fStatus) &&
|
|
||||||
(
|
|
||||||
fReady === 'all' ||
|
|
||||||
(fReady === 'ready' && isReadyToVerify) ||
|
|
||||||
(fReady === 'not_ready' && !isReadyToVerify)
|
|
||||||
) &&
|
|
||||||
(
|
|
||||||
!search.trim() ||
|
|
||||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
fullName.toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [pendingUsers, search, fType, fRole, fReady, fStatus])
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
|
|
||||||
const current = filtered.slice((page - 1) * perPage, page * perPage)
|
|
||||||
|
|
||||||
// Modal state
|
|
||||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const applyFilters = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const badge = (text: string, color: string) =>
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${color}`}>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
const typeBadge = (t: UserType) =>
|
|
||||||
t === 'personal'
|
|
||||||
? badge('Personal', 'bg-blue-100 text-blue-700')
|
|
||||||
: badge('Company', 'bg-purple-100 text-purple-700')
|
|
||||||
|
|
||||||
const roleBadge = (r: UserRole) =>
|
|
||||||
r === 'admin'
|
|
||||||
? badge('Admin', 'bg-indigo-100 text-indigo-700')
|
|
||||||
: badge('User', 'bg-gray-100 text-gray-700')
|
|
||||||
|
|
||||||
const statusBadge = (s: PendingUser['status']) => {
|
|
||||||
if (s === 'pending') return badge('Pending', 'bg-amber-100 text-amber-700')
|
|
||||||
return badge('Active', 'bg-green-100 text-green-700')
|
|
||||||
}
|
|
||||||
|
|
||||||
const verificationStatusBadge = (user: PendingUser) => {
|
|
||||||
const steps = [
|
|
||||||
{ name: 'Email', completed: user.email_verified === 1 },
|
|
||||||
{ name: 'Profile', completed: user.profile_completed === 1 },
|
|
||||||
{ name: 'Documents', completed: user.documents_uploaded === 1 },
|
|
||||||
{ name: 'Contract', completed: user.contract_signed === 1 }
|
|
||||||
]
|
|
||||||
|
|
||||||
const completedSteps = steps.filter(s => s.completed).length
|
|
||||||
const totalSteps = steps.length
|
|
||||||
|
|
||||||
if (completedSteps === totalSteps) {
|
|
||||||
return badge('Ready to Verify', 'bg-green-100 text-green-700')
|
|
||||||
} else {
|
|
||||||
return badge(`${completedSteps}/${totalSteps} Steps`, 'bg-gray-100 text-gray-700')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading during SSR/initial client render
|
// Show loading during SSR/initial client render
|
||||||
if (!isClient) {
|
if (!isClient) {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
<UserVerifyInitialLoading t={t} />
|
||||||
<div className="text-center">
|
|
||||||
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
|
|
||||||
<p className="text-blue-900">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -134,256 +60,60 @@ export default function AdminUserVerifyPage() {
|
|||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
<UserVerifyAccessDenied t={t} />
|
||||||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
|
||||||
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
|
|
||||||
<p className="text-gray-600">You need admin privileges to access this page.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.12),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.12),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#eef2ff_100%)]">
|
||||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-[1820px] mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Header */}
|
<UserVerifyHeader t={t} />
|
||||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">User Verification Center</h1>
|
|
||||||
<p className="text-lg text-blue-700 mt-2">
|
|
||||||
Review and verify all users who need admin approval. Users must complete all steps before verification.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl border border-red-300 bg-red-50 text-red-700 px-6 py-5 flex gap-3 items-start mb-8 shadow">
|
<UserVerifyErrorCard
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
t={t}
|
||||||
<div>
|
error={error}
|
||||||
<p className="font-semibold">Error loading data</p>
|
onRetry={fetchPendingUsers}
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={fetchPendingUsers}
|
|
||||||
className="mt-2 text-sm underline hover:no-underline"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filter Card */}
|
|
||||||
<form
|
|
||||||
onSubmit={applyFilters}
|
|
||||||
className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 px-8 py-8 flex flex-col gap-6 mb-8"
|
|
||||||
>
|
|
||||||
<h2 className="text-lg font-semibold text-blue-900">
|
|
||||||
Search & Filter Pending Users
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-7 gap-4">
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Search</label>
|
|
||||||
<div className="relative">
|
|
||||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-blue-300" />
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder="Email, name, company..."
|
|
||||||
className="w-full rounded-lg border border-gray-300 pl-10 pr-3 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">User Type</label>
|
|
||||||
<select
|
|
||||||
value={fType}
|
|
||||||
onChange={e => { setFType(e.target.value as any); setPage(1) }}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">All Types</option>
|
|
||||||
<option value="personal">Personal</option>
|
|
||||||
<option value="company">Company</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Role</label>
|
|
||||||
<select
|
|
||||||
value={fRole}
|
|
||||||
onChange={e => { setFRole(e.target.value as any); setPage(1) }}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">All Roles</option>
|
|
||||||
<option value="user">User</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Verification Readiness</label>
|
|
||||||
<select
|
|
||||||
value={fReady}
|
|
||||||
onChange={e => { setFReady(e.target.value as VerificationReadyFilter); setPage(1) }}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">All Readiness</option>
|
|
||||||
<option value="ready">Ready to Verify</option>
|
|
||||||
<option value="not_ready">Not Ready</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Status</label>
|
|
||||||
<select
|
|
||||||
value={fStatus}
|
|
||||||
onChange={e => { setFStatus(e.target.value as StatusFilter); setPage(1) }}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">All Statuses</option>
|
|
||||||
<option value="pending">Pending</option>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Rows per page</label>
|
|
||||||
<select
|
|
||||||
value={perPage}
|
|
||||||
onChange={e => { setPerPage(parseInt(e.target.value, 10)); setPage(1) }}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Pending Users Table */}
|
|
||||||
<div className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 overflow-hidden mb-8">
|
|
||||||
<div className="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
|
|
||||||
<div className="text-lg font-semibold text-blue-900">
|
|
||||||
Users Pending Verification
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Showing {current.length} of {filtered.length} users
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-100 text-sm">
|
|
||||||
<thead className="bg-blue-50 text-blue-900 font-medium">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left">User</th>
|
|
||||||
<th className="px-4 py-3 text-left">Type</th>
|
|
||||||
<th className="px-4 py-3 text-left">Progress</th>
|
|
||||||
<th className="px-4 py-3 text-left">Status</th>
|
|
||||||
<th className="px-4 py-3 text-left">Role</th>
|
|
||||||
<th className="px-4 py-3 text-left">Created</th>
|
|
||||||
<th className="px-4 py-3 text-left">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{loading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-4 py-10 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<div className="h-4 w-4 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
|
||||||
<span className="text-sm text-blue-900">Loading users...</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : current.map(u => {
|
|
||||||
const displayName = u.user_type === 'company'
|
|
||||||
? u.company_name || 'Unknown Company'
|
|
||||||
: `${u.first_name || 'Unknown'} ${u.last_name || 'User'}`
|
|
||||||
|
|
||||||
const initials = u.user_type === 'company'
|
|
||||||
? (u.company_name?.[0] || 'C').toUpperCase()
|
|
||||||
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
|
|
||||||
|
|
||||||
const createdDate = new Date(u.created_at).toLocaleDateString()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={u.id} className="hover:bg-blue-50">
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-900 to-blue-700 text-white text-xs font-semibold shadow">
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-blue-900 leading-tight">
|
|
||||||
{displayName}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-blue-700">{u.email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">{typeBadge(u.user_type)}</td>
|
|
||||||
<td className="px-4 py-4">{verificationStatusBadge(u)}</td>
|
|
||||||
<td className="px-4 py-4">{statusBadge(u.status)}</td>
|
|
||||||
<td className="px-4 py-4">{roleBadge(u.role)}</td>
|
|
||||||
<td className="px-4 py-4 text-blue-900">{createdDate}</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedUserId(u.id.toString())
|
|
||||||
setIsDetailModalOpen(true)
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 text-blue-900 px-3 py-2 text-xs font-medium transition"
|
|
||||||
>
|
|
||||||
<EyeIcon className="h-4 w-4" /> View
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{current.length === 0 && !loading && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">
|
|
||||||
No unverified users match current filters.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
)}
|
||||||
</tbody>
|
|
||||||
</table>
|
<UserVerifyFilters
|
||||||
</div>
|
t={t}
|
||||||
{/* Pagination */}
|
search={search}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-8 py-6 bg-blue-50 border-t border-blue-100">
|
setSearch={setSearch}
|
||||||
<div className="text-xs text-blue-700">
|
fType={fType}
|
||||||
Page {page} of {totalPages} ({filtered.length} pending users)
|
setFType={setFType}
|
||||||
</div>
|
fRole={fRole}
|
||||||
<div className="flex gap-2">
|
setFRole={setFRole}
|
||||||
<button
|
fReady={fReady}
|
||||||
disabled={page === 1}
|
setFReady={setFReady}
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
fStatus={fStatus}
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
setFStatus={setFStatus}
|
||||||
>
|
perPage={perPage}
|
||||||
‹ Previous
|
setPerPage={setPerPage}
|
||||||
</button>
|
setPage={setPage}
|
||||||
<button
|
onSubmit={applyFilters}
|
||||||
disabled={page === totalPages}
|
/>
|
||||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
<UserVerifyUsersTable
|
||||||
>
|
t={t}
|
||||||
Next ›
|
loading={loading}
|
||||||
</button>
|
current={current}
|
||||||
</div>
|
filteredLength={filtered.length}
|
||||||
</div>
|
page={page}
|
||||||
</div>
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onViewUser={openUserDetail}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Detail Modal */}
|
{/* User Detail Modal */}
|
||||||
<UserDetailModal
|
<UserDetailModal
|
||||||
isOpen={isDetailModalOpen}
|
isOpen={isDetailModalOpen}
|
||||||
onClose={() => {
|
onClose={closeUserDetail}
|
||||||
setIsDetailModalOpen(false)
|
|
||||||
setSelectedUserId(null)
|
|
||||||
}}
|
|
||||||
userId={selectedUserId}
|
userId={selectedUserId}
|
||||||
onUserUpdated={() => {
|
onUserUpdated={() => {
|
||||||
fetchPendingUsers()
|
fetchPendingUsers()
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { useEffect, useState, useMemo } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import PageLayout from '../components/PageLayout'
|
import PageLayout from '../components/PageLayout'
|
||||||
import Waves from '../components/background/waves'
|
import Waves from '../components/background/waves'
|
||||||
@ -18,6 +21,7 @@ type Affiliate = {
|
|||||||
const PLACEHOLDER_IMAGE = 'https://images.unsplash.com/photo-1557804506-669a67965ba0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80'
|
const PLACEHOLDER_IMAGE = 'https://images.unsplash.com/photo-1557804506-669a67965ba0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80'
|
||||||
|
|
||||||
export default function AffiliateLinksPage() {
|
export default function AffiliateLinksPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [affiliates, setAffiliates] = useState<Affiliate[]>([])
|
const [affiliates, setAffiliates] = useState<Affiliate[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@ -115,14 +119,12 @@ export default function AffiliateLinksPage() {
|
|||||||
{/* Header (aligned with management pages) */}
|
{/* Header (aligned with management pages) */}
|
||||||
<header className="flex flex-col gap-4 mb-8">
|
<header className="flex flex-col gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Affiliate Partners</h1>
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.k1b9c46e5')}</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">
|
<p className="text-lg text-blue-700 mt-2">{t('autofix.k633438a0')}</p>
|
||||||
Discover our trusted partners and earn commissions through affiliate links.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/* NEW: Category filter */}
|
{/* NEW: Category filter */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm text-blue-900 font-medium">Filter by category:</label>
|
<label className="text-sm text-blue-900 font-medium">{t('autofix.kebf33594')}</label>
|
||||||
<select
|
<select
|
||||||
value={selectedCategory}
|
value={selectedCategory}
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
@ -139,7 +141,7 @@ export default function AffiliateLinksPage() {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<div className="mx-auto max-w-2xl text-center">
|
<div className="mx-auto max-w-2xl text-center">
|
||||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-400 border-b-transparent" />
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-400 border-b-transparent" />
|
||||||
<p className="mt-4 text-sm text-gray-600">Loading affiliate partners...</p>
|
<p className="mt-4 text-sm text-gray-600">{t('autofix.k5834cbed')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -150,9 +152,7 @@ export default function AffiliateLinksPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && posts.length === 0 && (
|
{!loading && !error && posts.length === 0 && (
|
||||||
<div className="mx-auto max-w-2xl text-center text-sm text-gray-600">
|
<div className="mx-auto max-w-2xl text-center text-sm text-gray-600">{t('autofix.k431328cf')}</div>
|
||||||
No affiliate partners available at the moment.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cards (aligned to white panels, border, shadow) */}
|
{/* Cards (aligned to white panels, border, shadow) */}
|
||||||
@ -195,12 +195,8 @@ export default function AffiliateLinksPage() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow transition"
|
className="rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow transition"
|
||||||
>
|
>{t('autofix.k7db4e5a9')}</a>
|
||||||
Visit Affiliate Link
|
<span className="text-[11px] text-gray-500">{t('autofix.k8b89f863')}</span>
|
||||||
</a>
|
|
||||||
<span className="text-[11px] text-gray-500">
|
|
||||||
External partner website.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
227
src/app/api/_utils/backendAuth.ts
Normal file
227
src/app/api/_utils/backendAuth.ts
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
type BackendAuthPayload = {
|
||||||
|
user?: {
|
||||||
|
id?: string;
|
||||||
|
role?: string;
|
||||||
|
roles?: string[];
|
||||||
|
isAdmin?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
role?: string;
|
||||||
|
roles?: string[];
|
||||||
|
isAdmin?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BackendSessionResult = {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
payload: BackendAuthPayload | null;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BackendTokenResult = {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
accessToken: string | null;
|
||||||
|
setCookies: string[];
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function splitSetCookieHeader(header: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
let inExpires = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < header.length; i += 1) {
|
||||||
|
const lower = header.slice(i, i + 8).toLowerCase();
|
||||||
|
if (lower === 'expires=') inExpires = true;
|
||||||
|
if (inExpires && header[i] === ';') inExpires = false;
|
||||||
|
if (!inExpires && header[i] === ',') {
|
||||||
|
parts.push(header.slice(start, i).trim());
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = header.slice(start).trim();
|
||||||
|
if (last) parts.push(last);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSetCookies(response: Response): string[] {
|
||||||
|
const anyHeaders = response.headers as unknown as { getSetCookie?: () => string[] };
|
||||||
|
if (typeof anyHeaders.getSetCookie === 'function') {
|
||||||
|
return anyHeaders.getSetCookie();
|
||||||
|
}
|
||||||
|
|
||||||
|
const single = response.headers.get('set-cookie');
|
||||||
|
return single ? splitSetCookieHeader(single) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendAuthPath(): string {
|
||||||
|
const configured = process.env.BACKEND_AUTH_VALIDATE_PATH?.trim();
|
||||||
|
if (configured) return configured.startsWith('/') ? configured : `/${configured}`;
|
||||||
|
return '/api/auth/validate';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendBaseUrl(): string | null {
|
||||||
|
const value = process.env.NEXT_PUBLIC_API_BASE_URL?.trim();
|
||||||
|
return value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendRefreshPath(): string {
|
||||||
|
const configured = process.env.BACKEND_REFRESH_PATH?.trim();
|
||||||
|
if (configured) return configured.startsWith('/') ? configured : `/${configured}`;
|
||||||
|
return '/api/refresh';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAdminRole(payload: BackendAuthPayload | null): boolean {
|
||||||
|
if (!payload) return false;
|
||||||
|
|
||||||
|
const roleCandidates = [
|
||||||
|
payload.role,
|
||||||
|
payload.user?.role,
|
||||||
|
...(Array.isArray(payload.roles) ? payload.roles : []),
|
||||||
|
...(Array.isArray(payload.user?.roles) ? payload.user.roles : []),
|
||||||
|
]
|
||||||
|
.map((v) => String(v ?? '').toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const explicitAdminFlag = Boolean(payload.isAdmin) || Boolean(payload.user?.isAdmin);
|
||||||
|
return explicitAdminFlag || roleCandidates.includes('admin') || roleCandidates.includes('superadmin');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBackendSession(request: Request): Promise<BackendSessionResult> {
|
||||||
|
const apiBase = resolveBackendBaseUrl();
|
||||||
|
if (!apiBase) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
payload: null,
|
||||||
|
message: 'Missing NEXT_PUBLIC_API_BASE_URL.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = request.headers.get('cookie') ?? '';
|
||||||
|
const authPath = resolveBackendAuthPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBase}${authPath}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
cookie,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as BackendAuthPayload | null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
payload,
|
||||||
|
message: !response.ok
|
||||||
|
? (payload && typeof payload.message === 'string' ? payload.message : 'Authentication failed.')
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 502,
|
||||||
|
payload: null,
|
||||||
|
message: 'Failed to reach backend auth endpoint.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBackendAccessToken(request: Request): Promise<BackendTokenResult> {
|
||||||
|
const apiBase = resolveBackendBaseUrl();
|
||||||
|
if (!apiBase) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
accessToken: null,
|
||||||
|
setCookies: [],
|
||||||
|
message: 'Missing NEXT_PUBLIC_API_BASE_URL.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = request.headers.get('cookie') ?? '';
|
||||||
|
const refreshPath = resolveBackendRefreshPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBase}${refreshPath}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
cookie,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const setCookies = readSetCookies(response);
|
||||||
|
const payload = (await response.json().catch(() => null)) as { accessToken?: string; message?: string } | null;
|
||||||
|
const accessToken = typeof payload?.accessToken === 'string' ? payload.accessToken : null;
|
||||||
|
|
||||||
|
if (!response.ok || !accessToken) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: response.status,
|
||||||
|
accessToken: null,
|
||||||
|
setCookies,
|
||||||
|
message: payload?.message ?? 'Failed to refresh backend access token.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: response.status,
|
||||||
|
accessToken,
|
||||||
|
setCookies,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 502,
|
||||||
|
accessToken: null,
|
||||||
|
setCookies: [],
|
||||||
|
message: 'Failed to reach backend refresh endpoint.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdminSession(request: Request): Promise<{ ok: true; payload: BackendAuthPayload | null } | { ok: false; response: NextResponse }> {
|
||||||
|
const session = await fetchBackendSession(request);
|
||||||
|
if (!session.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: NextResponse.json(
|
||||||
|
{ ok: false, message: session.message ?? 'Unauthorized.' },
|
||||||
|
{ status: session.status === 401 ? 401 : 403 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAdminRole(session.payload)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: NextResponse.json(
|
||||||
|
{ ok: false, message: 'Admin access required.' },
|
||||||
|
{ status: 403 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, payload: session.payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAdminState(payload: BackendAuthPayload | null): { authenticated: boolean; isAdmin: boolean; user: BackendAuthPayload['user'] | null } {
|
||||||
|
const authenticated = Boolean(payload);
|
||||||
|
return {
|
||||||
|
authenticated,
|
||||||
|
isAdmin: hasAdminRole(payload),
|
||||||
|
user: payload?.user ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
28
src/app/api/auth/validate/route.ts
Normal file
28
src/app/api/auth/validate/route.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { extractAdminState, fetchBackendSession } from '../../_utils/backendAuth';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await fetchBackendSession(request);
|
||||||
|
|
||||||
|
if (!session.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
authenticated: false,
|
||||||
|
isAdmin: false,
|
||||||
|
user: null,
|
||||||
|
message: session.message ?? 'Unauthorized.',
|
||||||
|
},
|
||||||
|
{ status: session.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = extractAdminState(session.payload);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
...state,
|
||||||
|
});
|
||||||
|
}
|
||||||
138
src/app/api/i18n/preferences/route.ts
Normal file
138
src/app/api/i18n/preferences/route.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { fetchBackendAccessToken, requireAdminSession } from '../../_utils/backendAuth';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
function resolvePreferencesBackendPath(): string {
|
||||||
|
const configured = process.env.BACKEND_I18N_PREFERENCES_PATH?.trim();
|
||||||
|
if (configured) return configured.startsWith('/') ? configured : `/${configured}`;
|
||||||
|
return '/api/admin/i18n/preferences';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendBaseUrl(): string | null {
|
||||||
|
const value = process.env.NEXT_PUBLIC_API_BASE_URL?.trim();
|
||||||
|
return value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyPreferencesRequest(request: Request, method: 'GET' | 'POST' | 'PUT' | 'DELETE') {
|
||||||
|
console.info('[API][i18n/preferences] start', { method });
|
||||||
|
|
||||||
|
const access = await requireAdminSession(request);
|
||||||
|
if (!access.ok) {
|
||||||
|
console.warn('[API][i18n/preferences] denied:admin-session', { method });
|
||||||
|
return access.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResult = await fetchBackendAccessToken(request);
|
||||||
|
if (!tokenResult.ok || !tokenResult.accessToken) {
|
||||||
|
console.warn('[API][i18n/preferences] denied:access-token', {
|
||||||
|
method,
|
||||||
|
status: tokenResult.status,
|
||||||
|
message: tokenResult.message,
|
||||||
|
});
|
||||||
|
const denied = NextResponse.json(
|
||||||
|
{ ok: false, message: tokenResult.message ?? 'Unable to obtain backend access token.' },
|
||||||
|
{ status: tokenResult.status === 401 ? 401 : 403 }
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const setCookie of tokenResult.setCookies) {
|
||||||
|
denied.headers.append('set-cookie', setCookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
return denied;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBase = resolveBackendBaseUrl();
|
||||||
|
if (!apiBase) {
|
||||||
|
console.error('[API][i18n/preferences] missing NEXT_PUBLIC_API_BASE_URL');
|
||||||
|
return NextResponse.json({ ok: false, message: 'Missing NEXT_PUBLIC_API_BASE_URL.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = request.headers.get('cookie') ?? '';
|
||||||
|
const backendPath = resolvePreferencesBackendPath();
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
cookie,
|
||||||
|
Authorization: `Bearer ${tokenResult.accessToken}`,
|
||||||
|
};
|
||||||
|
let body: string | undefined;
|
||||||
|
|
||||||
|
// Build the backend URL; for DELETE we forward the languageCode query param if present.
|
||||||
|
const incomingUrl = new URL(request.url);
|
||||||
|
const languageCode = incomingUrl.searchParams.get('languageCode');
|
||||||
|
const backendQuery = languageCode ? `?languageCode=${encodeURIComponent(languageCode)}` : '';
|
||||||
|
const backendUrl = `${apiBase}${backendPath}${backendQuery}`;
|
||||||
|
|
||||||
|
if (method !== 'GET' && method !== 'DELETE') {
|
||||||
|
const payload = await request.json().catch(() => ({}));
|
||||||
|
body = JSON.stringify(payload ?? {});
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
|
const categoriesCount = Array.isArray((payload as { categories?: unknown[] })?.categories)
|
||||||
|
? (payload as { categories?: unknown[] }).categories!.length
|
||||||
|
: 0;
|
||||||
|
const globalKeysCount = Array.isArray((payload as { globalKeys?: unknown[] })?.globalKeys)
|
||||||
|
? (payload as { globalKeys?: unknown[] }).globalKeys!.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
console.info('[API][i18n/preferences] outgoing-payload', {
|
||||||
|
method,
|
||||||
|
categoriesCount,
|
||||||
|
globalKeysCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'DELETE' && languageCode) {
|
||||||
|
console.info('[API][i18n/preferences] delete-language', { languageCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendResponse = await fetch(backendUrl, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
cache: 'no-store',
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!backendResponse) {
|
||||||
|
console.error('[API][i18n/preferences] backend unreachable', {
|
||||||
|
method,
|
||||||
|
backendPath,
|
||||||
|
});
|
||||||
|
const failed = NextResponse.json({ ok: false, message: 'Preferences backend is unreachable.' }, { status: 502 });
|
||||||
|
for (const setCookie of tokenResult.setCookies) {
|
||||||
|
failed.headers.append('set-cookie', setCookie);
|
||||||
|
}
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info('[API][i18n/preferences] backend-response', {
|
||||||
|
method,
|
||||||
|
status: backendResponse.status,
|
||||||
|
ok: backendResponse.ok,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await backendResponse.json().catch(() => null);
|
||||||
|
const out = NextResponse.json(payload ?? { ok: backendResponse.ok }, { status: backendResponse.status });
|
||||||
|
|
||||||
|
for (const setCookie of tokenResult.setCookies) {
|
||||||
|
out.headers.append('set-cookie', setCookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
return proxyPreferencesRequest(request, 'GET');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
return proxyPreferencesRequest(request, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
return proxyPreferencesRequest(request, 'PUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
return proxyPreferencesRequest(request, 'DELETE');
|
||||||
|
}
|
||||||
1373
src/app/api/i18n/scan/route.ts
Normal file
1373
src/app/api/i18n/scan/route.ts
Normal file
File diff suppressed because it is too large
Load Diff
326
src/app/api/i18n/translations/route.ts
Normal file
326
src/app/api/i18n/translations/route.ts
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { flattenObject, unflattenObject } from '@/app/i18n/dynamicTranslations';
|
||||||
|
import { requireAdminSession } from '../../_utils/backendAuth';
|
||||||
|
|
||||||
|
const TRANSLATIONS_DIR = path.join(process.cwd(), 'src', 'app', 'i18n', 'translations');
|
||||||
|
const CORE_LANGUAGE_CODES = new Set(['en', 'de']);
|
||||||
|
|
||||||
|
type FlatTranslations = Record<string, string>;
|
||||||
|
|
||||||
|
type LanguageDescriptor = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValidLanguageCode(code: string): boolean {
|
||||||
|
return /^[a-z]{2,5}(?:-[a-zA-Z]{2,4})?$/.test(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(input: string): string {
|
||||||
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLanguageName(input: string, code: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (trimmed.length === 0) return code.toUpperCase();
|
||||||
|
return trimmed.replace(/[\r\n]+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLanguageNameFromSource(source: string, code: string): string {
|
||||||
|
const nameMatch = source.match(/^\s*\/\/\s*Language name:\s*(.+)$/m);
|
||||||
|
if (nameMatch?.[1]) return normalizeLanguageName(nameMatch[1], code);
|
||||||
|
if (code === 'en') return 'English';
|
||||||
|
if (code === 'de') return 'Deutsch';
|
||||||
|
return code.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findObjectLiteralBounds(source: string, variableName: string): { start: number; end: number } {
|
||||||
|
const exportRegex = new RegExp(`export\\s+const\\s+${escapeRegex(variableName)}\\s*:\\s*Translations\\s*=`, 'm');
|
||||||
|
const match = exportRegex.exec(source);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Could not find exported translations object for language "${variableName}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterExportIndex = match.index + match[0].length;
|
||||||
|
const objectStart = source.indexOf('{', afterExportIndex);
|
||||||
|
if (objectStart === -1) {
|
||||||
|
throw new Error(`Could not find object start for language "${variableName}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
let inSingle = false;
|
||||||
|
let inDouble = false;
|
||||||
|
let inBacktick = false;
|
||||||
|
let inLineComment = false;
|
||||||
|
let inBlockComment = false;
|
||||||
|
|
||||||
|
for (let i = objectStart; i < source.length; i += 1) {
|
||||||
|
const char = source[i];
|
||||||
|
const next = source[i + 1];
|
||||||
|
const prev = source[i - 1];
|
||||||
|
|
||||||
|
if (inLineComment) {
|
||||||
|
if (char === '\n') inLineComment = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inBlockComment) {
|
||||||
|
if (char === '*' && next === '/') {
|
||||||
|
inBlockComment = false;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inSingle && !inDouble && !inBacktick) {
|
||||||
|
if (char === '/' && next === '/') {
|
||||||
|
inLineComment = true;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '/' && next === '*') {
|
||||||
|
inBlockComment = true;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inDouble && !inBacktick && char === '\'' && prev !== '\\') {
|
||||||
|
inSingle = !inSingle;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inSingle && !inBacktick && char === '"' && prev !== '\\') {
|
||||||
|
inDouble = !inDouble;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inSingle && !inDouble && char === '`' && prev !== '\\') {
|
||||||
|
inBacktick = !inBacktick;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inSingle || inDouble || inBacktick) continue;
|
||||||
|
|
||||||
|
if (char === '{') {
|
||||||
|
depth += 1;
|
||||||
|
} else if (char === '}') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
return { start: objectStart, end: i + 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Could not find object end for language "${variableName}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTranslationObject(source: string, code: string): Record<string, unknown> {
|
||||||
|
const { start, end } = findObjectLiteralBounds(source, code);
|
||||||
|
const literal = source.slice(start, end);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = Function(`"use strict"; return (${literal});`)() as Record<string, unknown>;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
throw new Error('Invalid object result.');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse translation file for language "${code}": ${error instanceof Error ? error.message : 'unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTsObjectLiteral(value: unknown): string {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTranslationFileContent(code: string, languageName: string, nestedObject: Record<string, unknown>): string {
|
||||||
|
return [
|
||||||
|
"import { Translations } from '../types';",
|
||||||
|
'',
|
||||||
|
`// Language name: ${normalizeLanguageName(languageName, code)}`,
|
||||||
|
`export const ${code}: Translations = ${toTsObjectLiteral(nestedObject)};`,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLanguageFile(code: string): Promise<{ source: string; filePath: string }> {
|
||||||
|
const filePath = path.join(TRANSLATIONS_DIR, `${code}.ts`);
|
||||||
|
const source = await fs.readFile(filePath, 'utf8');
|
||||||
|
return { source, filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLanguageFiles(): Promise<string[]> {
|
||||||
|
const entries = await fs.readdir(TRANSLATIONS_DIR, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
|
||||||
|
.map((entry) => entry.name.slice(0, -3))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAllTranslations(): Promise<{
|
||||||
|
languages: LanguageDescriptor[];
|
||||||
|
translations: Record<string, FlatTranslations>;
|
||||||
|
}> {
|
||||||
|
const codes = await getLanguageFiles();
|
||||||
|
const languages: LanguageDescriptor[] = [];
|
||||||
|
const translations: Record<string, FlatTranslations> = {};
|
||||||
|
|
||||||
|
for (const code of codes) {
|
||||||
|
const { source } = await readLanguageFile(code);
|
||||||
|
const parsed = parseTranslationObject(source, code);
|
||||||
|
languages.push({ code, name: extractLanguageNameFromSource(source, code) });
|
||||||
|
translations[code] = flattenObject(parsed as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { languages, translations };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFlatTranslations(value: unknown): FlatTranslations {
|
||||||
|
if (!value || typeof value !== 'object') return {};
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, String(v ?? '')])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLanguageFile(
|
||||||
|
code: string,
|
||||||
|
languageName: string,
|
||||||
|
incomingFlat: FlatTranslations,
|
||||||
|
englishFlat: FlatTranslations
|
||||||
|
): Promise<void> {
|
||||||
|
const mergedFlat: FlatTranslations = {};
|
||||||
|
for (const key of Object.keys(englishFlat)) {
|
||||||
|
const candidate = incomingFlat[key];
|
||||||
|
mergedFlat[key] = candidate !== undefined ? String(candidate) : String(englishFlat[key] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = unflattenObject(mergedFlat);
|
||||||
|
const filePath = path.join(TRANSLATIONS_DIR, `${code}.ts`);
|
||||||
|
const content = buildTranslationFileContent(code, languageName, nested);
|
||||||
|
await fs.writeFile(filePath, content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const access = await requireAdminSession(request);
|
||||||
|
if (!access.ok) return access.response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await readAllTranslations();
|
||||||
|
return NextResponse.json({ ok: true, ...data });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, message: error instanceof Error ? error.message : 'Failed to read translations.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const access = await requireAdminSession(request);
|
||||||
|
if (!access.ok) return access.response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const code = String(body?.code ?? '').trim().toLowerCase();
|
||||||
|
const languageName = normalizeLanguageName(String(body?.name ?? ''), code);
|
||||||
|
|
||||||
|
if (!isValidLanguageCode(code)) {
|
||||||
|
return NextResponse.json({ ok: false, message: 'Invalid language code.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCodes = new Set(await getLanguageFiles());
|
||||||
|
if (existingCodes.has(code)) {
|
||||||
|
return NextResponse.json({ ok: false, message: `Language "${code}" already exists.` }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { translations } = await readAllTranslations();
|
||||||
|
const englishFlat = translations.en;
|
||||||
|
if (!englishFlat) {
|
||||||
|
return NextResponse.json({ ok: false, message: 'English base translation file is missing.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeLanguageFile(code, languageName, englishFlat, englishFlat);
|
||||||
|
const updated = await readAllTranslations();
|
||||||
|
return NextResponse.json({ ok: true, ...updated });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, message: error instanceof Error ? error.message : 'Failed to create language file.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const access = await requireAdminSession(request);
|
||||||
|
if (!access.ok) return access.response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const payload = body?.translations;
|
||||||
|
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return NextResponse.json({ ok: false, message: 'translations payload is required.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCurrent = await readAllTranslations();
|
||||||
|
const englishFlat = allCurrent.translations.en;
|
||||||
|
if (!englishFlat) {
|
||||||
|
return NextResponse.json({ ok: false, message: 'English base translation file is missing.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageNameByCode: Record<string, string> = Object.fromEntries(
|
||||||
|
allCurrent.languages.map((lang) => [lang.code, lang.name])
|
||||||
|
);
|
||||||
|
|
||||||
|
const incomingTranslations = payload as Record<string, unknown>;
|
||||||
|
const availableCodes = new Set(Object.keys(allCurrent.translations));
|
||||||
|
|
||||||
|
for (const [code, flatCandidate] of Object.entries(incomingTranslations)) {
|
||||||
|
if (!availableCodes.has(code)) continue;
|
||||||
|
const normalizedFlat = normalizeFlatTranslations(flatCandidate);
|
||||||
|
const languageName = languageNameByCode[code] ?? code.toUpperCase();
|
||||||
|
await writeLanguageFile(code, languageName, normalizedFlat, englishFlat);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await readAllTranslations();
|
||||||
|
return NextResponse.json({ ok: true, ...updated });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, message: error instanceof Error ? error.message : 'Failed to save translation files.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const access = await requireAdminSession(request);
|
||||||
|
if (!access.ok) return access.response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const code = String(body?.code ?? '').trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!isValidLanguageCode(code)) {
|
||||||
|
return NextResponse.json({ ok: false, message: 'Invalid language code.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CORE_LANGUAGE_CODES.has(code)) {
|
||||||
|
return NextResponse.json({ ok: false, message: `Language "${code}" cannot be deleted.` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(TRANSLATIONS_DIR, `${code}.ts`);
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
|
||||||
|
const updated = await readAllTranslations();
|
||||||
|
return NextResponse.json({ ok: true, ...updated });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, message: error instanceof Error ? error.message : 'Failed to delete language file.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/app/coffee-abonnements/[id]/page.tsx
Normal file
114
src/app/coffee-abonnements/[id]/page.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import PageLayout from '../../components/PageLayout';
|
||||||
|
import { useActiveCoffees } from '../hooks/getActiveCoffees';
|
||||||
|
import CoffeeDetailGallery from '../components/CoffeeDetailGallery';
|
||||||
|
import { useCoffeePictures } from '../hooks/useCoffeePictures';
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
|
import SubscribeGuard from '../components/SubscribeGuard';
|
||||||
|
|
||||||
|
export default function CoffeeAbonnementDetailPage() {
|
||||||
|
return (
|
||||||
|
<SubscribeGuard>
|
||||||
|
<CoffeeAbonnementDetailPageContent />
|
||||||
|
</SubscribeGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoffeeAbonnementDetailPageContent() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const params = useParams();
|
||||||
|
const { coffees, loading, error } = useActiveCoffees();
|
||||||
|
|
||||||
|
const rawId = params?.id;
|
||||||
|
const coffeeId = Array.isArray(rawId) ? rawId[0] : rawId;
|
||||||
|
const { pictureUrls: endpointPictureUrls, loading: picturesLoading } = useCoffeePictures(coffeeId ? String(coffeeId) : undefined);
|
||||||
|
const coffee = coffees.find((item) => item.id === String(coffeeId));
|
||||||
|
const fallbackGallery = coffee ? (coffee.gallery.length ? coffee.gallery : (coffee.image ? [coffee.image] : [])) : [];
|
||||||
|
const gallery = endpointPictureUrls.length ? endpointPictureUrls : fallbackGallery;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
||||||
|
<div className="max-w-455 mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">{t('autofix.kbdc4e405')}</div>
|
||||||
|
<h1 className="mt-3 text-2xl font-bold tracking-tight text-slate-900">{t('autofix.k50bb594b')}</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{t('autofix.ka157a704')}</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/coffee-abonnements"
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>{t('autofix.k96839795')}</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
|
<div className="h-80 rounded-2xl bg-slate-100 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && (
|
||||||
|
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && !coffee && (
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.kbcb6ceea')}</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">{t('autofix.k8e0e178e')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && coffee && (
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-5 gap-5">
|
||||||
|
<div className="xl:col-span-3 rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
|
<CoffeeDetailGallery images={gallery} alt={coffee.name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="xl:col-span-2 space-y-5">
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900">{coffee.name}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-slate-600">{coffee.description || t('autofix.kec078e54')}</p>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-2">
|
||||||
|
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2">
|
||||||
|
<span className="text-sm text-slate-600">Price per pack</span>
|
||||||
|
<span className="text-sm font-semibold text-slate-900">EUR {coffee.pricePer10.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2">
|
||||||
|
<span className="text-sm text-slate-600">Capsules per pack</span>
|
||||||
|
<span className="text-sm font-semibold text-slate-900">10 capsules</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-2">
|
||||||
|
<span className="text-sm text-slate-600">{t('autofix.k5bd8edf9')}</span>
|
||||||
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
|
{picturesLoading ? 'Loading...' : gallery.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
|
<p className="text-sm text-slate-600">Ready to add this coffee to your plan? Go back to the selection page and choose how many packs you want.</p>
|
||||||
|
<Link
|
||||||
|
href="/coffee-abonnements"
|
||||||
|
className="mt-4 inline-flex items-center justify-center rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition"
|
||||||
|
>{t('autofix.k47e6f301')}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/app/coffee-abonnements/components/AboHeroHeader.tsx
Normal file
29
src/app/coffee-abonnements/components/AboHeroHeader.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AboHeroHeader({ title, subtitle }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 px-8 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
|
Coffee ABO
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-2xl font-bold tracking-tight text-slate-900">{title}</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/profile/subscriptions"
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
My subscriptions
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/coffee-abonnements/components/AboStepper.tsx
Normal file
25
src/app/coffee-abonnements/components/AboStepper.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
type Props = {
|
||||||
|
currentStep: 1 | 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AboStepper({ currentStep }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[24px] border border-white/80 bg-white/90 px-5 py-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.2)] backdrop-blur">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className={`h-8 w-8 rounded-full flex items-center justify-center font-semibold ${
|
||||||
|
currentStep === 1 ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500'
|
||||||
|
}`}>1</span>
|
||||||
|
<span className="ml-2 font-medium">Selection</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-px flex-1 bg-slate-200" />
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className={`h-8 w-8 rounded-full flex items-center justify-center font-semibold ${
|
||||||
|
currentStep === 2 ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500'
|
||||||
|
}`}>2</span>
|
||||||
|
<span className="ml-2 font-medium">Summary</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
images: string[];
|
||||||
|
alt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CoffeeDetailGallery({ images, alt }: Props) {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const safeImages = images.length > 0 ? images : [''];
|
||||||
|
const current = safeImages[index] || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="relative h-72 sm:h-96 overflow-hidden rounded-2xl border border-slate-200 bg-slate-100">
|
||||||
|
{current ? (
|
||||||
|
<img src={current} alt={alt} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full flex items-center justify-center text-sm text-slate-400">No image available</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{safeImages.length > 1 && (
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||||
|
{safeImages.map((img, i) => (
|
||||||
|
<button
|
||||||
|
key={`${img}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
className={`relative h-16 sm:h-20 overflow-hidden rounded-xl border transition ${
|
||||||
|
i === index ? 'border-slate-900 ring-2 ring-slate-900/20' : 'border-slate-200 hover:border-slate-300'
|
||||||
|
}`}
|
||||||
|
aria-label={`Show gallery image ${i + 1}`}
|
||||||
|
>
|
||||||
|
<img src={img} alt={`${alt} ${i + 1}`} className="h-full w-full object-cover" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/app/coffee-abonnements/components/CoffeeSelectionGrid.tsx
Normal file
160
src/app/coffee-abonnements/components/CoffeeSelectionGrid.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import type { CoffeeItem } from '../hooks/getActiveCoffees';
|
||||||
|
import { CAPSULES_PER_PACK, MAX_ABO_PACKS, MIN_ABO_PACKS, packsToCapsules } from '../lib/orderRules';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
coffees: CoffeeItem[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
selections: Record<string, number>;
|
||||||
|
totalPacks: number;
|
||||||
|
onAdjustQuantity: (id: string, delta: number) => void;
|
||||||
|
onSetQuantity: (id: string, nextQuantity: number) => void;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CoffeeSelectionGrid({
|
||||||
|
coffees,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
selections,
|
||||||
|
totalPacks,
|
||||||
|
onAdjustQuantity,
|
||||||
|
onSetQuantity,
|
||||||
|
title,
|
||||||
|
}: Props) {
|
||||||
|
const remainingCapacity = Math.max(0, MAX_ABO_PACKS - totalPacks);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
|
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Choose your coffees in packs. One pack contains {CAPSULES_PER_PACK} capsules.</p>
|
||||||
|
<div className="mt-3 inline-flex items-center rounded-2xl border border-sky-200 bg-sky-50 px-4 py-2 text-sm font-medium text-sky-900">
|
||||||
|
Minimum order: {MIN_ABO_PACKS} packs ({packsToCapsules(MIN_ABO_PACKS)} capsules).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-right">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">Capacity</div>
|
||||||
|
<div className="mt-1 text-lg font-bold text-slate-900">{remainingCapacity.toLocaleString('en-US')} packs left</div>
|
||||||
|
<div className="text-xs text-slate-500">up to {MAX_ABO_PACKS.toLocaleString('en-US')} packs per subscription</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="h-52 rounded-2xl bg-slate-100 animate-pulse" />
|
||||||
|
<div className="h-52 rounded-2xl bg-slate-100 animate-pulse" />
|
||||||
|
<div className="h-52 rounded-2xl bg-slate-100 animate-pulse" />
|
||||||
|
<div className="h-52 rounded-2xl bg-slate-100 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{coffees.map((coffee) => {
|
||||||
|
const active = coffee.id in selections;
|
||||||
|
const qty = selections[coffee.id] || 0;
|
||||||
|
const maxForCoffee = qty + remainingCapacity;
|
||||||
|
const addableForCoffee = remainingCapacity;
|
||||||
|
const subtotal = qty * coffee.pricePer10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={coffee.id}
|
||||||
|
className={`group rounded-2xl border p-4 shadow-sm transition ${
|
||||||
|
active ? 'border-slate-900/30 bg-slate-50' : 'border-slate-200 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Link href={`/coffee-abonnements/${coffee.id}`} className="block relative overflow-hidden rounded-xl mb-3">
|
||||||
|
{coffee.image ? (
|
||||||
|
<img
|
||||||
|
src={coffee.image}
|
||||||
|
alt={coffee.name}
|
||||||
|
className="h-36 w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-36 w-full bg-slate-100 rounded-xl" />
|
||||||
|
)}
|
||||||
|
<div className="absolute top-2 left-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-semibold text-white">Details</div>
|
||||||
|
<div className="absolute top-2 right-2 flex flex-col items-end gap-1">
|
||||||
|
<span className={`inline-flex items-center justify-center rounded-full text-white text-[11px] font-bold px-3 py-1 shadow-lg ring-2 ring-white/50 backdrop-blur-sm ${
|
||||||
|
active ? 'bg-slate-900' : 'bg-slate-700/90'
|
||||||
|
}`}>
|
||||||
|
EUR {coffee.pricePer10.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-slate-900/90 text-white border border-white/20 shadow-sm backdrop-blur-sm">per pack</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="font-semibold text-sm text-slate-900 line-clamp-1">{coffee.name}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-600 leading-relaxed line-clamp-3">{coffee.description}</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/coffee-abonnements/${coffee.id}`}
|
||||||
|
className="inline-flex items-center justify-center text-xs font-semibold rounded-xl px-3 py-2 border border-slate-200 text-slate-600 hover:bg-slate-100 transition"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500">Pack selection</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-500">{packsToCapsules(qty).toLocaleString('en-US')} capsules</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full bg-slate-900 px-3 py-1 text-xs font-semibold text-white">
|
||||||
|
{qty.toLocaleString('en-US')} packs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAdjustQuantity(coffee.id, -1)}
|
||||||
|
disabled={qty <= 0}
|
||||||
|
className="h-10 w-10 rounded-full bg-white text-base font-bold text-slate-900 shadow-sm ring-1 ring-slate-200 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={maxForCoffee}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={qty}
|
||||||
|
onChange={(e) => onSetQuantity(coffee.id, Number(e.target.value))}
|
||||||
|
className="h-10 w-full rounded-xl border border-slate-200 bg-white px-3 text-center text-sm font-semibold text-slate-900 shadow-sm focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAdjustQuantity(coffee.id, +1)}
|
||||||
|
disabled={qty >= maxForCoffee}
|
||||||
|
className="h-10 w-10 rounded-full bg-white text-base font-bold text-slate-900 shadow-sm ring-1 ring-slate-200 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span>You can add {addableForCoffee.toLocaleString('en-US')} more packs here.</span>
|
||||||
|
<span className="font-semibold text-slate-700">EUR {subtotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/app/coffee-abonnements/components/SelectionSummaryCard.tsx
Normal file
122
src/app/coffee-abonnements/components/SelectionSummaryCard.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import type { CoffeeItem } from '../hooks/getActiveCoffees';
|
||||||
|
import { MAX_ABO_PACKS, MIN_ABO_PACKS, packsToCapsules } from '../lib/orderRules';
|
||||||
|
|
||||||
|
type SelectedEntry = {
|
||||||
|
coffee: CoffeeItem;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedEntries: SelectedEntry[];
|
||||||
|
shippingLoading: boolean;
|
||||||
|
isFreeShippingSelected: boolean;
|
||||||
|
selectedShippingFee: number;
|
||||||
|
totalNetWithShipping: number;
|
||||||
|
totalCapsules: number;
|
||||||
|
totalPacks: number;
|
||||||
|
orderPackError: string | null;
|
||||||
|
remainingMinPacks: number;
|
||||||
|
canProceed: boolean;
|
||||||
|
onProceed: () => void;
|
||||||
|
title: string;
|
||||||
|
emptyText: string;
|
||||||
|
continueText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SelectionSummaryCard({
|
||||||
|
selectedEntries,
|
||||||
|
shippingLoading,
|
||||||
|
isFreeShippingSelected,
|
||||||
|
selectedShippingFee,
|
||||||
|
totalNetWithShipping,
|
||||||
|
totalCapsules,
|
||||||
|
totalPacks,
|
||||||
|
orderPackError,
|
||||||
|
remainingMinPacks,
|
||||||
|
canProceed,
|
||||||
|
onProceed,
|
||||||
|
title,
|
||||||
|
emptyText,
|
||||||
|
continueText,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/90 p-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||||
|
|
||||||
|
{selectedEntries.length === 0 && <p className="text-sm text-slate-600">{emptyText}</p>}
|
||||||
|
|
||||||
|
{selectedEntries.map((entry) => (
|
||||||
|
<div key={entry.coffee.id} className="flex justify-between text-sm border-b border-slate-100 last:border-b-0 pb-2 last:pb-0">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-slate-800">{entry.coffee.name}</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{entry.quantity} packs ({packsToCapsules(entry.quantity)} capsules) • <span className="inline-flex items-center font-semibold text-slate-900">EUR {entry.coffee.pricePer10.toFixed(2)}/pack</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right font-semibold text-slate-800">EUR {(entry.quantity * entry.coffee.pricePer10).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm border-b border-slate-100 pb-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700">Shipping</span>
|
||||||
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
|
{shippingLoading ? 'Loading...' : isFreeShippingSelected ? 'FREE SHIPPING' : `EUR ${selectedShippingFee.toFixed(2)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-2 border-t border-slate-200">
|
||||||
|
<span className="text-sm font-semibold text-slate-700">Total (net)</span>
|
||||||
|
<span className="text-lg font-extrabold tracking-tight text-slate-900">EUR {totalNetWithShipping.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-xs text-slate-700">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">{totalPacks.toLocaleString('en-US')}</span> packs selected.
|
||||||
|
<div className="text-slate-500">{totalCapsules.toLocaleString('en-US')} capsules total · minimum {MIN_ABO_PACKS} packs · maximum {MAX_ABO_PACKS.toLocaleString('en-US')} packs</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{orderPackError ? (
|
||||||
|
<span className="inline-flex items-center rounded-md bg-rose-50 text-rose-700 px-2 py-1 border border-rose-200">
|
||||||
|
{remainingMinPacks > 0
|
||||||
|
? `${remainingMinPacks} more pack${remainingMinPacks === 1 ? '' : 's'} needed to reach the minimum order.`
|
||||||
|
: orderPackError}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center rounded-md bg-emerald-50 text-emerald-700 px-2 py-1 border border-emerald-200">
|
||||||
|
Selection is within the allowed order range.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onProceed}
|
||||||
|
disabled={!canProceed}
|
||||||
|
className={`group w-full mt-2 rounded-xl px-4 py-3 font-semibold transition inline-flex items-center justify-center ${
|
||||||
|
canProceed ? 'bg-slate-900 text-white hover:bg-slate-800 shadow-md hover:shadow-lg' : 'bg-slate-200 text-slate-500 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{continueText}
|
||||||
|
<svg
|
||||||
|
className={`ml-2 h-5 w-5 transition-transform ${canProceed ? 'group-hover:translate-x-0.5' : ''}`}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!canProceed && (
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
{remainingMinPacks > 0
|
||||||
|
? `You can continue once at least ${MIN_ABO_PACKS} packs are selected.`
|
||||||
|
: `Please reduce the order to ${MAX_ABO_PACKS.toLocaleString('en-US')} packs or fewer.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/app/coffee-abonnements/components/SubscribeGuard.tsx
Normal file
32
src/app/coffee-abonnements/components/SubscribeGuard.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import PageLayout from '../../components/PageLayout';
|
||||||
|
import { useSubscribeGuard } from '../hooks/useSubscribeGuard';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SubscribeGuard({ children }: Props) {
|
||||||
|
const { isChecking, isAllowed } = useSubscribeGuard();
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
return (
|
||||||
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
||||||
|
<div className="max-w-455 mx-auto px-4 sm:px-6 xl:px-10 py-8">
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur text-center">
|
||||||
|
<div className="mx-auto h-10 w-10 rounded-full border-2 border-slate-200 border-t-slate-900 animate-spin" />
|
||||||
|
<p className="mt-4 text-sm text-slate-600">
|
||||||
|
{isChecking ? 'Checking subscription access...' : 'Redirecting...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ export type ActiveCoffee = {
|
|||||||
description: string;
|
description: string;
|
||||||
price: string | number; // price can be a string or number
|
price: string | number; // price can be a string or number
|
||||||
pictureUrl?: string;
|
pictureUrl?: string;
|
||||||
|
pictureUrls?: string[] | string | null;
|
||||||
state: number; // 1 for active, 0 for inactive
|
state: number; // 1 for active, 0 for inactive
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -16,8 +17,27 @@ export type CoffeeItem = {
|
|||||||
description: string;
|
description: string;
|
||||||
pricePer10: number; // price for 10 pieces
|
pricePer10: number; // price for 10 pieces
|
||||||
image: string;
|
image: string;
|
||||||
|
gallery: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizePictureUrls(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) return value.filter((url): url is string => typeof url === 'string' && url.trim() !== '');
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.filter((url): url is string => typeof url === 'string' && url.trim() !== '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Accept comma-separated fallback values.
|
||||||
|
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
export function useActiveCoffees() {
|
export function useActiveCoffees() {
|
||||||
const [coffees, setCoffees] = useState<CoffeeItem[]>([]);
|
const [coffees, setCoffees] = useState<CoffeeItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -70,12 +90,15 @@ export function useActiveCoffees() {
|
|||||||
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
|
.filter((coffee) => (coffee as any).state === 1 || (coffee as any).state === true || (coffee as any).state === '1')
|
||||||
.map((coffee) => {
|
.map((coffee) => {
|
||||||
const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price;
|
const price = typeof coffee.price === 'string' ? parseFloat(coffee.price) : coffee.price;
|
||||||
|
const gallery = normalizePictureUrls((coffee as any).pictureUrls);
|
||||||
|
const thumbnail = coffee.pictureUrl || gallery[0] || '';
|
||||||
return {
|
return {
|
||||||
id: String(coffee.id),
|
id: String(coffee.id),
|
||||||
name: coffee.title || `Coffee ${coffee.id}`,
|
name: coffee.title || `Coffee ${coffee.id}`,
|
||||||
description: coffee.description || '',
|
description: coffee.description || '',
|
||||||
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
|
pricePer10: !isNaN(price as number) ? (price as number) * 10 : 0,
|
||||||
image: coffee.pictureUrl || '',
|
image: thumbnail,
|
||||||
|
gallery,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
112
src/app/coffee-abonnements/hooks/useCoffeePictures.ts
Normal file
112
src/app/coffee-abonnements/hooks/useCoffeePictures.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { authFetch } from '../../utils/authFetch';
|
||||||
|
|
||||||
|
type PictureApiRecord = {
|
||||||
|
url?: string;
|
||||||
|
pictureUrl?: string;
|
||||||
|
src?: string;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePictureUrls(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((url): url is string => typeof url === 'string' && url.trim() !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.filter((url): url is string => typeof url === 'string' && url.trim() !== '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueUrls(urls: string[]): string[] {
|
||||||
|
return Array.from(new Set(urls.filter((u) => typeof u === 'string' && u.trim() !== '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCoffeePictures(coffeeId?: string) {
|
||||||
|
const [pictureUrls, setPictureUrls] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!coffeeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
|
||||||
|
const candidateUrls = [
|
||||||
|
`${base}/api/admin/coffee/${coffeeId}/pictures`,
|
||||||
|
`${base}/api/coffee/${coffeeId}/pictures`,
|
||||||
|
];
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const loadPictures = async () => {
|
||||||
|
if (!isCancelled) setLoading(true);
|
||||||
|
|
||||||
|
for (const url of candidateUrls) {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403 || response.status === 404) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
if (!contentType.includes('application/json')) continue;
|
||||||
|
|
||||||
|
const json = await response.json().catch(() => null);
|
||||||
|
const payload = json && typeof json === 'object' && json.data && typeof json.data === 'object'
|
||||||
|
? json.data
|
||||||
|
: json;
|
||||||
|
|
||||||
|
const fromList = normalizePictureUrls((payload as any)?.pictureUrls);
|
||||||
|
const fromSingle = typeof (payload as any)?.pictureUrl === 'string' ? [(payload as any).pictureUrl] : [];
|
||||||
|
const fromDetails = Array.isArray((payload as any)?.pictures)
|
||||||
|
? ((payload as any).pictures as PictureApiRecord[])
|
||||||
|
.map((item) => item?.url || item?.pictureUrl || item?.src || item?.path || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const merged = uniqueUrls([...fromList, ...fromDetails, ...fromSingle]);
|
||||||
|
if (!isCancelled) setPictureUrls(merged);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Try next candidate endpoint.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCancelled) setPictureUrls([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadPictures().finally(() => {
|
||||||
|
if (!isCancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [coffeeId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pictureUrls: coffeeId ? pictureUrls : [],
|
||||||
|
loading: coffeeId ? loading : false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -2,18 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
export type CoffeeShippingFeePieceCount = 60 | 120;
|
|
||||||
|
|
||||||
export type CoffeeShippingFee = {
|
export type CoffeeShippingFee = {
|
||||||
pieceCount: CoffeeShippingFeePieceCount;
|
pieceCount: number;
|
||||||
price: number;
|
price: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ShippingFeeMap = Record<CoffeeShippingFeePieceCount, number>;
|
function normalizePieceCount(v: any): number | null {
|
||||||
|
|
||||||
function normalizePieceCount(v: any): CoffeeShippingFeePieceCount | null {
|
|
||||||
const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : NaN);
|
const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : NaN);
|
||||||
if (n === 60 || n === 120) return n;
|
if (Number.isInteger(n) && n >= 60 && n % 10 === 0) return n;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,12 +20,11 @@ export function useShippingFees() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const feeByPieceCount: ShippingFeeMap = useMemo(() => {
|
// Threshold lookup: finds the highest breakpoint <= n (mirrors backend logic).
|
||||||
const map: ShippingFeeMap = { 60: 0, 120: 0 };
|
const resolveShippingFee = useMemo(() => (n: number): number => {
|
||||||
for (const row of fees) {
|
const sorted = [...fees].sort((a, b) => b.pieceCount - a.pieceCount);
|
||||||
map[row.pieceCount] = row.price;
|
const match = sorted.find(f => f.pieceCount <= n);
|
||||||
}
|
return match ? match.price : 0;
|
||||||
return map;
|
|
||||||
}, [fees]);
|
}, [fees]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -83,5 +78,5 @@ export function useShippingFees() {
|
|||||||
};
|
};
|
||||||
}, [base]);
|
}, [base]);
|
||||||
|
|
||||||
return { fees, feeByPieceCount, loading, error };
|
return { fees, resolveShippingFee, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
107
src/app/coffee-abonnements/hooks/useSubscribeGuard.ts
Normal file
107
src/app/coffee-abonnements/hooks/useSubscribeGuard.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import useAuthStore from '../../store/authStore';
|
||||||
|
|
||||||
|
type GuardState = 'checking' | 'allowed' | 'redirecting';
|
||||||
|
|
||||||
|
function hasPermission(permsSrc: any, permission: string) {
|
||||||
|
if (Array.isArray(permsSrc)) {
|
||||||
|
return (
|
||||||
|
permsSrc.includes?.(permission) ||
|
||||||
|
permsSrc.some?.((perm: any) => perm?.name === permission || perm?.key === permission)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permsSrc && typeof permsSrc === 'object') {
|
||||||
|
return !!permsSrc[permission];
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubscribeGuard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const isAuthReady = useAuthStore((state) => state.isAuthReady);
|
||||||
|
const accessToken = useAuthStore((state) => state.accessToken);
|
||||||
|
const refreshAuthToken = useAuthStore((state) => state.refreshAuthToken);
|
||||||
|
const [guardState, setGuardState] = useState<GuardState>('checking');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
if (!isAuthReady) {
|
||||||
|
if (!cancelled) setGuardState('checking');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
if (!cancelled) setGuardState('redirecting');
|
||||||
|
router.replace('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = (user as any)?.id ?? (user as any)?._id ?? (user as any)?.userId;
|
||||||
|
if (!uid) {
|
||||||
|
if (!cancelled) setGuardState('redirecting');
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokenToUse = accessToken;
|
||||||
|
try {
|
||||||
|
if (!tokenToUse && refreshAuthToken) {
|
||||||
|
const ok = await refreshAuthToken();
|
||||||
|
if (ok) tokenToUse = useAuthStore.getState().accessToken;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('useSubscribeGuard.refreshAuthToken', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || '';
|
||||||
|
const url = `${base}/api/users/${uid}/permissions`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(tokenToUse ? { Authorization: `Bearer ${tokenToUse}` } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
const permsSrc = body?.data?.permissions ?? body?.permissions ?? body;
|
||||||
|
const allowed = hasPermission(permsSrc, 'can_subscribe');
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
if (!cancelled) setGuardState('redirecting');
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) setGuardState('allowed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('useSubscribeGuard.permissions', error);
|
||||||
|
if (!cancelled) setGuardState('redirecting');
|
||||||
|
router.replace('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isAuthReady, user, accessToken, refreshAuthToken, router]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isChecking: guardState === 'checking',
|
||||||
|
isAllowed: guardState === 'allowed',
|
||||||
|
isRedirecting: guardState === 'redirecting',
|
||||||
|
};
|
||||||
|
}
|
||||||
52
src/app/coffee-abonnements/lib/orderRules.ts
Normal file
52
src/app/coffee-abonnements/lib/orderRules.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
export const CAPSULES_PER_PACK = 10;
|
||||||
|
export const MIN_ABO_PACKS = 6;
|
||||||
|
export const MAX_ABO_PACKS = 10000;
|
||||||
|
export const COFFEE_SELECTIONS_STORAGE_KEY = 'coffeeSelections';
|
||||||
|
export const COFFEE_SELECTIONS_UNIT_STORAGE_KEY = 'coffeeSelectionsUnit';
|
||||||
|
export const COFFEE_SELECTIONS_UNIT = 'packs-v1';
|
||||||
|
|
||||||
|
export function packsToCapsules(packs: number) {
|
||||||
|
return Math.max(0, Math.floor(Number(packs) || 0)) * CAPSULES_PER_PACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePackCount(value: unknown) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) return 0;
|
||||||
|
return Math.max(0, Math.floor(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrderPackError(totalPacks: number) {
|
||||||
|
if (totalPacks < MIN_ABO_PACKS) {
|
||||||
|
return `Order must contain at least ${MIN_ABO_PACKS} packs (${packsToCapsules(MIN_ABO_PACKS)} capsules).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalPacks > MAX_ABO_PACKS) {
|
||||||
|
return `Order cannot contain more than ${MAX_ABO_PACKS.toLocaleString('en-US')} packs (${packsToCapsules(MAX_ABO_PACKS).toLocaleString('en-US')} capsules).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRemainingMinPacks(totalPacks: number) {
|
||||||
|
return Math.max(0, MIN_ABO_PACKS - totalPacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStoredSelections(
|
||||||
|
rawSelections: Record<string, unknown>,
|
||||||
|
unit: string | null,
|
||||||
|
) {
|
||||||
|
const entries = Object.entries(rawSelections || {});
|
||||||
|
|
||||||
|
return entries.reduce<Record<string, number>>((acc, [coffeeId, value]) => {
|
||||||
|
const normalizedValue = normalizePackCount(value);
|
||||||
|
if (normalizedValue <= 0) return acc;
|
||||||
|
|
||||||
|
const packCount = unit === COFFEE_SELECTIONS_UNIT
|
||||||
|
? normalizedValue
|
||||||
|
: (normalizedValue % CAPSULES_PER_PACK === 0 ? normalizedValue / CAPSULES_PER_PACK : normalizedValue);
|
||||||
|
|
||||||
|
if (packCount <= 0) return acc;
|
||||||
|
acc[coffeeId] = Math.min(packCount, MAX_ABO_PACKS);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
@ -4,22 +4,41 @@ import PageLayout from '../components/PageLayout';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useActiveCoffees } from './hooks/getActiveCoffees';
|
import { useActiveCoffees } from './hooks/getActiveCoffees';
|
||||||
import { useShippingFees } from './hooks/useShippingFees';
|
import { useShippingFees } from './hooks/useShippingFees';
|
||||||
|
import AboHeroHeader from './components/AboHeroHeader';
|
||||||
|
import AboStepper from './components/AboStepper';
|
||||||
|
import CoffeeSelectionGrid from './components/CoffeeSelectionGrid';
|
||||||
|
import SelectionSummaryCard from './components/SelectionSummaryCard';
|
||||||
|
import SubscribeGuard from './components/SubscribeGuard';
|
||||||
|
import {
|
||||||
|
COFFEE_SELECTIONS_STORAGE_KEY,
|
||||||
|
COFFEE_SELECTIONS_UNIT,
|
||||||
|
COFFEE_SELECTIONS_UNIT_STORAGE_KEY,
|
||||||
|
getOrderPackError,
|
||||||
|
getRemainingMinPacks,
|
||||||
|
MAX_ABO_PACKS,
|
||||||
|
packsToCapsules,
|
||||||
|
} from './lib/orderRules';
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
|
||||||
export default function CoffeeAbonnementPage() {
|
export default function CoffeeAbonnementPage() {
|
||||||
|
return (
|
||||||
|
<SubscribeGuard>
|
||||||
|
<CoffeeAbonnementPageContent />
|
||||||
|
</SubscribeGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoffeeAbonnementPageContent() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [selections, setSelections] = useState<Record<string, number>>({});
|
const [selections, setSelections] = useState<Record<string, number>>({});
|
||||||
const [bump, setBump] = useState<Record<string, boolean>>({});
|
|
||||||
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<60 | 120>(120);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Fetch active coffees from the backend
|
// Fetch active coffees from the backend
|
||||||
const { coffees, loading, error } = useActiveCoffees();
|
const { coffees, loading, error } = useActiveCoffees();
|
||||||
|
|
||||||
// Shipping fees (per piece count)
|
// Shipping fees (threshold-based)
|
||||||
const { feeByPieceCount, loading: shippingLoading, error: shippingError } = useShippingFees();
|
const { resolveShippingFee, loading: shippingLoading, error: shippingError } = useShippingFees();
|
||||||
const shippingFeeFor60 = feeByPieceCount[60] ?? 0;
|
|
||||||
const shippingFeeFor120 = feeByPieceCount[120] ?? 0;
|
|
||||||
const selectedShippingFee = feeByPieceCount[selectedPlanCapsules] ?? 0;
|
|
||||||
const isFreeShippingSelected = Number(selectedShippingFee) === 0;
|
|
||||||
|
|
||||||
const selectedEntries = useMemo(
|
const selectedEntries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -34,384 +53,103 @@ export default function CoffeeAbonnementPage() {
|
|||||||
const totalPrice = useMemo(
|
const totalPrice = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedEntries.reduce(
|
selectedEntries.reduce(
|
||||||
(sum, entry) => sum + (entry.quantity / 10) * entry.coffee.pricePer10,
|
(sum, entry) => sum + entry.quantity * entry.coffee.pricePer10,
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
[selectedEntries]
|
[selectedEntries]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const totalPacks = useMemo(
|
||||||
|
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
|
||||||
|
[selectedEntries]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCapsules = useMemo(() => packsToCapsules(totalPacks), [totalPacks]);
|
||||||
|
const selectedShippingFee = resolveShippingFee(totalCapsules);
|
||||||
|
const isFreeShippingSelected = Number(selectedShippingFee) === 0;
|
||||||
|
const orderPackError = getOrderPackError(totalPacks);
|
||||||
|
const remainingMinPacks = getRemainingMinPacks(totalPacks);
|
||||||
|
|
||||||
const totalNetWithShipping = useMemo(
|
const totalNetWithShipping = useMemo(
|
||||||
() => totalPrice + (Number.isFinite(selectedShippingFee) ? selectedShippingFee : 0),
|
() => totalPrice + (Number.isFinite(selectedShippingFee) ? selectedShippingFee : 0),
|
||||||
[totalPrice, selectedShippingFee]
|
[totalPrice, selectedShippingFee]
|
||||||
);
|
);
|
||||||
|
|
||||||
// NEW: enforce selected plan size (60 or 120 capsules)
|
const canProceed = selectedEntries.length > 0 && !orderPackError;
|
||||||
const totalCapsules = useMemo(
|
|
||||||
() => selectedEntries.reduce((sum, entry) => sum + entry.quantity, 0),
|
|
||||||
[selectedEntries]
|
|
||||||
);
|
|
||||||
const packsSelected = totalCapsules / 10;
|
|
||||||
const requiredPacks = selectedPlanCapsules / 10;
|
|
||||||
const canProceed = packsSelected === requiredPacks;
|
|
||||||
|
|
||||||
const proceedToSummary = () => {
|
const proceedToSummary = () => {
|
||||||
if (!canProceed) return;
|
if (!canProceed) return;
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem('coffeeSelections', JSON.stringify(selections));
|
sessionStorage.setItem(COFFEE_SELECTIONS_STORAGE_KEY, JSON.stringify(selections));
|
||||||
sessionStorage.setItem('coffeeAboSizeCapsules', String(selectedPlanCapsules));
|
sessionStorage.setItem(COFFEE_SELECTIONS_UNIT_STORAGE_KEY, COFFEE_SELECTIONS_UNIT);
|
||||||
} catch {}
|
} catch {}
|
||||||
router.push('/coffee-abonnements/summary');
|
router.push('/coffee-abonnements/summary');
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCoffee = (id: string) => {
|
const setQuantity = (id: string, nextValue: number) => {
|
||||||
setSelections((prev) => {
|
setSelections((prev) => {
|
||||||
const copy = { ...prev };
|
const normalized = Math.max(0, Math.floor(Number(nextValue) || 0));
|
||||||
if (id in copy) {
|
const current = prev[id] || 0;
|
||||||
delete copy[id];
|
|
||||||
} else {
|
|
||||||
const total = Object.values(copy).reduce((sum, qty) => sum + qty, 0);
|
|
||||||
if (total + 10 > selectedPlanCapsules) return prev;
|
|
||||||
copy[id] = 10;
|
|
||||||
}
|
|
||||||
return copy;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeQuantity = (id: string, delta: number) => {
|
|
||||||
setSelections((prev) => {
|
|
||||||
if (!(id in prev)) return prev;
|
|
||||||
const otherTotal = Object.entries(prev).reduce((sum, [key, qty]) => key === id ? sum : sum + qty, 0);
|
const otherTotal = Object.entries(prev).reduce((sum, [key, qty]) => key === id ? sum : sum + qty, 0);
|
||||||
const maxForCoffee = Math.min(120, selectedPlanCapsules - otherTotal);
|
const maxForCoffee = Math.max(0, MAX_ABO_PACKS - otherTotal);
|
||||||
const next = prev[id] + delta;
|
const bounded = Math.min(normalized, maxForCoffee);
|
||||||
if (next < 10 || next > maxForCoffee) return prev;
|
|
||||||
const updated = { ...prev, [id]: next };
|
if (bounded <= 0) {
|
||||||
setBump((b) => ({ ...b, [id]: true }));
|
if (!(id in prev)) return prev;
|
||||||
setTimeout(() => setBump((b) => ({ ...b, [id]: false })), 250);
|
const next = { ...prev };
|
||||||
return updated;
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bounded === current) return prev;
|
||||||
|
return { ...prev, [id]: bounded };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const adjustQuantity = (id: string, delta: number) => {
|
||||||
|
const current = selections[id] || 0;
|
||||||
|
setQuantity(id, current + delta);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
<div className="mx-auto max-w-7xl px-4 py-10 space-y-10 bg-gradient-to-b from-white to-[#1C2B4A0D]">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
<span className="text-[#1C2B4A]">Configure Coffee Subscription</span>
|
<AboHeroHeader
|
||||||
</h1>
|
title={t('autofix.kb0b660e2')}
|
||||||
|
subtitle={t('autofix.k7f48f374')}
|
||||||
{/* Stepper */}
|
|
||||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="h-8 w-8 rounded-full bg-[#1C2B4A] text-white flex items-center justify-center font-semibold">1</span>
|
|
||||||
<span className="ml-2 font-medium">Selection</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-px flex-1 bg-gray-200" />
|
|
||||||
<div className="flex items-center opacity-60">
|
|
||||||
<span className="h-8 w-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center font-semibold">2</span>
|
|
||||||
<span className="ml-2 font-medium">Summary</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-semibold mb-4">1. Select subscription size</h2>
|
|
||||||
<div className="mb-6 rounded-xl border border-[#1C2B4A]/20 p-4 bg-white/80 backdrop-blur-sm shadow-lg">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedPlanCapsules(60)}
|
|
||||||
className={`rounded-lg border px-4 py-3 text-left transition ${
|
|
||||||
selectedPlanCapsules === 60
|
|
||||||
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
|
||||||
: 'border-gray-300 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="font-semibold">60 piece abo</div>
|
|
||||||
{shippingLoading ? (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
|
|
||||||
Shipping…
|
|
||||||
</span>
|
|
||||||
) : shippingFeeFor60 === 0 ? (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">
|
|
||||||
FREE SHIPPING
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
|
|
||||||
Shipping €{shippingFeeFor60.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600">6 packs of 10 capsules</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedPlanCapsules(120)}
|
|
||||||
className={`rounded-lg border px-4 py-3 text-left transition ${
|
|
||||||
selectedPlanCapsules === 120
|
|
||||||
? 'border-[#1C2B4A] bg-[#1C2B4A]/5 text-[#1C2B4A]'
|
|
||||||
: 'border-gray-300 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="font-semibold">120 piece abo</div>
|
|
||||||
{shippingLoading ? (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
|
|
||||||
Shipping…
|
|
||||||
</span>
|
|
||||||
) : shippingFeeFor120 === 0 ? (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">
|
|
||||||
FREE SHIPPING
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">
|
|
||||||
Shipping €{shippingFeeFor120.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600">12 packs of 10 capsules</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{shippingError && (
|
|
||||||
<div className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
|
||||||
Shipping fees could not be loaded: {shippingError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mb-4">2. Choose coffees & quantities</h2>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
|
||||||
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
|
||||||
<div className="h-44 rounded-xl bg-gray-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{coffees.map((coffee) => {
|
|
||||||
const active = coffee.id in selections;
|
|
||||||
const qty = selections[coffee.id] || 0;
|
|
||||||
const remainingCapsules = selectedPlanCapsules - totalCapsules;
|
|
||||||
const maxForCoffee = active
|
|
||||||
? Math.min(120, qty + remainingCapsules)
|
|
||||||
: 0;
|
|
||||||
const sliderMax = Math.max(10, maxForCoffee);
|
|
||||||
const sliderProgress = sliderMax <= 10
|
|
||||||
? 100
|
|
||||||
: Math.min(100, Math.max(0, ((qty - 10) / (sliderMax - 10)) * 100));
|
|
||||||
const canAddCoffee = active || remainingCapsules >= 10;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={coffee.id}
|
|
||||||
className={`group rounded-xl border p-4 shadow-sm transition ${
|
|
||||||
active ? 'border-[#1C2B4A] bg-[#1C2B4A]/5 shadow-md' : 'border-gray-200 bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="relative overflow-hidden rounded-md mb-3">
|
|
||||||
{coffee.image ? (
|
|
||||||
<img
|
|
||||||
src={coffee.image}
|
|
||||||
alt={coffee.name}
|
|
||||||
className="h-36 w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="h-36 w-full bg-gray-100 rounded-md" />
|
<AboStepper currentStep={1} />
|
||||||
)}
|
|
||||||
{/* price badge (per 10) */}
|
<CoffeeSelectionGrid
|
||||||
<div className="absolute top-2 right-2 flex flex-col items-end gap-1">
|
coffees={coffees}
|
||||||
<span
|
loading={loading}
|
||||||
aria-label={`Price €${coffee.pricePer10} per 10 capsules`}
|
error={error}
|
||||||
className={`relative inline-flex items-center justify-center rounded-full text-white text-[11px] font-bold px-3 py-1 shadow-lg ring-2 ring-white/50 backdrop-blur-sm transition-transform group-hover:scale-105 ${
|
selections={selections}
|
||||||
active ? 'bg-[#1C2B4A]' : 'bg-[#1C2B4A]/80'
|
totalPacks={totalPacks}
|
||||||
}`}
|
onAdjustQuantity={adjustQuantity}
|
||||||
>
|
onSetQuantity={setQuantity}
|
||||||
€{coffee.pricePer10}
|
title={t('autofix.k0b03e660')}
|
||||||
</span>
|
/>
|
||||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[#1C2B4A]/90 text-white border border-white/20 shadow-sm backdrop-blur-sm">
|
|
||||||
per 10 pcs
|
<SelectionSummaryCard
|
||||||
</span>
|
selectedEntries={selectedEntries}
|
||||||
</div>
|
shippingLoading={shippingLoading}
|
||||||
</div>
|
isFreeShippingSelected={isFreeShippingSelected}
|
||||||
<div className="flex items-start justify-between">
|
selectedShippingFee={selectedShippingFee}
|
||||||
<h3 className="font-semibold text-sm">{coffee.name}</h3>
|
totalNetWithShipping={totalNetWithShipping}
|
||||||
</div>
|
totalCapsules={totalCapsules}
|
||||||
<p className="mt-2 text-xs text-gray-600 leading-relaxed">
|
totalPacks={totalPacks}
|
||||||
{coffee.description}
|
orderPackError={orderPackError}
|
||||||
</p>
|
remainingMinPacks={remainingMinPacks}
|
||||||
<button
|
canProceed={canProceed}
|
||||||
type="button"
|
onProceed={proceedToSummary}
|
||||||
onClick={() => toggleCoffee(coffee.id)}
|
title={t('autofix.ke7b634f2')}
|
||||||
disabled={!canAddCoffee}
|
emptyText={t('autofix.kec078e54')}
|
||||||
className={`mt-3 w-full text-xs font-medium rounded px-3 py-2 border transition ${
|
continueText={t('autofix.k02665163')}
|
||||||
active
|
|
||||||
? 'border-[#1C2B4A] text-[#1C2B4A] bg-white hover:bg-[#1C2B4A]/10'
|
|
||||||
: canAddCoffee
|
|
||||||
? 'border-gray-300 hover:bg-gray-100'
|
|
||||||
: 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{active ? 'Remove' : 'Add'}
|
|
||||||
</button>
|
|
||||||
{active && (
|
|
||||||
<div className="mt-4 flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[11px] font-medium text-gray-500">Quantity (10–120)</span>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center justify-center rounded-full bg-[#1C2B4A] text-white px-3 py-1 text-xs font-semibold transition-transform duration-300 ${bump[coffee.id] ? 'scale-110' : 'scale-100'}`}
|
|
||||||
>
|
|
||||||
{qty} pcs
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => changeQuantity(coffee.id, -10)}
|
|
||||||
disabled={qty <= 10}
|
|
||||||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
|
||||||
>
|
|
||||||
-10
|
|
||||||
</button>
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={10}
|
|
||||||
max={sliderMax}
|
|
||||||
step={10}
|
|
||||||
value={qty}
|
|
||||||
onChange={(e) =>
|
|
||||||
changeQuantity(coffee.id, parseInt(e.target.value, 10) - qty)
|
|
||||||
}
|
|
||||||
className="w-full appearance-none cursor-pointer bg-transparent"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(to right,#1C2B4A 0%,#1C2B4A ' +
|
|
||||||
sliderProgress +
|
|
||||||
'%,#e5e7eb ' +
|
|
||||||
sliderProgress +
|
|
||||||
'%,#e5e7eb 100%)',
|
|
||||||
height: '6px',
|
|
||||||
borderRadius: '999px',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => changeQuantity(coffee.id, +10)}
|
|
||||||
disabled={qty + 10 > maxForCoffee}
|
|
||||||
className="h-8 w-14 rounded-full bg-gray-100 hover:bg-gray-200 text-xs font-medium transition active:scale-95"
|
|
||||||
>
|
|
||||||
+10
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-gray-500">
|
|
||||||
<span>Subtotal</span>
|
|
||||||
<span className="font-semibold text-gray-700">
|
|
||||||
€{((qty / 10) * coffee.pricePer10).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Section 2: Compact preview + next steps */}
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-semibold mb-4">3. Preview</h2>
|
|
||||||
<div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
|
|
||||||
{selectedEntries.length === 0 && (
|
|
||||||
<p className="text-sm text-gray-600">No coffees selected yet.</p>
|
|
||||||
)}
|
|
||||||
{selectedEntries.map((entry) => (
|
|
||||||
<div key={entry.coffee.id} className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{entry.coffee.name}</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{entry.quantity} Stk •{' '}
|
|
||||||
<span className="inline-flex items-center font-semibold text-[#1C2B4A]">
|
|
||||||
€{entry.coffee.pricePer10}/10
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-right font-semibold">
|
|
||||||
€{((entry.quantity / 10) * entry.coffee.pricePer10).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Shipping */}
|
|
||||||
<div className="flex justify-between text-sm border-b pb-2">
|
|
||||||
<span className="text-sm font-medium">Shipping</span>
|
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
{shippingLoading ? (
|
|
||||||
'Loading…'
|
|
||||||
) : isFreeShippingSelected ? (
|
|
||||||
'FREE SHIPPING'
|
|
||||||
) : (
|
|
||||||
`€${selectedShippingFee.toFixed(2)}`
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between pt-2 border-t">
|
|
||||||
<span className="text-sm font-semibold">Total (net)</span>
|
|
||||||
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">
|
|
||||||
€{totalNetWithShipping.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Packs/capsules summary and validation hint (refined design) */}
|
|
||||||
<div className="text-xs text-gray-700">
|
|
||||||
Selected: {totalCapsules} capsules ({packsSelected} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
|
||||||
{packsSelected !== requiredPacks && (
|
|
||||||
<span className="ml-2 inline-flex items-center rounded-md bg-red-50 text-red-700 px-2 py-1 border border-red-200">
|
|
||||||
Please select exactly {selectedPlanCapsules} capsules ({requiredPacks} packs).
|
|
||||||
{packsSelected < requiredPacks ? ` ${requiredPacks - packsSelected} packs missing.` : ` ${packsSelected - requiredPacks} packs too many.`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={proceedToSummary}
|
|
||||||
disabled={!canProceed}
|
|
||||||
className={`group w-full mt-2 rounded-lg px-4 py-3 font-semibold transition inline-flex items-center justify-center ${
|
|
||||||
canProceed
|
|
||||||
? 'bg-[#1C2B4A] text-white hover:bg-[#1C2B4A]/90 shadow-md hover:shadow-lg'
|
|
||||||
: 'bg-gray-200 text-gray-600 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Next steps
|
|
||||||
<svg
|
|
||||||
className={`ml-2 h-5 w-5 transition-transform ${
|
|
||||||
canProceed ? 'group-hover:translate-x-0.5' : ''
|
|
||||||
}`}
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10.293 3.293a1 1 0 011.414 0l5.999 6a1 1 0 010 1.414l-6 6a1 1 0 11-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{!canProceed && (
|
|
||||||
<p className="text-xs text-gray-600">
|
|
||||||
You can continue once exactly {selectedPlanCapsules} capsules ({requiredPacks} packs) are selected.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import React, { useEffect, useRef } from 'react'
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -11,6 +14,7 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SignaturePad({ value, onChange, className, required = false, error = null }: Props) {
|
export default function SignaturePad({ value, onChange, className, required = false, error = null }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
const isDrawing = useRef(false)
|
const isDrawing = useRef(false)
|
||||||
|
|
||||||
@ -163,9 +167,7 @@ export default function SignaturePad({ value, onChange, className, required = fa
|
|||||||
onTouchEnd={endDrawing}
|
onTouchEnd={endDrawing}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className={`mt-2 text-xs ${error ? 'text-red-700' : 'text-gray-500'}`}>
|
<p className={`mt-2 text-xs ${error ? 'text-red-700' : 'text-gray-500'}`}>{error || (value ? t('autofix.k352c82ef') : t('autofix.k99595e55'))}</p>
|
||||||
{error || (value ? 'Signature captured.' : 'Draw your signature in the box.')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { authFetch } from '../../../utils/authFetch'
|
import { authFetch } from '../../../utils/authFetch'
|
||||||
|
import { getOrderPackError } from '../../lib/orderRules'
|
||||||
|
|
||||||
export type SubscribeAboItem = { coffeeId: string | number; quantity?: number }
|
export type SubscribeAboItem = { coffeeId: string | number; quantity?: number }
|
||||||
export type SubscribeAboInput = {
|
export type SubscribeAboInput = {
|
||||||
@ -33,6 +34,9 @@ export type SubscribeAboInput = {
|
|||||||
invoiceCity?: string
|
invoiceCity?: string
|
||||||
invoicePhone?: string
|
invoicePhone?: string
|
||||||
invoiceEmail?: string
|
invoiceEmail?: string
|
||||||
|
uidNumber?: string
|
||||||
|
atuNumber?: string
|
||||||
|
taxMode?: 'standard_vat' | 'reverse_charge'
|
||||||
signingCity?: string
|
signingCity?: string
|
||||||
signatureDataUrl?: string
|
signatureDataUrl?: string
|
||||||
// logged-in user id
|
// logged-in user id
|
||||||
@ -99,6 +103,9 @@ export async function subscribeAbo(input: SubscribeAboInput) {
|
|||||||
paymentMethod: input.paymentMethod || undefined,
|
paymentMethod: input.paymentMethod || undefined,
|
||||||
invoiceByEmail: input.invoiceByEmail ?? false,
|
invoiceByEmail: input.invoiceByEmail ?? false,
|
||||||
invoiceSameAsShipping: input.invoiceSameAsShipping ?? true,
|
invoiceSameAsShipping: input.invoiceSameAsShipping ?? true,
|
||||||
|
uidNumber: input.uidNumber || undefined,
|
||||||
|
atuNumber: input.atuNumber || undefined,
|
||||||
|
taxMode: input.taxMode || undefined,
|
||||||
signingCity: input.signingCity || undefined,
|
signingCity: input.signingCity || undefined,
|
||||||
signatureDataUrl: input.signatureDataUrl || undefined,
|
signatureDataUrl: input.signatureDataUrl || undefined,
|
||||||
}
|
}
|
||||||
@ -117,11 +124,11 @@ export async function subscribeAbo(input: SubscribeAboInput) {
|
|||||||
coffeeId: i.coffeeId,
|
coffeeId: i.coffeeId,
|
||||||
quantity: i.quantity != null ? i.quantity : 1,
|
quantity: i.quantity != null ? i.quantity : 1,
|
||||||
}))
|
}))
|
||||||
// NEW: enforce supported package sizes
|
|
||||||
const sumPacks = body.items.reduce((s: number, it: any) => s + Number(it.quantity || 0), 0)
|
const sumPacks = body.items.reduce((s: number, it: any) => s + Number(it.quantity || 0), 0)
|
||||||
if (sumPacks !== 6 && sumPacks !== 12) {
|
const orderPackError = getOrderPackError(sumPacks)
|
||||||
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, 'expected 6 or 12')
|
if (orderPackError) {
|
||||||
throw new Error('Order must contain exactly 6 packs (60 capsules) or 12 packs (120 capsules).')
|
console.warn('[subscribeAbo] Invalid pack total:', sumPacks, orderPackError)
|
||||||
|
throw new Error(orderPackError)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
body.coffeeId = input.coffeeId
|
body.coffeeId = input.coffeeId
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
@ -17,6 +20,7 @@ import {
|
|||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
export default function CommunityPage() {
|
export default function CommunityPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(state => state.user)
|
const user = useAuthStore(state => state.user)
|
||||||
|
|
||||||
@ -113,12 +117,8 @@ export default function CommunityPage() {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">{t('autofix.k08c92a12')}</h1>
|
||||||
Welcome to Profit Planet Community 🌍
|
<p className="text-xl text-gray-600 max-w-3xl mx-auto">{t('autofix.k3c32c87f')}</p>
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
|
||||||
Connect with like-minded individuals, share sustainable practices, and make a positive impact together.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Community Stats */}
|
{/* Community Stats */}
|
||||||
@ -139,12 +139,8 @@ export default function CommunityPage() {
|
|||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 flex items-center">
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center">
|
||||||
<TrophyIcon className="h-6 w-6 text-[#8D6B1D] mr-2" />
|
<TrophyIcon className="h-6 w-6 text-[#8D6B1D] mr-2" />{t('autofix.k5c598bc0')}</h2>
|
||||||
Trending Groups
|
<button className="text-[#8D6B1D] hover:text-[#7A5E1A] text-sm font-medium flex items-center">{t('autofix.k16b60f69')}<ArrowRightIcon className="h-4 w-4 ml-1" />
|
||||||
</h2>
|
|
||||||
<button className="text-[#8D6B1D] hover:text-[#7A5E1A] text-sm font-medium flex items-center">
|
|
||||||
View All
|
|
||||||
<ArrowRightIcon className="h-4 w-4 ml-1" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -160,9 +156,7 @@ export default function CommunityPage() {
|
|||||||
<p className="text-xs text-gray-500">{group.members} members</p>
|
<p className="text-xs text-gray-500">{group.members} members</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="w-full mt-3 px-3 py-2 bg-[#8D6B1D]/10 text-[#8D6B1D] rounded-lg text-sm font-medium hover:bg-[#8D6B1D]/20 transition-colors">
|
<button className="w-full mt-3 px-3 py-2 bg-[#8D6B1D]/10 text-[#8D6B1D] rounded-lg text-sm font-medium hover:bg-[#8D6B1D]/20 transition-colors">{t('autofix.k15da24d8')}</button>
|
||||||
Join Group
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -172,12 +166,8 @@ export default function CommunityPage() {
|
|||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 flex items-center">
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center">
|
||||||
<ChatBubbleLeftRightIcon className="h-6 w-6 text-[#8D6B1D] mr-2" />
|
<ChatBubbleLeftRightIcon className="h-6 w-6 text-[#8D6B1D] mr-2" />{t('autofix.k70bcafbd')}</h2>
|
||||||
Recent Discussions
|
<button className="text-[#8D6B1D] hover:text-[#7A5E1A] text-sm font-medium">{t('autofix.k9c3db145')}</button>
|
||||||
</h2>
|
|
||||||
<button className="text-[#8D6B1D] hover:text-[#7A5E1A] text-sm font-medium">
|
|
||||||
Start Discussion
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -220,41 +210,35 @@ export default function CommunityPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-4">Quick Actions</h3>
|
<h3 className="font-semibold text-gray-900 mb-4">{t('autofix.k52af8b8d')}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button className="w-full flex items-center justify-center px-4 py-3 bg-[#8D6B1D] text-white rounded-lg hover:bg-[#7A5E1A] transition-colors">
|
<button className="w-full flex items-center justify-center px-4 py-3 bg-[#8D6B1D] text-white rounded-lg hover:bg-[#7A5E1A] transition-colors">
|
||||||
<PlusIcon className="h-4 w-4 mr-2" />
|
<PlusIcon className="h-4 w-4 mr-2" />{t('autofix.k6a486e3e')}</button>
|
||||||
Create Group
|
|
||||||
</button>
|
|
||||||
<button className="w-full flex items-center justify-center px-4 py-3 border border-[#8D6B1D] text-[#8D6B1D] rounded-lg hover:bg-[#8D6B1D]/10 transition-colors">
|
<button className="w-full flex items-center justify-center px-4 py-3 border border-[#8D6B1D] text-[#8D6B1D] rounded-lg hover:bg-[#8D6B1D]/10 transition-colors">
|
||||||
<ChatBubbleLeftRightIcon className="h-4 w-4 mr-2" />
|
<ChatBubbleLeftRightIcon className="h-4 w-4 mr-2" />{t('autofix.k9c3db145')}</button>
|
||||||
Start Discussion
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/dashboard')}
|
onClick={() => router.push('/dashboard')}
|
||||||
className="w-full flex items-center justify-center px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
className="w-full flex items-center justify-center px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
>
|
>{t('autofix.kd00443f2')}</button>
|
||||||
Go to Dashboard
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* My Groups */}
|
{/* My Groups */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-4">My Groups</h3>
|
<h3 className="font-semibold text-gray-900 mb-4">{t('autofix.k26ecadfd')}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
<div className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||||
<div className="text-lg">🌱</div>
|
<div className="text-lg">🌱</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">Eco Warriors</p>
|
<p className="text-sm font-medium text-gray-900">{t('autofix.k58424b1d')}</p>
|
||||||
<p className="text-xs text-gray-500">1,284 members</p>
|
<p className="text-xs text-gray-500">{t('autofix.kaf787fe5')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
<div className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||||
<div className="text-lg">♻️</div>
|
<div className="text-lg">♻️</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">Zero Waste Living</p>
|
<p className="text-sm font-medium text-gray-900">{t('autofix.k6de13000')}</p>
|
||||||
<p className="text-xs text-gray-500">892 members</p>
|
<p className="text-xs text-gray-500">{t('autofix.k258c3515')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -262,16 +246,14 @@ export default function CommunityPage() {
|
|||||||
|
|
||||||
{/* Community Guidelines */}
|
{/* Community Guidelines */}
|
||||||
<div className="bg-gradient-to-br from-[#8D6B1D]/10 to-[#C49225]/10 rounded-lg p-6 border border-[#8D6B1D]/20">
|
<div className="bg-gradient-to-br from-[#8D6B1D]/10 to-[#C49225]/10 rounded-lg p-6 border border-[#8D6B1D]/20">
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">Community Guidelines</h3>
|
<h3 className="font-semibold text-gray-900 mb-2">{t('autofix.k961ba411')}</h3>
|
||||||
<ul className="text-sm text-gray-700 space-y-1">
|
<ul className="text-sm text-gray-700 space-y-1">
|
||||||
<li>• Be respectful and kind</li>
|
<li>{t('autofix.kccf7593a')}</li>
|
||||||
<li>• Stay on topic</li>
|
<li>{t('autofix.kf69154f8')}</li>
|
||||||
<li>• Share authentic experiences</li>
|
<li>{t('autofix.k483aa95a')}</li>
|
||||||
<li>• Help others learn and grow</li>
|
<li>{t('autofix.k75d83433')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button className="text-xs text-[#8D6B1D] hover:underline mt-3">
|
<button className="text-xs text-[#8D6B1D] hover:underline mt-3">{t('autofix.k6aa2d843')}</button>
|
||||||
Read full guidelines
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,93 +3,79 @@
|
|||||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
||||||
import { useTranslation } from '../i18n/useTranslation';
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { SUPPORTED_LANGUAGES, LANGUAGE_NAMES } from '../i18n/config';
|
|
||||||
|
interface LangEntry { code: string; name: string }
|
||||||
|
|
||||||
interface LanguageSwitcherProps {
|
interface LanguageSwitcherProps {
|
||||||
variant?: 'light' | 'dark';
|
variant?: 'light' | 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag Icons mit Emoji (viel sauberer als selbst gezeichnete CSS-Flaggen)
|
|
||||||
const FlagIcon = ({ countryCode, className = "size-5" }: { countryCode: string; className?: string }) => {
|
|
||||||
const flags = {
|
|
||||||
'de': '🇩🇪',
|
|
||||||
'en': '🇬🇧'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`${className} flex items-center justify-center text-base`}>
|
|
||||||
{flags[countryCode as keyof typeof flags] || '🏳️'}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcherProps) {
|
export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcherProps) {
|
||||||
const { language, setLanguage } = useTranslation();
|
const { language, setLanguage, languages } = useTranslation();
|
||||||
|
|
||||||
const getButtonStyles = () => {
|
const allLangs: LangEntry[] = languages.map((lang) => ({
|
||||||
if (variant === 'dark') {
|
code: lang.code,
|
||||||
return 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white/10 px-3 py-2 text-sm font-semibold text-white inset-ring-1 inset-ring-white/5 hover:bg-white/20';
|
name: lang.name,
|
||||||
}
|
}));
|
||||||
return 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-gray-100 px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-gray-300 hover:bg-gray-200';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMenuStyles = () => {
|
const activeLang: LangEntry =
|
||||||
if (variant === 'dark') {
|
allLangs.find((l) => l.code === language) ?? { code: language, name: language };
|
||||||
return 'absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-white/10 rounded-md bg-gray-800 outline-1 -outline-offset-1 outline-white/10 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
|
|
||||||
}
|
|
||||||
return 'absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-gray-100 rounded-md bg-white outline-1 -outline-offset-1 outline-gray-200 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getItemStyles = (isActive: boolean) => {
|
const buttonCls =
|
||||||
if (variant === 'dark') {
|
variant === 'dark'
|
||||||
return `group flex items-center px-4 py-2 text-sm ${
|
? 'group inline-flex min-w-[168px] items-center justify-between gap-3 rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-sm font-semibold text-white shadow-sm backdrop-blur-sm transition hover:bg-white/15 data-[open]:bg-white/15 data-[open]:border-white/25'
|
||||||
isActive
|
: 'group inline-flex min-w-[176px] items-center justify-between gap-3 rounded-2xl border border-white/80 bg-white/75 px-3.5 py-2.5 text-sm font-semibold text-slate-900 shadow-[0_18px_45px_-28px_rgba(15,23,42,0.32)] backdrop-blur-md transition hover:border-white hover:bg-white/90 hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.36)] data-[open]:border-white data-[open]:bg-white/92 data-[open]:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.36)]';
|
||||||
? 'bg-[#8D6B1D] text-white'
|
|
||||||
: 'text-gray-300 data-focus:bg-white/5 data-focus:text-white data-focus:outline-hidden'
|
const menuCls =
|
||||||
}`;
|
variant === 'dark'
|
||||||
}
|
? 'absolute right-0 z-50 mt-2.5 w-60 origin-top-right rounded-2xl border border-white/15 bg-slate-900/95 p-1.5 shadow-2xl backdrop-blur-md transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in'
|
||||||
return `group flex items-center px-4 py-2 text-sm ${
|
: 'absolute right-0 z-50 mt-3 w-64 origin-top-right rounded-[24px] border border-white/80 bg-white/88 p-2 shadow-[0_28px_80px_-38px_rgba(15,23,42,0.4)] backdrop-blur-xl transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
|
||||||
isActive
|
|
||||||
? 'bg-[#8D6B1D] text-white'
|
const itemCls = (isActive: boolean) =>
|
||||||
: 'text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden'
|
variant === 'dark'
|
||||||
}`;
|
? `flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition ${isActive ? 'bg-[#8D6B1D]/25 text-white ring-1 ring-[#8D6B1D]/50' : 'text-slate-200 hover:bg-white/10 hover:text-white'}`
|
||||||
};
|
: `flex w-full items-center gap-3 rounded-2xl px-3.5 py-3 text-sm transition ${isActive ? 'bg-gradient-to-r from-[#8D6B1D]/12 via-amber-100/70 to-white/80 text-[#6f5416] ring-1 ring-[#8D6B1D]/20 shadow-[inset_0_1px_0_rgba(255,255,255,0.85)]' : 'text-slate-700 hover:bg-white/75 hover:text-slate-900'}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu as="div" className="relative inline-block">
|
<Menu as="div" className="relative inline-block">
|
||||||
<MenuButton className={getButtonStyles()}>
|
<MenuButton className={buttonCls}>
|
||||||
<FlagIcon countryCode={language} className="size-4" />
|
<span className="inline-flex items-center gap-2 min-w-0">
|
||||||
{LANGUAGE_NAMES[language]}
|
<span className="truncate">{activeLang.name}</span>
|
||||||
<ChevronDownIcon aria-hidden="true" className="-mr-1 size-5 text-gray-500" />
|
<span
|
||||||
|
className={
|
||||||
|
variant === 'dark'
|
||||||
|
? 'rounded-md bg-white/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white/80'
|
||||||
|
: 'rounded-full border border-white/80 bg-white/70 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.18em] text-slate-600 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{activeLang.code}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon aria-hidden="true" className="size-4 shrink-0 opacity-70 transition group-data-[open]:rotate-180" />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|
||||||
<MenuItems
|
<MenuItems transition className={menuCls}>
|
||||||
transition
|
{allLangs.map((lang) => (
|
||||||
className={getMenuStyles()}
|
<MenuItem key={lang.code}>
|
||||||
>
|
<button onClick={() => setLanguage(lang.code)} className={itemCls(language === lang.code)}>
|
||||||
<div className="py-1">
|
<span className="flex-1 text-left truncate">{lang.name}</span>
|
||||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
<span
|
||||||
<MenuItem key={lang}>
|
className={
|
||||||
<button
|
|
||||||
onClick={() => setLanguage(lang)}
|
|
||||||
className={getItemStyles(language === lang)}
|
|
||||||
>
|
|
||||||
<FlagIcon
|
|
||||||
countryCode={lang}
|
|
||||||
className={`mr-3 size-5 ${
|
|
||||||
variant === 'dark'
|
variant === 'dark'
|
||||||
? (language === lang ? 'opacity-100' : 'opacity-70 group-data-focus:opacity-100')
|
? 'rounded-md bg-white/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white/75'
|
||||||
: (language === lang ? 'opacity-100' : 'opacity-80 group-data-focus:opacity-100')
|
: 'rounded-full border border-white/75 bg-white/65 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500'
|
||||||
}`}
|
}
|
||||||
/>
|
>
|
||||||
<span className="flex-1 text-left">{LANGUAGE_NAMES[lang]}</span>
|
{lang.code}
|
||||||
{language === lang && (
|
</span>
|
||||||
<span className="ml-2 text-xs font-bold">✓</span>
|
{language === lang.code && (
|
||||||
|
<span className={variant === 'dark' ? 'text-[11px] font-bold text-amber-300' : 'text-[11px] font-bold text-[#8D6B1D]'}>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</MenuItems>
|
</MenuItems>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import Header from './nav/Header';
|
|||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
import PageTransitionEffect from './animation/pageTransitionEffect';
|
import PageTransitionEffect from './animation/pageTransitionEffect';
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
|
||||||
// Utility to detect mobile devices
|
// Utility to detect mobile devices
|
||||||
function isMobileDevice() {
|
function isMobileDevice() {
|
||||||
if (typeof navigator === 'undefined') return false;
|
if (typeof navigator === 'undefined') return false;
|
||||||
@ -27,6 +29,7 @@ export default function PageLayout({
|
|||||||
className = 'bg-white text-gray-900',
|
className = 'bg-white text-gray-900',
|
||||||
contentClassName = 'flex-1 relative z-10 w-full',
|
contentClassName = 'flex-1 relative z-10 w-full',
|
||||||
}: PageLayoutProps) {
|
}: PageLayoutProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const isMobile = isMobileDevice();
|
const isMobile = isMobileDevice();
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false); // NEW
|
const [isLoggingOut, setIsLoggingOut] = useState(false); // NEW
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@ -72,7 +75,7 @@ export default function PageLayout({
|
|||||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
<div className="flex flex-col items-center gap-3 text-white">
|
<div className="flex flex-col items-center gap-3 text-white">
|
||||||
<div className="h-10 w-10 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
<div className="h-10 w-10 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
||||||
<p className="text-sm font-medium">Logging you out...</p>
|
<p className="text-sm font-medium">{t('autofix.kb1c1c0e5')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
import { Dialog, Transition } from '@headlessui/react'
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
import {
|
import {
|
||||||
@ -44,6 +47,7 @@ export default function TutorialModal({
|
|||||||
onNext,
|
onNext,
|
||||||
onPrevious
|
onPrevious
|
||||||
}: TutorialModalProps) {
|
}: TutorialModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const step = steps[currentStep - 1]
|
const step = steps[currentStep - 1]
|
||||||
|
|
||||||
if (!step) return null
|
if (!step) return null
|
||||||
@ -194,17 +198,13 @@ export default function TutorialModal({
|
|||||||
? 'text-slate-50 cursor-default'
|
? 'text-slate-50 cursor-default'
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>{t('autofix.kccc13f16')}</button>
|
||||||
← Go back
|
|
||||||
</button>
|
|
||||||
{!isLastStep && (
|
{!isLastStep && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
className="text-xs font-semibold text-blue-600 hover:text-blue-700 transition-colors"
|
className="text-xs font-semibold text-blue-600 hover:text-blue-700 transition-colors"
|
||||||
>
|
>{t('autofix.ka3cbb536')}</button>
|
||||||
Continue →
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -214,7 +214,7 @@ export default function TutorialModal({
|
|||||||
<div className="relative hidden lg:flex lg:flex-1 mt-4 lg:mt-0 h-32 lg:h-full lg:min-h-[150px] items-end justify-end">
|
<div className="relative hidden lg:flex lg:flex-1 mt-4 lg:mt-0 h-32 lg:h-full lg:min-h-[150px] items-end justify-end">
|
||||||
{/* <img
|
{/* <img
|
||||||
src="/images/misc/cow.png"
|
src="/images/misc/cow.png"
|
||||||
alt="Profit Planet Mascot"
|
alt={t('autofix.kcc1c5596')}
|
||||||
className="max-h-full max-w-full object-contain opacity-90 pl-30"
|
className="max-h-full max-w-full object-contain opacity-90 pl-30"
|
||||||
/> */}
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user