dev #21

Merged
Seazn merged 35 commits from dev into main 2026-05-21 17:34:44 +00:00
176 changed files with 24179 additions and 6488 deletions

1
global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '*.css';

1759
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
} }
} }

View 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])}`));

View File

@ -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">
Were a dynamic group of individuals who are passionate about what we do and dedicated to delivering the Were 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">&rarr;</span>
See our job postings
<span aria-hidden="true">&rarr;</span>
</a> </a>
</div> </div>
</div> </div>

View File

@ -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>

View File

@ -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">

View File

@ -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 &amp; City</label>
Postal Code &amp; 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>
) )

View File

@ -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)}

View File

@ -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.`

View File

@ -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>
);
}

View File

@ -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),

View File

@ -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'}`}

View File

@ -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,

View File

@ -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>

View File

@ -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 => (

View File

@ -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>

View File

@ -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 };
}

View File

@ -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,
}
}

View File

@ -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>

View File

@ -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); }}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
)}
</>
);
}

View 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>
)
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View 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,
}
}

View File

@ -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,
};
}

View 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 };
}

View File

@ -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,
};
}

View 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 &amp; 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>
);
}

View File

@ -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,

View File

@ -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>
)} )}

View File

@ -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">5ary 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 */}

View File

@ -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 matrixs 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>

View File

@ -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>

View File

@ -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>

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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"

View File

@ -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,

View 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,
}
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View 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,
}
}

View File

@ -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>
) )

View File

@ -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}
/> />

View 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'
}

View 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
}

View File

@ -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>

View File

@ -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 */}

View File

@ -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>
); );

View File

@ -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,
}; };
} }

View File

@ -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 &quot;{deleteTarget.title}&quot;. 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>
); );
} }

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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'
}

View File

@ -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,
}
}

View File

@ -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}
/> />

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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>
)
}

View 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>
)
}

View 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,
}
}

View File

@ -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()

View File

@ -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>

View 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,
};
}

View 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,
});
}

View 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');
}

File diff suppressed because it is too large Load Diff

View 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 }
);
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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}</>;
}

View File

@ -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,
}; };
}); });

View 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,
};
}

View File

@ -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 };
} }

View 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',
};
}

View 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;
}, {});
}

View File

@ -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 (10120)</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>
); );

View File

@ -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>
) )
} }

View File

@ -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

View File

@ -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>

View File

@ -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>
); );

View File

@ -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>
)} )}

View File

@ -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