wish i was dead
This commit is contained in:
parent
ffe357a05a
commit
afeff4f474
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import {
|
import {
|
||||||
AcademicCapIcon,
|
AcademicCapIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@ -206,6 +209,7 @@ const footerNavigation = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AboutUsPage() {
|
export default function AboutUsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gray-900 pb-24 sm:pb-32">
|
<div className="bg-gray-900 pb-24 sm:pb-32">
|
||||||
@ -227,7 +231,7 @@ export default function AboutUsPage() {
|
|||||||
{/* Header section */}
|
{/* Header section */}
|
||||||
<div className="px-6 pt-14 lg:px-8">
|
<div className="px-6 pt-14 lg:px-8">
|
||||||
<div className="mx-auto max-w-2xl pt-24 text-center sm:pt-40">
|
<div className="mx-auto max-w-2xl pt-24 text-center sm:pt-40">
|
||||||
<h1 className="text-5xl font-semibold tracking-tight text-white sm:text-7xl">We are a community</h1>
|
<h1 className="text-5xl font-semibold tracking-tight text-white sm:text-7xl">{t('autofix.kbd979e13')}</h1>
|
||||||
<p className="mt-8 text-lg font-medium text-pretty text-gray-400 sm:text-xl/8">
|
<p className="mt-8 text-lg font-medium text-pretty text-gray-400 sm:text-xl/8">
|
||||||
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
|
Anim aute id magna aliqua ad ad non deserunt sunt. Qui irure qui lorem cupidatat commodo. Elit sunt amet
|
||||||
fugiat veniam occaecat fugiat.
|
fugiat veniam occaecat fugiat.
|
||||||
@ -288,7 +292,7 @@ export default function AboutUsPage() {
|
|||||||
{/* Feature section */}
|
{/* Feature section */}
|
||||||
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
|
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
|
||||||
<div className="mx-auto max-w-2xl lg:mx-0">
|
<div className="mx-auto max-w-2xl lg:mx-0">
|
||||||
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">Our values</h2>
|
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">{t('autofix.kf0646f35')}</h2>
|
||||||
<p className="mt-6 text-lg/8 text-gray-300">
|
<p className="mt-6 text-lg/8 text-gray-300">
|
||||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste
|
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste
|
||||||
dolor cupiditate blanditiis.
|
dolor cupiditate blanditiis.
|
||||||
@ -310,7 +314,7 @@ export default function AboutUsPage() {
|
|||||||
{/* Team section */}
|
{/* Team section */}
|
||||||
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
|
<div className="mx-auto mt-32 max-w-7xl px-6 sm:mt-40 lg:px-8">
|
||||||
<div className="mx-auto max-w-2xl lg:mx-0">
|
<div className="mx-auto max-w-2xl lg:mx-0">
|
||||||
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">Our team</h2>
|
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">{t('autofix.k3777e830')}</h2>
|
||||||
<p className="mt-6 text-lg/8 text-gray-400">
|
<p className="mt-6 text-lg/8 text-gray-400">
|
||||||
We’re a dynamic group of individuals who are passionate about what we do and dedicated to delivering the
|
We’re a dynamic group of individuals who are passionate about what we do and dedicated to delivering the
|
||||||
best results for our clients.
|
best results for our clients.
|
||||||
@ -345,9 +349,7 @@ export default function AboutUsPage() {
|
|||||||
className="h-96 w-full flex-none rounded-2xl object-cover shadow-xl lg:aspect-square lg:h-auto lg:max-w-sm"
|
className="h-96 w-full flex-none rounded-2xl object-cover shadow-xl lg:aspect-square lg:h-auto lg:max-w-sm"
|
||||||
/>
|
/>
|
||||||
<div className="w-full flex-auto">
|
<div className="w-full flex-auto">
|
||||||
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">
|
<h2 className="text-4xl font-semibold tracking-tight text-pretty text-white sm:text-5xl">{t('autofix.k5ef19112')}</h2>
|
||||||
Join our team
|
|
||||||
</h2>
|
|
||||||
<p className="mt-6 text-lg/8 text-pretty text-gray-400">
|
<p className="mt-6 text-lg/8 text-pretty text-gray-400">
|
||||||
Lorem ipsum dolor sit amet consect adipisicing elit. Possimus magnam voluptatum cupiditate veritatis
|
Lorem ipsum dolor sit amet consect adipisicing elit. Possimus magnam voluptatum cupiditate veritatis
|
||||||
in accusamus quisquam.
|
in accusamus quisquam.
|
||||||
@ -364,9 +366,7 @@ export default function AboutUsPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-10 flex">
|
<div className="mt-10 flex">
|
||||||
<a href="#" className="text-sm/6 font-semibold text-indigo-400 hover:text-indigo-300">
|
<a href="#" className="text-sm/6 font-semibold text-indigo-400 hover:text-indigo-300">{t('autofix.k81b056f2')}<span aria-hidden="true">→</span>
|
||||||
See our job postings
|
|
||||||
<span aria-hidden="true">→</span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import Cropper from 'react-easy-crop'
|
import Cropper from 'react-easy-crop'
|
||||||
import { Point, Area } from 'react-easy-crop'
|
import { Point, Area } from 'react-easy-crop'
|
||||||
@ -11,6 +14,7 @@ interface AffiliateCropModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AffiliateCropModal({ isOpen, imageSrc, onClose, onCropComplete }: AffiliateCropModalProps) {
|
export default function AffiliateCropModal({ isOpen, imageSrc, onClose, onCropComplete }: AffiliateCropModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
||||||
@ -70,7 +74,7 @@ export default function AffiliateCropModal({ isOpen, imageSrc, onClose, onCropCo
|
|||||||
<div className="relative w-full max-w-4xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
|
<div className="relative w-full max-w-4xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-white">
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-white">
|
||||||
<h2 className="text-xl font-semibold text-blue-900">Crop Affiliate Logo</h2>
|
<h2 className="text-xl font-semibold text-blue-900">{t('autofix.kcf4ba87d')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-500 hover:text-gray-700 transition"
|
className="text-gray-500 hover:text-gray-700 transition"
|
||||||
@ -120,9 +124,7 @@ export default function AffiliateCropModal({ isOpen, imageSrc, onClose, onCropCo
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
||||||
>
|
>{t('autofix.kef1656df')}</button>
|
||||||
Apply Crop
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import Header from '../../components/nav/Header'
|
import Header from '../../components/nav/Header'
|
||||||
import Footer from '../../components/Footer'
|
import Footer from '../../components/Footer'
|
||||||
@ -40,6 +43,7 @@ const AFFILIATE_CATEGORIES = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export default function AffiliateManagementPage() {
|
export default function AffiliateManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const isAdmin = !!user && (
|
const isAdmin = !!user && (
|
||||||
@ -133,9 +137,7 @@ export default function AffiliateManagementPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
className="mt-2 text-sm text-red-600 hover:text-red-800 font-medium"
|
className="mt-2 text-sm text-red-600 hover:text-red-800 font-medium"
|
||||||
>
|
>{t('autofix.k3b7dd87a')}</button>
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -143,20 +145,14 @@ export default function AffiliateManagementPage() {
|
|||||||
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-6 md:py-10 px-4 md:px-8 rounded-2xl shadow-lg mb-6 md:mb-8">
|
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-6 md:py-10 px-4 md:px-8 rounded-2xl shadow-lg mb-6 md:mb-8">
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 md:gap-6 mb-4 md:mb-6">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 md:gap-6 mb-4 md:mb-6">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl md:text-4xl font-extrabold text-blue-900 tracking-tight">
|
<h1 className="text-2xl md:text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.k0fe28e0b')}</h1>
|
||||||
Affiliate Management
|
<p className="text-sm md:text-lg text-blue-700 mt-1 md:mt-2">{t('autofix.k49568342')}</p>
|
||||||
</h1>
|
|
||||||
<p className="text-sm md:text-lg text-blue-700 mt-1 md:mt-2">
|
|
||||||
Manage your affiliate partners and tracking links
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
className="w-full md:w-auto inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
className="w-full md:w-auto inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-5 w-5" />
|
<PlusIcon className="h-5 w-5" />{t('autofix.ke1abc7d9')}</button>
|
||||||
Add Affiliate
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter */}
|
{/* Search and Filter */}
|
||||||
@ -165,7 +161,7 @@ export default function AffiliateManagementPage() {
|
|||||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search affiliates..."
|
placeholder={t('autofix.k832a032b')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
@ -193,7 +189,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 +227,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 +272,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,7 +346,7 @@ 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'
|
||||||
? 'Try adjusting your search or filter'
|
? 'Try adjusting your search or filter'
|
||||||
@ -551,7 +547,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 +555,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 +590,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 +604,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 +640,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 +653,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 +687,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>
|
||||||
@ -815,7 +809,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 +817,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 +827,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 +838,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 +849,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 +863,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 && (
|
||||||
@ -908,7 +902,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 +915,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 +948,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>
|
||||||
@ -979,10 +971,8 @@ function DeleteConfirmModal({ affiliateName, onClose, onConfirm, isDeleting }: {
|
|||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||||
<TrashIcon className="h-6 w-6 text-red-600" />
|
<TrashIcon className="h-6 w-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-4 text-lg font-semibold text-center text-gray-900">Delete Affiliate</h3>
|
<h3 className="mt-4 text-lg font-semibold text-center text-gray-900">{t('autofix.kccbc54c1')}</h3>
|
||||||
<p className="mt-2 text-sm text-center text-gray-600">
|
<p className="mt-2 text-sm text-center text-gray-600">{t('autofix.k055bba0c')}<span className="font-semibold">{affiliateName}</span>{t('autofix.kd5cca6e9')}</p>
|
||||||
Are you sure you want to delete <span className="font-semibold">{affiliateName}</span>? This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import useContractManagement from '../hooks/useContractManagement'
|
import useContractManagement from '../hooks/useContractManagement'
|
||||||
|
|
||||||
@ -31,6 +34,7 @@ function summarizeForLog(payload: Record<string, any>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CompanySettingsPanel() {
|
export default function CompanySettingsPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { getCompanySettings, updateCompanySettings } = useContractManagement()
|
const { getCompanySettings, updateCompanySettings } = useContractManagement()
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
@ -155,9 +159,7 @@ export default function CompanySettingsPanel() {
|
|||||||
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 text-sm text-gray-500 py-4">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-900" />
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-900" />{t('autofix.k81a1b900')}</div>
|
||||||
Loading settings…
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,9 +167,7 @@ export default function CompanySettingsPanel() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="company_name" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="company_name" className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.k33918465')}</label>
|
||||||
Company Name
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="company_name"
|
id="company_name"
|
||||||
@ -175,7 +175,7 @@ export default function CompanySettingsPanel() {
|
|||||||
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 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
placeholder="ProfitPlanet GmbH"
|
placeholder={t('autofix.k91e69df1')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -189,7 +189,7 @@ export default function CompanySettingsPanel() {
|
|||||||
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 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
placeholder="Musterstraße 1"
|
placeholder={t('autofix.k81c7c2f2')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -203,7 +203,7 @@ export default function CompanySettingsPanel() {
|
|||||||
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 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
placeholder="12345 Berlin"
|
placeholder={t('autofix.k93165aea')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -265,7 +265,7 @@ export default function CompanySettingsPanel() {
|
|||||||
{saving ? 'Saving…' : 'Save'}
|
{saving ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
{saved && (
|
{saved && (
|
||||||
<span className="text-sm text-green-600 font-medium">Saved successfully</span>
|
<span className="text-sm text-green-600 font-medium">{t('autofix.ka29ac729')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import useContractManagement from '../hooks/useContractManagement';
|
import useContractManagement from '../hooks/useContractManagement';
|
||||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editingTemplateId?: string | null;
|
editingTemplateId?: string | null;
|
||||||
onCancelEdit?: () => void;
|
onCancelEdit?: () => void;
|
||||||
@ -11,6 +13,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdit }: Props) {
|
export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdit }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [htmlCode, setHtmlCode] = useState('');
|
const [htmlCode, setHtmlCode] = useState('');
|
||||||
const [isPreview, setIsPreview] = useState(false);
|
const [isPreview, setIsPreview] = useState(false);
|
||||||
@ -251,7 +254,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
{editingMeta && (
|
{editingMeta && (
|
||||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-3 flex items-center justify-between gap-3">
|
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-3 flex items-center justify-between gap-3">
|
||||||
<div className="text-sm text-indigo-900">
|
<div className="text-sm text-indigo-900">
|
||||||
<span className="font-semibold">Editing:</span> {name || 'Untitled'} (v{editingMeta.version}) • state: {editingMeta.state}
|
<span className="font-semibold">{t('autofix.k41afd863')}</span> {name || 'Untitled'} (v{editingMeta.version}) • state: {editingMeta.state}
|
||||||
</div>
|
</div>
|
||||||
{onCancelEdit && (
|
{onCancelEdit && (
|
||||||
<button
|
<button
|
||||||
@ -261,9 +264,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
onCancelEdit();
|
onCancelEdit();
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center rounded-lg bg-white hover:bg-gray-50 text-gray-900 px-3 py-1.5 text-sm font-medium shadow border border-gray-200 transition"
|
className="inline-flex items-center rounded-lg bg-white hover:bg-gray-50 text-gray-900 px-3 py-1.5 text-sm font-medium shadow border border-gray-200 transition"
|
||||||
>
|
>{t('autofix.k06d4487f')}</button>
|
||||||
Cancel editing
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -271,7 +272,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
|
|||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Template name"
|
placeholder={t('autofix.k2fac9ff2')}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -360,9 +361,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 +380,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 +399,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)}
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import useContractManagement, { DocumentTemplate } from '../hooks/useContractManagement';
|
import useContractManagement, { DocumentTemplate } from '../hooks/useContractManagement';
|
||||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
onEdit?: (id: string) => void;
|
onEdit?: (id: string) => void;
|
||||||
@ -345,6 +347,7 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) {
|
export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [items, setItems] = useState<ContractTemplate[]>([]);
|
const [items, setItems] = useState<ContractTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
@ -720,7 +723,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
|||||||
|
|
||||||
<div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center">
|
<div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
<input
|
<input
|
||||||
placeholder="Search by name, language, version or status"
|
placeholder={t('autofix.k35ac864e')}
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-slate-300 focus:ring-4 focus:ring-sky-100"
|
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-slate-300 focus:ring-4 focus:ring-sky-100"
|
||||||
@ -846,7 +849,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
|||||||
<div className="rounded-3xl border border-dashed border-slate-300 bg-slate-50 px-6 py-12 text-center text-sm text-slate-500">
|
<div className="rounded-3xl border border-dashed border-slate-300 bg-slate-50 px-6 py-12 text-center text-sm text-slate-500">
|
||||||
{items.length === 0
|
{items.length === 0
|
||||||
? 'No templates available yet. Create the first template to populate this workspace.'
|
? 'No templates available yet. Create the first template to populate this workspace.'
|
||||||
: 'No templates match the current search.'}
|
: t('autofix.k047a175d')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -854,7 +857,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
|
|||||||
|
|
||||||
<ConfirmActionModal
|
<ConfirmActionModal
|
||||||
open={Boolean(pendingToggle?.requiresConfirm)}
|
open={Boolean(pendingToggle?.requiresConfirm)}
|
||||||
title="Activate template now?"
|
title={t('autofix.k0c51fa85')}
|
||||||
description={pendingToggle?.message || 'This action will update template activation status.'}
|
description={pendingToggle?.message || 'This action will update template activation status.'}
|
||||||
confirmText="Activate"
|
confirmText="Activate"
|
||||||
onClose={() => setPendingToggle(null)}
|
onClose={() => setPendingToggle(null)}
|
||||||
|
|||||||
@ -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>
|
||||||
@ -418,7 +409,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.`
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import ContractTemplateList from './components/contractTemplateList';
|
|||||||
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 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: '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> },
|
||||||
@ -16,6 +18,7 @@ const NAV = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
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 = useSyncExternalStore(
|
const mounted = useSyncExternalStore(
|
||||||
@ -89,9 +92,9 @@ export default function ContractManagementPage() {
|
|||||||
Admin workspace
|
Admin workspace
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-black tracking-tight text-slate-950 md:text-5xl">Template Management</h1>
|
<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">
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-600 md:text-base">
|
||||||
Keep contract, invoice and custom templates tidy in one place, with clearer navigation between active versions, languages and revisions.
|
{t('autofix.k39791457')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-slate-600">
|
<div className="flex flex-wrap gap-2 text-xs text-slate-600">
|
||||||
@ -106,16 +109,16 @@ export default function ContractManagementPage() {
|
|||||||
<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">
|
<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="mb-4 flex items-center gap-2 text-xl font-semibold text-slate-900">
|
<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} />
|
<ContractUploadCompanyStamp onUploaded={bumpRefresh} />
|
||||||
|
|
||||||
<div className="mt-8 border-t border-slate-200 pt-6">
|
<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">
|
<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" 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>
|
<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
|
{t('autofix.kaa8bbc8e')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-4 text-sm text-slate-500">Address details used on invoices.</p>
|
<p className="mb-4 text-sm text-slate-500">{t('autofix.k15bea9bb')}</p>
|
||||||
<CompanySettingsPanel />
|
<CompanySettingsPanel />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -136,7 +139,7 @@ export default function ContractManagementPage() {
|
|||||||
<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">
|
<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="mb-4 flex items-center gap-2 text-xl font-semibold text-slate-900">
|
<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'}`}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'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 {
|
||||||
@ -11,6 +14,7 @@ import { PlusIcon, TrashIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '
|
|||||||
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,
|
||||||
@ -46,10 +50,8 @@ export default function AdminDashboardManagementPage() {
|
|||||||
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
|
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 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>
|
<h1 className="text-3xl sm:text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.kc4315932')}</h1>
|
||||||
<p className="text-sm sm:text-base text-blue-700 mt-2">
|
<p className="text-sm sm:text-base text-blue-700 mt-2">{t('autofix.k098ec0b9')}</p>
|
||||||
Manage the “Platforms” cards shown on the user dashboard.
|
|
||||||
</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">
|
||||||
@ -59,9 +61,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
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-lg bg-blue-900 text-white px-4 py-2 text-sm font-semibold hover:bg-blue-800"
|
||||||
>
|
>
|
||||||
<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}
|
||||||
@ -98,9 +98,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
|
|
||||||
<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-2xl border border-gray-200 bg-white p-6 text-sm text-gray-600">{t('autofix.k832387c5')}</div>
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && platforms.map(platform => (
|
{!loading && platforms.map(platform => (
|
||||||
@ -168,7 +166,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
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-lg border px-3 py-2 text-sm ' +
|
||||||
(isValidHref(platform.href) ? 'border-gray-200' : 'border-red-300')
|
(isValidHref(platform.href) ? 'border-gray-200' : 'border-red-300')
|
||||||
@ -229,7 +227,7 @@ 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 text-gray-700">{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 })}
|
||||||
@ -246,9 +244,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-2xl border border-gray-200 bg-white p-8 text-center text-sm text-gray-600">{t('autofix.kbce9fbea')}</div>
|
||||||
No platforms configured.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Header from '../../components/nav/Header'
|
import Header from '../../components/nav/Header'
|
||||||
import Footer from '../../components/Footer'
|
import Footer from '../../components/Footer'
|
||||||
@ -11,6 +14,7 @@ import { importSqlDump, SqlExecutionData, SqlExecutionMeta } from './hooks/execu
|
|||||||
import { createFolderStructure, listFolderStructureIssues, listLooseFiles, listGhostDirectories, moveLooseFilesToContract, FixResult, FolderStructureIssueUser, GhostDirectory, LooseFileUser } from './hooks/exoscaleMaintenance'
|
import { createFolderStructure, listFolderStructureIssues, listLooseFiles, listGhostDirectories, moveLooseFilesToContract, FixResult, FolderStructureIssueUser, GhostDirectory, LooseFileUser } from './hooks/exoscaleMaintenance'
|
||||||
|
|
||||||
export default function DevManagementPage() {
|
export default function DevManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
|
|
||||||
@ -264,18 +268,16 @@ export default function DevManagementPage() {
|
|||||||
<CommandLineIcon className="h-6 w-6 text-blue-700" />
|
<CommandLineIcon className="h-6 w-6 text-blue-700" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-blue-900">Dev Management</h1>
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-blue-900">{t('autofix.k664072a1')}</h1>
|
||||||
<p className="text-sm sm:text-base text-blue-700">
|
<p className="text-sm sm:text-base text-blue-700">{t('autofix.k6e4a6069')}</p>
|
||||||
Import SQL dump files to run database migrations.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 flex items-start gap-2">
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 flex items-start gap-2">
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
<ExclamationTriangleIcon className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">Use with caution</div>
|
<div className="font-semibold">{t('autofix.k6c6e5c0f')}</div>
|
||||||
<div>SQL dumps run immediately and can modify production data.</div>
|
<div>{t('autofix.k8a35cc53')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -288,32 +290,28 @@ export default function DevManagementPage() {
|
|||||||
activeTab === 'sql' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
activeTab === 'sql' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CommandLineIcon className="h-4 w-4" /> SQL Import
|
<CommandLineIcon className="h-4 w-4" />{t('autofix.k4db68c96')}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('structure')}
|
onClick={() => setActiveTab('structure')}
|
||||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||||
activeTab === 'structure' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
activeTab === 'structure' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<WrenchScrewdriverIcon className="h-4 w-4" /> Folder Structure
|
<WrenchScrewdriverIcon className="h-4 w-4" />{t('autofix.kcb491706')}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('loose')}
|
onClick={() => setActiveTab('loose')}
|
||||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||||
activeTab === 'loose' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
activeTab === 'loose' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FolderOpenIcon className="h-4 w-4" /> Loose Files
|
<FolderOpenIcon className="h-4 w-4" />{t('autofix.k04b5cbca')}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('ghost')}
|
onClick={() => setActiveTab('ghost')}
|
||||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||||
activeTab === 'ghost' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
activeTab === 'ghost' ? 'bg-blue-900 text-white' : 'text-blue-800 hover:bg-blue-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FolderOpenIcon className="h-4 w-4" /> Ghost Directories
|
<FolderOpenIcon className="h-4 w-4" />{t('autofix.k6838438d')}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -322,7 +320,7 @@ export default function DevManagementPage() {
|
|||||||
<section className="mt-6 md:mt-8 grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
|
<section className="mt-6 md:mt-8 grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
|
||||||
<div className="lg:col-span-2 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
<div className="lg:col-span-2 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||||
<h2 className="text-lg font-semibold text-blue-900">SQL Dump Import</h2>
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k981b1f1a')}</h2>
|
||||||
|
|
||||||
{/* actions: stack on mobile, full width */}
|
{/* actions: stack on mobile, full width */}
|
||||||
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-2 sm:gap-3">
|
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-2 sm:gap-3">
|
||||||
@ -330,8 +328,7 @@ export default function DevManagementPage() {
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<ArrowUpTrayIcon className="h-4 w-4" /> Import SQL
|
<ArrowUpTrayIcon className="h-4 w-4" />{t('autofix.k8a59b156')}</button>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={clearResults}
|
onClick={clearResults}
|
||||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
@ -349,8 +346,8 @@ export default function DevManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-dashed border-gray-200 bg-slate-50 px-4 sm:px-6 py-8 sm:py-10 text-center">
|
<div className="rounded-xl border border-dashed border-gray-200 bg-slate-50 px-4 sm:px-6 py-8 sm:py-10 text-center">
|
||||||
<div className="text-sm text-gray-600">Select a .sql dump file using Import SQL.</div>
|
<div className="text-sm text-gray-600">{t('autofix.kb6eacc9d')}</div>
|
||||||
<div className="mt-2 text-xs text-gray-500">Only SQL dump files are supported.</div>
|
<div className="mt-2 text-xs text-gray-500">{t('autofix.k3ac8ca10')}</div>
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
<div className="mt-4 text-sm text-blue-900 font-semibold break-words">
|
<div className="mt-4 text-sm text-blue-900 font-semibold break-words">
|
||||||
Selected: {selectedFile.name}
|
Selected: {selectedFile.name}
|
||||||
@ -374,10 +371,10 @@ export default function DevManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||||
<h3 className="text-lg font-semibold text-blue-900 mb-3">Result Summary</h3>
|
<h3 className="text-lg font-semibold text-blue-900 mb-3">{t('autofix.kde2b4fa0')}</h3>
|
||||||
<div className="space-y-2 text-sm text-gray-700">
|
<div className="space-y-2 text-sm text-gray-700">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Result Sets</span>
|
<span>{t('autofix.k7938d4fd')}</span>
|
||||||
<span className="font-semibold text-blue-900">
|
<span className="font-semibold text-blue-900">
|
||||||
{Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0}
|
{Array.isArray(result?.result) ? (result?.result as any[]).length : result?.result ? 1 : 0}
|
||||||
</span>
|
</span>
|
||||||
@ -391,19 +388,17 @@ export default function DevManagementPage() {
|
|||||||
<span className="font-semibold text-blue-900">{meta?.durationMs ? `${meta.durationMs} ms` : '-'}</span>
|
<span className="font-semibold text-blue-900">{meta?.durationMs ? `${meta.durationMs} ms` : '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 text-xs text-gray-500">
|
<div className="mt-6 text-xs text-gray-500">{t('autofix.k0f0395ca')}</div>
|
||||||
Multi-statement SQL and dump files are supported. Use with caution.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-blue-900">Import Results</h2>
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.kb4675362')}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!result && (
|
{!result && (
|
||||||
<div className="text-sm text-gray-500">No results yet. Import a SQL dump to see output.</div>
|
<div className="text-sm text-gray-500">{t('autofix.k23c9f0ff')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result?.result && (
|
{result?.result && (
|
||||||
@ -419,10 +414,8 @@ export default function DevManagementPage() {
|
|||||||
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
<section className="mt-6 md:mt-8 rounded-2xl bg-white border border-gray-100 shadow-lg p-4 md:p-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-lg font-semibold text-blue-900">Exoscale Folder Structure</h2>
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.kd51f320c')}</h2>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">{t('autofix.kb1341138')}</p>
|
||||||
Ensures both contract and gdpr folders exist for each user.
|
|
||||||
</p>
|
|
||||||
{structureStatus && (
|
{structureStatus && (
|
||||||
<div className="text-xs text-slate-500">{structureStatus}</div>
|
<div className="text-xs text-slate-500">{structureStatus}</div>
|
||||||
)}
|
)}
|
||||||
@ -462,9 +455,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 +471,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 +495,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 +526,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>
|
||||||
)}
|
)}
|
||||||
@ -576,9 +567,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 +582,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 +612,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 +644,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 +670,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 => (
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import PageLayout from '../../components/PageLayout'
|
import PageLayout from '../../components/PageLayout'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@ -8,6 +11,7 @@ import useAuthStore from '../../store/authStore'
|
|||||||
import InvoiceDetailModal from './components/InvoiceDetailModal'
|
import InvoiceDetailModal from './components/InvoiceDetailModal'
|
||||||
|
|
||||||
export default function FinanceManagementPage() {
|
export default function FinanceManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const accessToken = useAuthStore(s => s.accessToken)
|
const accessToken = useAuthStore(s => s.accessToken)
|
||||||
const { rates, loading: vatLoading, error: vatError } = useVatRates()
|
const { rates, loading: vatLoading, error: vatError } = useVatRates()
|
||||||
@ -241,8 +245,8 @@ export default function FinanceManagementPage() {
|
|||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||||
<header className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex flex-col gap-2">
|
<header className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex flex-col gap-2">
|
||||||
<h1 className="text-3xl font-extrabold text-blue-900">Finance Management</h1>
|
<h1 className="text-3xl font-extrabold text-blue-900">{t('autofix.k777299de')}</h1>
|
||||||
<p className="text-sm text-blue-700">Overview of taxes, revenue, and invoices.</p>
|
<p className="text-sm text-blue-700">{t('autofix.k01ad6d49')}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@ -266,9 +270,9 @@ export default function FinanceManagementPage() {
|
|||||||
onChange={e => setTimeframe(e.target.value as any)}
|
onChange={e => setTimeframe(e.target.value as any)}
|
||||||
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-gray-200 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-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">YTD</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -278,15 +282,13 @@ export default function FinanceManagementPage() {
|
|||||||
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-3">
|
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<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-[#1C2B4A]">{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-gray-600">Live data from backend; edit on a separate page.</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-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-[#1C2B4A]/90"
|
||||||
>
|
>{t('autofix.k4191cdba')}</button>
|
||||||
Edit VAT
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700">
|
<div className="text-sm text-gray-700">
|
||||||
{vatLoading && 'Loading VAT rates...'}
|
{vatLoading && 'Loading VAT rates...'}
|
||||||
@ -302,10 +304,10 @@ export default function FinanceManagementPage() {
|
|||||||
<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-[#1C2B4A]">Invoices</h2>
|
||||||
<div className="flex flex-wrap gap-2 text-sm">
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
<button onClick={() => { setUploadError(null); setUploadModalOpen(true) }} className="rounded-lg bg-[#1C2B4A] px-3 py-2 text-white font-medium hover:bg-[#1C2B4A]/90">Upload Invoice</button>
|
<button onClick={() => { setUploadError(null); setUploadModalOpen(true) }} className="rounded-lg bg-[#1C2B4A] px-3 py-2 text-white font-medium hover:bg-[#1C2B4A]/90">{t('autofix.kec5a5357')}</button>
|
||||||
<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 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">{t('autofix.kfdcad59b')}</button>
|
||||||
<button onClick={() => exportBills('csv')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export CSV</button>
|
<button onClick={() => exportBills('csv')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">{t('autofix.k4c5e8e87')}</button>
|
||||||
<button onClick={() => exportBills('pdf')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Export PDF</button>
|
<button onClick={() => exportBills('pdf')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">{t('autofix.k4c5ecd73')}</button>
|
||||||
<button onClick={reload} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Reload</button>
|
<button onClick={reload} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Reload</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -356,17 +358,15 @@ export default function FinanceManagementPage() {
|
|||||||
)}
|
)}
|
||||||
{(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-md border border-blue-100 bg-blue-50/60 px-3 py-3 text-sm mb-3">
|
||||||
{diagLoading && <div className="text-blue-800">Checking pool inflow...</div>}
|
{diagLoading && <div className="text-blue-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-blue-900 font-semibold">Pool inflow diagnostic for invoice #{diagData.invoice_id ?? '—'}</div>
|
||||||
<div className="text-gray-700">
|
<div className="text-gray-700">{t('autofix.k81c0b74b')}<span className="font-medium">{diagData.ok ? 'OK' : 'Blocked'}</span>{t('autofix.k77049179')}<span className="font-mono">{diagData.reason}</span>
|
||||||
Status: <span className="font-medium">{diagData.ok ? 'OK' : 'Blocked'}</span> • Reason: <span className="font-mono">{diagData.reason}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{diagData.ok && (
|
{diagData.ok && (
|
||||||
<div className="text-gray-700">
|
<div className="text-gray-700">{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>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Array.isArray(diagData.candidates) && diagData.candidates.length > 0 && (
|
{Array.isArray(diagData.candidates) && diagData.candidates.length > 0 && (
|
||||||
@ -405,7 +405,7 @@ export default function FinanceManagementPage() {
|
|||||||
<th className="px-3 py-2 font-semibold">Invoice</th>
|
<th className="px-3 py-2 font-semibold">Invoice</th>
|
||||||
<th className="px-3 py-2 font-semibold">Customer</th>
|
<th className="px-3 py-2 font-semibold">Customer</th>
|
||||||
<th className="px-3 py-2 font-semibold">Issued</th>
|
<th className="px-3 py-2 font-semibold">Issued</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">Amount</th>
|
||||||
<th className="px-3 py-2 font-semibold">Status</th>
|
<th className="px-3 py-2 font-semibold">Status</th>
|
||||||
<th className="px-3 py-2 font-semibold">Actions</th>
|
<th className="px-3 py-2 font-semibold">Actions</th>
|
||||||
@ -419,9 +419,7 @@ export default function FinanceManagementPage() {
|
|||||||
</>
|
</>
|
||||||
) : 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-gray-500">{t('autofix.kbdb02e32')}</td>
|
||||||
Keine Rechnungen gefunden.
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredBills.map(inv => (
|
filteredBills.map(inv => (
|
||||||
@ -505,22 +503,22 @@ export default function FinanceManagementPage() {
|
|||||||
{uploadModalOpen && (
|
{uploadModalOpen && (
|
||||||
<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-black/40 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl overflow-y-auto max-h-[90vh]">
|
<div className="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl overflow-y-auto max-h-[90vh]">
|
||||||
<h3 className="text-lg font-semibold text-[#1C2B4A] mb-4">Upload Invoice</h3>
|
<h3 className="text-lg font-semibold text-[#1C2B4A] mb-4">{t('autofix.kec5a5357')}</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Customer Name</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.kf2b5c1a6')}</label>
|
||||||
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_name} onChange={e => setUploadForm(f => ({ ...f, buyer_name: e.target.value }))} placeholder="Max Mustermann" />
|
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_name} onChange={e => setUploadForm(f => ({ ...f, buyer_name: e.target.value }))} placeholder={t('autofix.k1882bd75')} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Customer Email</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.k48852b8d')}</label>
|
||||||
<input type="email" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_email} onChange={e => setUploadForm(f => ({ ...f, buyer_email: e.target.value }))} placeholder="kunde@example.com" />
|
<input type="email" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_email} onChange={e => setUploadForm(f => ({ ...f, buyer_email: e.target.value }))} placeholder={t('autofix.kf8c220d3')} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Street</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">Street</label>
|
||||||
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_street} onChange={e => setUploadForm(f => ({ ...f, buyer_street: e.target.value }))} placeholder="Musterstraße 1" />
|
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_street} onChange={e => setUploadForm(f => ({ ...f, buyer_street: e.target.value }))} placeholder={t('autofix.k81c7c2f2')} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Postal Code</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.kc9d9d15d')}</label>
|
||||||
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_postal_code} onChange={e => setUploadForm(f => ({ ...f, buyer_postal_code: e.target.value }))} placeholder="8010" />
|
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_postal_code} onChange={e => setUploadForm(f => ({ ...f, buyer_postal_code: e.target.value }))} placeholder="8010" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -532,7 +530,7 @@ export default function FinanceManagementPage() {
|
|||||||
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_country} onChange={e => setUploadForm(f => ({ ...f, buyer_country: e.target.value }))} placeholder="Austria" />
|
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_country} onChange={e => setUploadForm(f => ({ ...f, buyer_country: e.target.value }))} placeholder="Austria" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Total Gross / Brutto <span className="text-red-500">*</span></label>
|
<label className="block text-xs font-medium text-gray-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-gray-200 px-3 py-2" value={uploadForm.total_gross} onChange={e => setUploadForm(f => ({ ...f, total_gross: e.target.value }))} placeholder="0.00" />
|
<input type="number" step="0.01" min="0" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.total_gross} onChange={e => setUploadForm(f => ({ ...f, total_gross: e.target.value }))} placeholder="0.00" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -575,15 +573,15 @@ export default function FinanceManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Issue Date</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.kd4af6368')}</label>
|
||||||
<input type="date" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.issued_at} onChange={e => setUploadForm(f => ({ ...f, issued_at: e.target.value }))} />
|
<input type="date" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.issued_at} onChange={e => setUploadForm(f => ({ ...f, issued_at: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Due Date</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.k867f8265')}</label>
|
||||||
<input type="date" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.due_at} onChange={e => setUploadForm(f => ({ ...f, due_at: e.target.value }))} />
|
<input type="date" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.due_at} onChange={e => setUploadForm(f => ({ ...f, due_at: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">PDF File</label>
|
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.kd6024811')}</label>
|
||||||
<input
|
<input
|
||||||
type="file" accept="application/pdf"
|
type="file" accept="application/pdf"
|
||||||
className="w-full text-sm text-gray-700 file:mr-3 file:rounded-lg file:border-0 file:bg-blue-50 file:px-3 file:py-2 file:text-blue-900 file:font-medium hover:file:bg-blue-100"
|
className="w-full text-sm text-gray-700 file:mr-3 file:rounded-lg file:border-0 file:bg-blue-50 file:px-3 file:py-2 file:text-blue-900 file:font-medium hover:file:bg-blue-100"
|
||||||
@ -609,19 +607,19 @@ export default function FinanceManagementPage() {
|
|||||||
{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-black/40 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-2xl bg-white p-6 shadow-2xl">
|
||||||
<h3 className="text-lg font-semibold text-[#1C2B4A] mb-1">Send Email Report</h3>
|
<h3 className="text-lg font-semibold text-[#1C2B4A] 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.
|
Only <strong>paid</strong> invoices will be included in the report, regardless of the status filter.
|
||||||
{(billFilter.from || billFilter.to) && (
|
{(billFilter.from || billFilter.to) && (
|
||||||
<span> The current date range filter ({billFilter.from || '…'} – {billFilter.to || '…'}) will be applied.</span>
|
<span> The current date range filter ({billFilter.from || '…'} – {billFilter.to || '…'}) will be applied.</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-gray-700 mb-1">{t('autofix.kd56a13f2')}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={reportEmail}
|
value={reportEmail}
|
||||||
onChange={e => setReportEmail(e.target.value)}
|
onChange={e => setReportEmail(e.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-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"
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !sendingReport) sendEmailReport() }}
|
onKeyDown={e => { if (e.key === 'Enter' && !sendingReport) sendEmailReport() }}
|
||||||
|
|||||||
@ -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
|
||||||
@ -68,15 +72,11 @@ export default function VatEditPage() {
|
|||||||
<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 +85,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 +96,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 +115,14 @@ export default function VatEditPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!loading && !error && pageData.length === 0 && (
|
{!loading && !error && pageData.length === 0 && (
|
||||||
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">No entries found.</td></tr>
|
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-500">{t('autofix.kb337d94e')}</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mt-4 text-sm text-gray-700">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mt-4 text-sm text-gray-700">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>Rows per page:</span>
|
<span>{t('autofix.k2f4ebc32')}</span>
|
||||||
<select
|
<select
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
|
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||||
|
|||||||
@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
|
||||||
|
<h2 className="text-lg font-bold text-[#1C2B4A] mb-4">{t('autofix.kf4e45236')}</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t('autofix.k92639a9a')}</label>
|
||||||
|
<input
|
||||||
|
value={newCode}
|
||||||
|
onChange={(e) => setNewCode(e.target.value)}
|
||||||
|
placeholder={t('autofix.k03538639')}
|
||||||
|
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t('autofix.k926966d0')}</label>
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder={t('autofix.ka019b3c0')}
|
||||||
|
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{addError && <p className="text-xs text-red-600">{addError}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
setAddError('');
|
||||||
|
setNewCode('');
|
||||||
|
setNewName('');
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={onAdd} className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,213 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
import type { NamespaceCategory } from '../hooks/useNamespaceCategories';
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/45 backdrop-blur-sm">
|
||||||
|
<div className="mx-4 w-full max-w-5xl rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden">
|
||||||
|
<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.kef9de7f0')}</h2>
|
||||||
|
<p className="text-xs text-slate-600 mt-1">{t('autofix.kc4671abe')}</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 max-h-[70vh] overflow-y-auto space-y-4">
|
||||||
|
<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-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCreateCategory}
|
||||||
|
className="rounded-md bg-[#1C2B4A] text-white px-3 py-1.5 text-sm font-medium hover:bg-[#152344]"
|
||||||
|
>{t('autofix.k1db86f96')}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
||||||
|
<p className="text-xs font-semibold text-slate-700 mb-2">{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-300 bg-slate-50 px-2 py-1 text-xs text-slate-700"
|
||||||
|
title={t('autofix.k66edf1eb')}
|
||||||
|
>
|
||||||
|
{ns}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{uncategorizedNamespaces.length === 0 && (
|
||||||
|
<span className="text-xs text-slate-400">{t('autofix.k741a01f7')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-xl border border-slate-200 bg-white"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="px-3 py-2.5 border-b border-slate-100 flex items-center justify-between gap-2 cursor-pointer"
|
||||||
|
onClick={() => setExpandedCategoryId((prev) => (prev === cat.id ? null : cat.id))}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-left">
|
||||||
|
<span className="text-sm font-semibold text-[#1C2B4A]">{cat.label}</span>
|
||||||
|
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-700">
|
||||||
|
{cat.namespaces.length}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">{isExpanded ? 'Hide' : 'Manage'}</span>
|
||||||
|
</div>
|
||||||
|
{cat.isCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteCategory(cat.id);
|
||||||
|
}}
|
||||||
|
className="text-xs rounded border border-red-200 bg-red-50 px-2 py-0.5 text-red-600 hover:bg-red-100"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<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-md border border-slate-300 bg-white px-2 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<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-md border border-slate-300 bg-white px-3 py-2 text-xs text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 min-h-10 rounded-md border border-dashed border-slate-200 p-2">
|
||||||
|
{cat.namespaces.map((ns) => (
|
||||||
|
<span
|
||||||
|
key={ns}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragNamespace(ns)}
|
||||||
|
className="group cursor-grab rounded-full border border-indigo-200 bg-indigo-50 px-2 py-1 text-xs text-indigo-700"
|
||||||
|
>
|
||||||
|
{ns}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeNamespaceFromCategory(cat.id, ns)}
|
||||||
|
className="ml-1 text-indigo-500 group-hover:text-red-500"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 border-t border-slate-200 bg-white flex justify-end">
|
||||||
|
<button onClick={onClose} className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { LanguageEntry } from '../hooks/useLanguageManagementTranslations';
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (!deleteTarget) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
|
||||||
|
<h2 className="text-lg font-bold text-red-600 mb-3">{t('autofix.kda5f982e')}</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-5">
|
||||||
|
Delete <strong>{allLanguages.find((l) => l.code === deleteTarget)?.name ?? deleteTarget}</strong>?
|
||||||
|
All translations for this language will be removed.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button onClick={onClose} className="rounded-md border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onDelete(deleteTarget)} className="rounded-md bg-red-600 text-white px-4 py-2 text-sm font-semibold hover:bg-red-700">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { RefObject } 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();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={headerRef} className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-[#1C2B4A]">{t('autofix.k346a2c64')}</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Manage UI translations. All {totalKeys} keys scanned from the English source file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={onScan}
|
||||||
|
disabled={isScanning || isAutoFixing}
|
||||||
|
className="rounded-md border border-[#1C2B4A] text-[#1C2B4A] px-3 py-2 text-sm font-medium hover:bg-[#1C2B4A] hover:text-white transition-colors flex items-center gap-2 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 ? 'Scanning...' : 'Scan & review fixes'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onBackToAdmin}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50"
|
||||||
|
>{t('autofix.kea7cde7a')}</button>
|
||||||
|
{isDirty && (
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
|
||||||
|
>{t('autofix.k4be6f631')}</button>
|
||||||
|
)}
|
||||||
|
{saved && !isDirty && (
|
||||||
|
<span className="rounded-md bg-green-50 border border-green-200 text-green-700 px-3 py-2 text-sm font-medium">
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{allLanguages.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
onClick={() => setActiveLang(lang.code)}
|
||||||
|
className={`relative rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-2 ${
|
||||||
|
activeLang === lang.code
|
||||||
|
? 'bg-[#1C2B4A] text-white shadow'
|
||||||
|
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lang.name}
|
||||||
|
<span className="text-xs opacity-60">({lang.code})</span>
|
||||||
|
{!isBuiltin(lang.code) && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteLanguageRequest(lang.code);
|
||||||
|
}}
|
||||||
|
title={t('autofix.k5fcc9b0e')}
|
||||||
|
className={`ml-1 inline-flex items-center justify-center rounded-full w-4 h-4 text-xs leading-none ${
|
||||||
|
activeLang === lang.code
|
||||||
|
? 'bg-white/20 hover:bg-white/40 text-white'
|
||||||
|
: 'bg-gray-200 hover:bg-red-100 text-gray-500 hover:text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={onOpenAddLanguage}
|
||||||
|
className="rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-sm text-gray-500 hover:border-[#1C2B4A] hover:text-[#1C2B4A] transition"
|
||||||
|
>
|
||||||
|
+ Add language
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-4 flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||||
|
<span>{t('autofix.kb8f33873')}</span>
|
||||||
|
<span>{allTabStats.translated} / {allTabStats.total} keys translated</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-gray-100 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[#1C2B4A] transition-all"
|
||||||
|
style={{ width: `${translationProgressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-[#1C2B4A]">
|
||||||
|
{translationProgressPercent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeLang !== 'en' && allTabStats.missing > 0 && wizardMissingKeysCount > 0 && (
|
||||||
|
<div className="rounded-xl border border-indigo-200 bg-indigo-50 p-4 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-indigo-900">{t('autofix.k5e5e8744')}</p>
|
||||||
|
<p className="text-xs text-indigo-800 mt-1">
|
||||||
|
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang} still has {wizardMissingKeysCount} missing keys. Start the wizard to fill them step by step.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenTranslationWizard}
|
||||||
|
className="rounded-md bg-[#1C2B4A] text-white px-3 py-1.5 text-xs font-semibold hover:bg-[#1C2B4A]/90"
|
||||||
|
>{t('autofix.k725dd1d6')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/app/admin/language-management/components/ScanFixPanel.tsx
Normal file
100
src/app/admin/language-management/components/ScanFixPanel.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
type Props = {
|
||||||
|
fixableFiles: string[]
|
||||||
|
selectedFiles: string[]
|
||||||
|
isAutoFixing: boolean
|
||||||
|
forceConvertToClient: boolean
|
||||||
|
onToggleFile: (file: string) => void
|
||||||
|
onSelectAll: () => void
|
||||||
|
onClear: () => void
|
||||||
|
onToggleForceConvertToClient: () => void
|
||||||
|
onRunFixSelected: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScanFixPanel({
|
||||||
|
fixableFiles,
|
||||||
|
selectedFiles,
|
||||||
|
isAutoFixing,
|
||||||
|
forceConvertToClient,
|
||||||
|
onToggleFile,
|
||||||
|
onSelectAll,
|
||||||
|
onClear,
|
||||||
|
onToggleForceConvertToClient,
|
||||||
|
onRunFixSelected,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="mb-5 rounded-xl border border-indigo-200 bg-indigo-50/40 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-indigo-800">{t('autofix.k43218db0')}</h3>
|
||||||
|
<p className="text-xs text-indigo-700/90 mt-1">{t('autofix.k34a0a2e4')}</p>
|
||||||
|
<label className="mt-2 inline-flex items-center gap-2 text-xs text-indigo-800">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceConvertToClient}
|
||||||
|
onChange={onToggleForceConvertToClient}
|
||||||
|
className="h-3.5 w-3.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>{t('autofix.k68c88f41')}</label>
|
||||||
|
<p className="text-[11px] text-indigo-700/80 mt-1">{t('autofix.k6569783c')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelectAll}
|
||||||
|
className="rounded-md border border-indigo-200 bg-white px-2.5 py-1.5 text-xs font-medium text-indigo-700 hover:bg-indigo-50"
|
||||||
|
>{t('autofix.k4c6eb72c')}</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fixableFiles.length === 0 ? (
|
||||||
|
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">{t('autofix.ke3480838')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2 max-h-44 overflow-y-auto pr-1">
|
||||||
|
{fixableFiles.map((file) => {
|
||||||
|
const checked = selectedFiles.includes(file)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={file}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-indigo-100 bg-white px-3 py-2 text-xs text-indigo-900"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onToggleFile(file)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="font-mono break-all">{file}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<p className="text-xs text-indigo-700">
|
||||||
|
Selected: {selectedFiles.length} / {fixableFiles.length}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isAutoFixing || selectedFiles.length === 0}
|
||||||
|
onClick={onRunFixSelected}
|
||||||
|
className="rounded-md bg-indigo-700 text-white px-3 py-1.5 text-xs font-semibold hover:bg-indigo-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isAutoFixing ? 'Applying fix...' : `Fix selected files (${selectedFiles.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,342 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ScanFixPanel from './ScanFixPanel';
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
import type { WorkspaceScanResult } from '../hooks/useI18nScanWorkflow';
|
||||||
|
|
||||||
|
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;
|
||||||
|
fixableFiles: string[];
|
||||||
|
selectedFiles: string[];
|
||||||
|
forceConvertToClient: boolean;
|
||||||
|
onToggleFile: (file: string) => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onToggleForceConvertToClient: () => void;
|
||||||
|
onRunFixSelected: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScanResultsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
lastScanTime,
|
||||||
|
workspaceScan,
|
||||||
|
totalKeys,
|
||||||
|
namespacesCount,
|
||||||
|
allLanguages,
|
||||||
|
activeLang,
|
||||||
|
scanResults,
|
||||||
|
scanError,
|
||||||
|
isScanning,
|
||||||
|
isAutoFixing,
|
||||||
|
fixableFiles,
|
||||||
|
selectedFiles,
|
||||||
|
forceConvertToClient,
|
||||||
|
onToggleFile,
|
||||||
|
onSelectAll,
|
||||||
|
onClear,
|
||||||
|
onToggleForceConvertToClient,
|
||||||
|
onRunFixSelected,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!isOpen) 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">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<h3 className="text-sm font-semibold text-red-700 mb-2">{t('autofix.kae63e46a')}</h3>
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{workspaceScan.missingKeys.map((entry) => (
|
||||||
|
<div key={entry.key} className="rounded-md border border-red-100 bg-white px-3 py-2">
|
||||||
|
<p className="font-mono text-xs text-red-700">{entry.key}</p>
|
||||||
|
<p className="text-[11px] text-gray-500 mt-1">
|
||||||
|
{entry.files.slice(0, 3).join(', ')}
|
||||||
|
{entry.files.length > 3 ? ` (+${entry.files.length - 3} more)` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workspaceScan && workspaceScan.untranslatedLiterals.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50/50 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-amber-700 mb-2">{t('autofix.k14eb468b')}</h3>
|
||||||
|
<p className="text-xs text-amber-700/80 mb-3">
|
||||||
|
These literals appear directly in JSX. Replace them with t('...') to make the page translatable.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 max-h-56 overflow-y-auto pr-1">
|
||||||
|
{workspaceScan.untranslatedLiterals.slice(0, 80).map((entry) => (
|
||||||
|
<div key={entry.text} className="rounded-md border border-amber-100 bg-white px-3 py-2">
|
||||||
|
<p className="text-xs font-medium text-amber-800">{entry.text}</p>
|
||||||
|
<p className="text-[11px] text-gray-500 mt-1">
|
||||||
|
{entry.files.slice(0, 3).join(', ')}
|
||||||
|
{entry.files.length > 3 ? ` (+${entry.files.length - 3} more)` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-8 py-4 border-t border-slate-200 flex justify-between items-center bg-white">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Scan now checks workspace files (pages, components, hooks, utils) and compares used keys against en.ts.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,494 @@
|
|||||||
|
'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;
|
||||||
|
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>;
|
||||||
|
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,
|
||||||
|
newGlobalKeySelection,
|
||||||
|
setNewGlobalKeySelection,
|
||||||
|
availableGlobalKeyOptions,
|
||||||
|
addGlobalKey,
|
||||||
|
globalFilteredKeys,
|
||||||
|
removeGlobalKey,
|
||||||
|
getDisplayValue,
|
||||||
|
translations,
|
||||||
|
handleChange,
|
||||||
|
globalKeySet,
|
||||||
|
filteredNs,
|
||||||
|
filteredGroups,
|
||||||
|
activeNamespacePanel,
|
||||||
|
setActiveNamespacePanel,
|
||||||
|
namespaceTranslationStats,
|
||||||
|
openedNamespacePanelRef,
|
||||||
|
openFromPanelClickRef,
|
||||||
|
onBackToPanels,
|
||||||
|
onOpenCategoryManager,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50/50 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-[#1C2B4A]">{t('autofix.k5f978731')}</h2>
|
||||||
|
<p className="text-xs text-slate-600 mt-1">{t('autofix.kb7a30760')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenCategoryManager}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100"
|
||||||
|
>{t('autofix.kd6e42900')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveCategory('all')}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
|
||||||
|
activeCategory === 'all'
|
||||||
|
? (activeLang !== 'en' && allTabStats.missing > 0
|
||||||
|
? 'bg-red-600 text-white shadow'
|
||||||
|
: 'bg-[#1C2B4A] text-white shadow')
|
||||||
|
: (activeLang !== 'en' && allTabStats.missing > 0
|
||||||
|
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
||||||
|
: 'text-gray-500 hover:text-[#1C2B4A] hover:bg-gray-100')
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span>All</span>
|
||||||
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
||||||
|
activeCategory === 'all' ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-700'
|
||||||
|
}`}>
|
||||||
|
{allTabStats.translated}/{allTabStats.total}
|
||||||
|
</span>
|
||||||
|
{activeLang !== 'en' && allTabStats.missing > 0 && (
|
||||||
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
||||||
|
activeCategory === 'all' ? 'bg-red-500/70 text-white' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{allTabStats.missing} missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveCategory('global')}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
|
||||||
|
activeCategory === 'global'
|
||||||
|
? (activeLang !== 'en' && globalTabStats.missing > 0
|
||||||
|
? 'bg-red-600 text-white shadow'
|
||||||
|
: 'bg-[#1C2B4A] text-white shadow')
|
||||||
|
: (activeLang !== 'en' && globalTabStats.missing > 0
|
||||||
|
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
||||||
|
: 'text-gray-500 hover:text-[#1C2B4A] hover:bg-gray-100')
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span>Global</span>
|
||||||
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
||||||
|
activeCategory === 'global' ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-700'
|
||||||
|
}`}>
|
||||||
|
{globalTabStats.translated}/{globalTabStats.total}
|
||||||
|
</span>
|
||||||
|
{activeLang !== 'en' && globalTabStats.missing > 0 && (
|
||||||
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
||||||
|
activeCategory === 'global' ? 'bg-red-500/70 text-white' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{globalTabStats.missing} missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{categoriesWithKnownNamespaces.map((cat) => {
|
||||||
|
const catStats = categoryTabStats[cat.id] ?? { total: 0, translated: 0, missing: 0 };
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
onClick={() => setActiveCategory(cat.id)}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
|
||||||
|
activeCategory === cat.id
|
||||||
|
? (activeLang !== 'en' && catStats.missing > 0
|
||||||
|
? 'bg-red-600 text-white shadow'
|
||||||
|
: 'bg-[#1C2B4A] text-white shadow')
|
||||||
|
: (activeLang !== 'en' && catStats.missing > 0
|
||||||
|
? 'border border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
||||||
|
: 'text-gray-500 hover:text-[#1C2B4A] hover:bg-gray-100')
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span>{cat.label}</span>
|
||||||
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
||||||
|
activeCategory === cat.id ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-700'
|
||||||
|
}`}>
|
||||||
|
{catStats.translated}/{catStats.total}
|
||||||
|
</span>
|
||||||
|
{activeLang !== 'en' && catStats.missing > 0 && (
|
||||||
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] leading-none ${
|
||||||
|
activeCategory === cat.id ? 'bg-red-500/70 text-white' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{catStats.missing} missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t('autofix.kbf49d59b')}
|
||||||
|
className="w-full max-w-sm rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
||||||
|
/>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoScrollOnPanelOpen}
|
||||||
|
onChange={(e) => setAutoScrollOnPanelOpen(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-[#1C2B4A] focus:ring-[#1C2B4A]"
|
||||||
|
/>{t('autofix.kfd1e0089')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{activeCategory === 'global' && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
||||||
|
<div className="px-5 py-3 bg-gray-50 border-b border-gray-100 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-[#1C2B4A]">{t('autofix.k6cfeedd3')}</span>
|
||||||
|
<p className="text-xs text-gray-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-md border border-slate-300 bg-white px-2 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<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-md border border-slate-300 bg-white px-2 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{globalFilteredKeys.length > 0 ? (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 bg-gray-50/50">
|
||||||
|
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">Key</th>
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">English (reference)</th>
|
||||||
|
)}
|
||||||
|
<th className="px-5 py-2 text-left font-medium text-gray-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-gray-50 last:border-0 hover:bg-blue-50/30">
|
||||||
|
<td className="px-5 py-2 font-mono text-xs text-gray-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="text-[10px] rounded border border-red-200 px-1.5 py-0.5 text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<td className="px-5 py-2 text-gray-500 align-top pt-3 text-xs">{enVal}</td>
|
||||||
|
)}
|
||||||
|
<td className="px-5 py-2">
|
||||||
|
<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 border px-2 py-1.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-[#1C2B4A] ${
|
||||||
|
hasOverride && activeLang !== 'en'
|
||||||
|
? 'border-green-300 bg-green-50'
|
||||||
|
: 'border-gray-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="Clear override (revert to built-in)"
|
||||||
|
onClick={() => handleChange(key, '')}
|
||||||
|
className="absolute top-1 right-1 text-xs text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center text-sm text-gray-500">{t('autofix.k0700b1f2')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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-xl border px-3 py-2 text-left transition-all duration-300 ${shiftClass} ${
|
||||||
|
isActive
|
||||||
|
? (hasMissing ? 'border-red-400 bg-red-50 shadow-sm' : 'border-[#1C2B4A] bg-[#1C2B4A]/5 shadow-sm')
|
||||||
|
: (hasMissing ? 'border-red-200 bg-red-50/70 hover:bg-red-50' : 'border-gray-200 bg-white hover:border-slate-300 hover:bg-slate-50')
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-semibold text-[#1C2B4A] capitalize">{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-[#1C2B4A] text-white')
|
||||||
|
: (hasMissing ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-700')
|
||||||
|
}`}>
|
||||||
|
{nsStats.translated}/{nsStats.total}
|
||||||
|
</span>
|
||||||
|
{hasMissing && (
|
||||||
|
<span className="rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
|
||||||
|
{nsStats.missing} missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">{isActive ? 'Open' : 'Click to open'}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeNamespacePanel && filteredGroups[activeNamespacePanel] && (
|
||||||
|
<div ref={openedNamespacePanelRef} className="rounded-xl border border-gray-200 bg-white overflow-hidden shadow-sm transition-all duration-300">
|
||||||
|
<div className="w-full flex items-center justify-between px-5 py-3 bg-gray-50 border-b border-gray-100">
|
||||||
|
<span className="font-semibold text-[#1C2B4A] capitalize">{activeNamespacePanel}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs rounded-full bg-slate-100 px-1.5 py-0.5 text-slate-700">
|
||||||
|
{(namespaceTranslationStats[activeNamespacePanel]?.translated ?? 0)}/{(namespaceTranslationStats[activeNamespacePanel]?.total ?? 0)}
|
||||||
|
</span>
|
||||||
|
{activeLang !== 'en' && (namespaceTranslationStats[activeNamespacePanel]?.missing ?? 0) > 0 && (
|
||||||
|
<span className="text-xs rounded-full bg-red-100 px-1.5 py-0.5 text-red-700">
|
||||||
|
{namespaceTranslationStats[activeNamespacePanel]?.missing} missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 bg-gray-50/50">
|
||||||
|
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">Key</th>
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">English (reference)</th>
|
||||||
|
)}
|
||||||
|
<th className="px-5 py-2 text-left font-medium text-gray-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 visibleValue = activeLang === 'en'
|
||||||
|
? currentVal
|
||||||
|
: (translations[activeLang]?.[key] ?? '');
|
||||||
|
const hasOverride = (translations[activeLang]?.[key] ?? '') !== '';
|
||||||
|
const isMissingInOpenedPanel =
|
||||||
|
activeLang !== 'en' && (
|
||||||
|
visibleValue.trim() === '' ||
|
||||||
|
visibleValue.trim() === enVal.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={key} className={`border-b border-gray-50 last:border-0 ${
|
||||||
|
isMissingInOpenedPanel ? 'bg-red-50/50 hover:bg-red-50' : 'hover:bg-blue-50/30'
|
||||||
|
}`}>
|
||||||
|
<td className={`px-5 py-2 font-mono text-xs align-top pt-3 ${
|
||||||
|
isMissingInOpenedPanel ? 'text-red-700' : 'text-gray-500'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span>{key}</span>
|
||||||
|
{globalKeySet.has(key) && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{activeLang !== 'en' && (
|
||||||
|
<td className="px-5 py-2 text-gray-500 align-top pt-3 text-xs">{enVal}</td>
|
||||||
|
)}
|
||||||
|
<td className="px-5 py-2">
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
rows={1}
|
||||||
|
value={visibleValue}
|
||||||
|
onChange={(e) => handleChange(key, e.target.value)}
|
||||||
|
placeholder={activeLang === 'en' ? '' : enVal}
|
||||||
|
className={`w-full rounded border px-2 py-1.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-[#1C2B4A] ${
|
||||||
|
isMissingInOpenedPanel
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: hasOverride && activeLang !== 'en'
|
||||||
|
? 'border-green-300 bg-green-50'
|
||||||
|
: 'border-gray-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="Clear override (revert to built-in)"
|
||||||
|
onClick={() => handleChange(key, '')}
|
||||||
|
className="absolute top-1 right-1 text-xs text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-100 px-5 py-3 bg-white flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBackToPanels}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{t('autofix.k6aba2cb0')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{activeCategory !== 'global' && filteredNs.length === 0 && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center text-sm text-gray-500">{t('autofix.k6a892262')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
import type { LanguageEntry } from '../hooks/useLanguageManagementTranslations';
|
||||||
|
|
||||||
|
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;
|
||||||
|
englishValue: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TranslationWizardModal({
|
||||||
|
isOpen,
|
||||||
|
currentWizardKey,
|
||||||
|
wizardIndex,
|
||||||
|
wizardMissingCount,
|
||||||
|
activeLang,
|
||||||
|
allLanguages,
|
||||||
|
wizardInput,
|
||||||
|
setWizardInput,
|
||||||
|
wizardMarkGlobal,
|
||||||
|
setWizardMarkGlobal,
|
||||||
|
englishValue,
|
||||||
|
onClose,
|
||||||
|
onPrevious,
|
||||||
|
onSkip,
|
||||||
|
onNext,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!isOpen || !currentWizardKey) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/45 backdrop-blur-sm">
|
||||||
|
<div className="mx-4 w-full max-w-2xl rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden">
|
||||||
|
<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) => setWizardMarkGlobal(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-slate-300 text-[#1C2B4A] focus:ring-[#1C2B4A]"
|
||||||
|
/>
|
||||||
|
Mark as global term (for shared words like IBAN)
|
||||||
|
</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}
|
||||||
|
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}
|
||||||
|
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={wizardInput.trim() === ''}
|
||||||
|
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{wizardIndex >= wizardMissingCount - 1 ? 'Save and finish' : 'Save and next'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
src/app/admin/language-management/hooks/useI18nScanWorkflow.ts
Normal file
181
src/app/admin/language-management/hooks/useI18nScanWorkflow.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return {
|
||||||
|
scannedFiles: Number(result?.scannedFiles ?? 0),
|
||||||
|
scannedDirectories: Number(result?.scannedDirectories ?? 0),
|
||||||
|
translationCallCount: Number(result?.translationCallCount ?? 0),
|
||||||
|
uniqueKeyCount: Number(result?.uniqueKeyCount ?? 0),
|
||||||
|
missingKeys: Array.isArray(result?.missingKeys) ? result.missingKeys : [],
|
||||||
|
untranslatedLiterals: Array.isArray(result?.untranslatedLiterals) ? result.untranslatedLiterals : [],
|
||||||
|
autoFixEligibleFiles: Array.isArray(result?.autoFixEligibleFiles) ? result.autoFixEligibleFiles : undefined,
|
||||||
|
autoFixForceConvertibleFiles: Array.isArray(result?.autoFixForceConvertibleFiles) ? result.autoFixForceConvertibleFiles : undefined,
|
||||||
|
changedFileCount: Number(result?.changedFileCount ?? 0),
|
||||||
|
createdKeyCount: Number(result?.createdKeyCount ?? 0),
|
||||||
|
changedFiles: Array.isArray(result?.changedFiles) ? result.changedFiles : [],
|
||||||
|
skippedFiles: Array.isArray(result?.skippedFiles) ? result.skippedFiles : [],
|
||||||
|
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() {
|
||||||
|
const [showScanModal, setShowScanModal] = useState(false)
|
||||||
|
const [lastScanTime, setLastScanTime] = useState<Date | null>(null)
|
||||||
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
|
const [isAutoFixing, setIsAutoFixing] = 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 fetch('/api/i18n/scan', { method: 'GET' })
|
||||||
|
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))
|
||||||
|
|
||||||
|
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 fetch('/api/i18n/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ targetFiles: selectedEligible, forceConvertToClient }),
|
||||||
|
})
|
||||||
|
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 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,
|
||||||
|
scanError,
|
||||||
|
workspaceScan,
|
||||||
|
selectedFiles,
|
||||||
|
fixableFiles,
|
||||||
|
forceConvertToClient,
|
||||||
|
setForceConvertToClient,
|
||||||
|
scan,
|
||||||
|
runFixSelected,
|
||||||
|
toggleFileSelection,
|
||||||
|
selectAllFiles,
|
||||||
|
clearSelectedFiles,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,217 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
export type LanguageEntry = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileBackedI18nData = {
|
||||||
|
languages: LanguageEntry[];
|
||||||
|
translations: Record<string, Record<string, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseLanguageManagementTranslationsOptions = {
|
||||||
|
coreLanguages: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useLanguageManagementTranslations({ coreLanguages }: UseLanguageManagementTranslationsOptions) {
|
||||||
|
const [data, setData] = useState<FileBackedI18nData>({ languages: [], translations: {} });
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState('');
|
||||||
|
|
||||||
|
const [activeLang, setActiveLang] = useState('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 fetchTranslationFiles = useCallback(async () => {
|
||||||
|
const response = await fetch('/api/i18n/translations', { cache: 'no-store' });
|
||||||
|
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>>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
setData({ languages, translations });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchTranslationFiles();
|
||||||
|
}, [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]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
translations: { ...prev.translations, [activeLang]: langTranslations },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setIsDirty(true);
|
||||||
|
setSaved(false);
|
||||||
|
}, [activeLang]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSaveError('');
|
||||||
|
const response = await fetch('/api/i18n/translations', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ translations: data.translations }),
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
setTimeout(() => setSaved(false), 2500);
|
||||||
|
} catch (error) {
|
||||||
|
setSaveError(error instanceof Error ? error.message : 'Failed to save translation files.');
|
||||||
|
setSaved(false);
|
||||||
|
}
|
||||||
|
}, [data.translations]);
|
||||||
|
|
||||||
|
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 fetch('/api/i18n/translations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, name }),
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
} catch (error) {
|
||||||
|
setAddError(error instanceof Error ? error.message : 'Failed to create language file.');
|
||||||
|
}
|
||||||
|
}, [allLanguages, newCode, newName]);
|
||||||
|
|
||||||
|
const handleDeleteLanguage = useCallback(async (code: string) => {
|
||||||
|
if (coreLanguages.has(code)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/i18n/translations', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
} catch (error) {
|
||||||
|
setAddError(error instanceof Error ? error.message : 'Failed to delete language file.');
|
||||||
|
}
|
||||||
|
}, [activeLang, coreLanguages]);
|
||||||
|
|
||||||
|
const isBuiltin = useCallback((code: string) => coreLanguages.has(code), [coreLanguages]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
allLanguages,
|
||||||
|
activeLang,
|
||||||
|
setActiveLang,
|
||||||
|
getDisplayValue,
|
||||||
|
isDirty,
|
||||||
|
saved,
|
||||||
|
saveError,
|
||||||
|
handleChange,
|
||||||
|
handleSave,
|
||||||
|
showAddModal,
|
||||||
|
setShowAddModal,
|
||||||
|
newCode,
|
||||||
|
setNewCode,
|
||||||
|
newName,
|
||||||
|
setNewName,
|
||||||
|
addError,
|
||||||
|
setAddError,
|
||||||
|
handleAddLanguage,
|
||||||
|
deleteTarget,
|
||||||
|
setDeleteTarget,
|
||||||
|
handleDeleteLanguage,
|
||||||
|
isBuiltin,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
const CATEGORY_LAYOUT_STORAGE_KEY = 'pp_i18n_category_layout_v1';
|
||||||
|
const GLOBAL_KEYS_STORAGE_KEY = 'pp_i18n_global_keys_v1';
|
||||||
|
|
||||||
|
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[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useNamespaceCategories({ namespaces, allKeys, defaultCategories }: UseNamespaceCategoriesOptions) {
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||||
|
const [showCategoryManagerModal, setShowCategoryManagerModal] = useState(false);
|
||||||
|
const [categories, setCategories] = useState<NamespaceCategory[]>(() => createDefaultCategories(defaultCategories));
|
||||||
|
const [categoriesHydrated, setCategoriesHydrated] = useState(false);
|
||||||
|
|
||||||
|
const [globalKeys, setGlobalKeys] = useState<string[]>([]);
|
||||||
|
const [globalKeysHydrated, setGlobalKeysHydrated] = 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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CATEGORY_LAYOUT_STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as Array<{ id: string; label: string; namespaces: string[]; isCustom: boolean }>;
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
const normalized = parsed
|
||||||
|
.filter((c) => c && typeof c.id === 'string' && typeof c.label === 'string')
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
namespaces: Array.isArray(c.namespaces) ? c.namespaces : [],
|
||||||
|
isCustom: Boolean(c.isCustom),
|
||||||
|
}));
|
||||||
|
if (normalized.length > 0) setCategories(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawGlobalKeys = localStorage.getItem(GLOBAL_KEYS_STORAGE_KEY);
|
||||||
|
if (rawGlobalKeys) {
|
||||||
|
const parsedGlobalKeys = JSON.parse(rawGlobalKeys) as unknown;
|
||||||
|
if (Array.isArray(parsedGlobalKeys)) {
|
||||||
|
const normalizedGlobalKeys = parsedGlobalKeys.filter((k): k is string => typeof k === 'string');
|
||||||
|
setGlobalKeys(normalizedGlobalKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore corrupted local storage.
|
||||||
|
} finally {
|
||||||
|
setCategoriesHydrated(true);
|
||||||
|
setGlobalKeysHydrated(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCategoriesHydrated(true);
|
||||||
|
setGlobalKeysHydrated(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!categoriesHydrated) return;
|
||||||
|
localStorage.setItem(CATEGORY_LAYOUT_STORAGE_KEY, JSON.stringify(categories));
|
||||||
|
}, [categories, categoriesHydrated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!globalKeysHydrated) return;
|
||||||
|
localStorage.setItem(GLOBAL_KEYS_STORAGE_KEY, JSON.stringify(globalKeys));
|
||||||
|
}, [globalKeys, globalKeysHydrated]);
|
||||||
|
|
||||||
|
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) => (prev.includes(key) ? prev : [...prev, key]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGlobalKey = (key: string) => {
|
||||||
|
setGlobalKeys((prev) => prev.filter((k) => k !== key));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCategory,
|
||||||
|
setActiveCategory,
|
||||||
|
showCategoryManagerModal,
|
||||||
|
setShowCategoryManagerModal,
|
||||||
|
categoriesWithKnownNamespaces,
|
||||||
|
uncategorizedNamespaces,
|
||||||
|
addNamespaceToCategory,
|
||||||
|
removeNamespaceFromCategory,
|
||||||
|
newCategoryLabel,
|
||||||
|
setNewCategoryLabel,
|
||||||
|
assignNamespaceByCategory,
|
||||||
|
setAssignNamespaceByCategory,
|
||||||
|
expandedCategoryId,
|
||||||
|
setExpandedCategoryId,
|
||||||
|
dragNamespace,
|
||||||
|
setDragNamespace,
|
||||||
|
handleCreateCategory,
|
||||||
|
deleteCategory,
|
||||||
|
globalKnownKeys,
|
||||||
|
globalKeySet,
|
||||||
|
addGlobalKey,
|
||||||
|
removeGlobalKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||||
@ -333,8 +335,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 +347,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 +364,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 +422,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 +431,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 +472,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>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import React, { useEffect, useMemo, useState, Suspense } from 'react' // CHANGED: add Suspense
|
import React, { useEffect, useMemo, useState, Suspense } from 'react' // CHANGED: add Suspense
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import PageLayout from '../../../components/PageLayout'
|
import PageLayout from '../../../components/PageLayout'
|
||||||
@ -13,6 +16,7 @@ const DEFAULT_FETCH_DEPTH = 50 // provisional large depth to approximate unlimit
|
|||||||
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
|
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
|
||||||
|
|
||||||
function MatrixDetailPageInner() {
|
function MatrixDetailPageInner() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const sp = useSearchParams()
|
const sp = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -378,7 +382,7 @@ function MatrixDetailPageInner() {
|
|||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/50 backdrop-blur-sm transition-opacity">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/50 backdrop-blur-sm transition-opacity">
|
||||||
<div className="flex items-center gap-3 rounded-lg bg-white shadow-md border border-gray-200 px-4 py-3">
|
<div className="flex items-center gap-3 rounded-lg bg-white shadow-md border border-gray-200 px-4 py-3">
|
||||||
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
||||||
<span className="text-sm text-gray-700">Refreshing…</span>
|
<span className="text-sm text-gray-700">{t('autofix.k14a4b43e')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -393,12 +397,9 @@ function MatrixDetailPageInner() {
|
|||||||
onClick={() => router.push('/admin/matrix-management')}
|
onClick={() => router.push('/admin/matrix-management')}
|
||||||
className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700"
|
className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ArrowLeftIcon className="h-4 w-4" />{t('autofix.k65b67dc3')}</button>
|
||||||
Back to matrices
|
|
||||||
</button>
|
|
||||||
<h1 className="text-3xl font-extrabold text-blue-900">{matrixName}</h1>
|
<h1 className="text-3xl font-extrabold text-blue-900">{matrixName}</h1>
|
||||||
<p className="text-base text-blue-700">
|
<p className="text-base text-blue-700">{t('autofix.k31d46514')}<span className="font-semibold text-blue-900">{topNodeEmail}</span>
|
||||||
Top node: <span className="font-semibold text-blue-900">{topNodeEmail}</span>
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||||||
@ -431,9 +432,7 @@ function MatrixDetailPageInner() {
|
|||||||
onClick={() => { setOpen(true) }}
|
onClick={() => { setOpen(true) }}
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-5 w-5" />
|
<PlusIcon className="h-5 w-5" />{t('autofix.kc7c429a6')}</button>
|
||||||
Add users to matrix
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -452,7 +451,7 @@ function MatrixDetailPageInner() {
|
|||||||
<input
|
<input
|
||||||
value={globalSearch}
|
value={globalSearch}
|
||||||
onChange={e => setGlobalSearch(e.target.value)}
|
onChange={e => setGlobalSearch(e.target.value)}
|
||||||
placeholder="Global search..."
|
placeholder={t('autofix.kd304af2e')}
|
||||||
className="pl-8 pr-2 py-2 rounded-lg border border-gray-200 text-xs focus:ring-1 focus:ring-blue-900 focus:border-transparent w-full"
|
className="pl-8 pr-2 py-2 rounded-lg border border-gray-200 text-xs focus:ring-1 focus:ring-blue-900 focus:border-transparent w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -470,27 +469,27 @@ function MatrixDetailPageInner() {
|
|||||||
{/* Small stats (CHANGED wording) */}
|
{/* Small stats (CHANGED wording) */}
|
||||||
<div className="mb-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
|
<div className="mb-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Total users fetched</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.k65e33378')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{users.length}</div>
|
<div className="text-xl font-semibold text-blue-900">{users.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Rogue users</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.kb343460d')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{rogueCount}</div>
|
<div className="text-xl font-semibold text-blue-900">{rogueCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Structure</div>
|
<div className="text-xs text-gray-500 mb-1">Structure</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">5‑ary Tree</div>
|
<div className="text-xl font-semibold text-blue-900">{t('autofix.kf3557acd')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Policy Max Depth</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.k776b751c')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{isUnlimited ? 'Unlimited' : policyMaxDepth}</div>
|
<div className="text-xl font-semibold text-blue-900">{isUnlimited ? 'Unlimited' : policyMaxDepth}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Fill %</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.k9683262f')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{fillMetrics.label}</div>
|
<div className="text-xl font-semibold text-blue-900">{fillMetrics.label}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
<div className="text-xs text-gray-500 mb-1">Highest full level</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.k7f9568ec')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{fillMetrics.highestFull}</div>
|
<div className="text-xl font-semibold text-blue-900">{fillMetrics.highestFull}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -499,11 +498,11 @@ function MatrixDetailPageInner() {
|
|||||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
||||||
<div className="px-8 py-6 border-b border-gray-100">
|
<div className="px-8 py-6 border-b border-gray-100">
|
||||||
<h2 className="text-xl font-semibold text-blue-900">Matrix Tree (Unlimited Depth)</h2>
|
<h2 className="text-xl font-semibold text-blue-900">Matrix Tree (Unlimited Depth)</h2>
|
||||||
<p className="text-xs text-blue-700">Each node can hold up to 5 direct children. Depth unbounded.</p>
|
<p className="text-xs text-blue-700">{t('autofix.kab4f5159')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-8 py-6">
|
<div className="px-8 py-6">
|
||||||
{!rootNode && (
|
{!rootNode && (
|
||||||
<div className="text-xs text-gray-500 italic">Root not yet loaded.</div>
|
<div className="text-xs text-gray-500 italic">{t('autofix.k4e61bc77')}</div>
|
||||||
)}
|
)}
|
||||||
{rootNode && (
|
{rootNode && (
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
@ -516,9 +515,7 @@ function MatrixDetailPageInner() {
|
|||||||
{/* Vacancies placeholder */}
|
{/* Vacancies placeholder */}
|
||||||
<div className="rounded-2xl bg-white border border-dashed border-blue-200 shadow-sm p-6 mb-8">
|
<div className="rounded-2xl bg-white border border-dashed border-blue-200 shadow-sm p-6 mb-8">
|
||||||
<h3 className="text-lg font-semibold text-blue-900 mb-2">Vacancies</h3>
|
<h3 className="text-lg font-semibold text-blue-900 mb-2">Vacancies</h3>
|
||||||
<p className="text-sm text-blue-700">
|
<p className="text-sm text-blue-700">{t('autofix.k9b3266b5')}</p>
|
||||||
Pending backend wiring to MatrixController.listVacancies. This section will surface empty slots and allow reassignment.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Users Modal */}
|
{/* Add Users Modal */}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React, { useMemo, useState, useEffect } from 'react'
|
import React, { useMemo, useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
@ -28,6 +31,7 @@ type Matrix = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MatrixManagementPage() {
|
export default function MatrixManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const token = useAuthStore(s => s.accessToken)
|
const token = useAuthStore(s => s.accessToken)
|
||||||
@ -289,37 +293,35 @@ export default function MatrixManagementPage() {
|
|||||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Matrix Management</h1>
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.kd09be3cd')}</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">Manage matrices, see stats, and create new ones.</p>
|
<p className="text-lg text-blue-700 mt-2">{t('autofix.kdc22ad8a')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-5 w-5" />
|
<PlusIcon className="h-5 w-5" />{t('autofix.kb7849a5a')}</button>
|
||||||
Create Matrix
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||||
<div className="flex items-center gap-2 text-xs text-blue-900">
|
<div className="flex items-center gap-2 text-xs text-blue-900">
|
||||||
<span className="font-semibold">Policy filter:</span>
|
<span className="font-semibold">{t('autofix.ka72e833f')}</span>
|
||||||
<button onClick={() => setPolicyFilter('all')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='all'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>All</button>
|
<button onClick={() => setPolicyFilter('all')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='all'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>All</button>
|
||||||
<button onClick={() => setPolicyFilter('unlimited')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='unlimited'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Unlimited</button>
|
<button onClick={() => setPolicyFilter('unlimited')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='unlimited'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Unlimited</button>
|
||||||
<button onClick={() => setPolicyFilter('five')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='five'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Depth 5</button>
|
<button onClick={() => setPolicyFilter('five')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='five'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>{t('autofix.kefd5231d')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-blue-900">
|
<div className="flex items-center gap-2 text-xs text-blue-900">
|
||||||
<span className="font-semibold">Sort:</span>
|
<span className="font-semibold">{t('autofix.k0dca1445')}</span>
|
||||||
<select value={sortByPolicy} onChange={e => setSortByPolicy(e.target.value as any)} className="border rounded px-2 py-1">
|
<select value={sortByPolicy} onChange={e => setSortByPolicy(e.target.value as any)} className="border rounded px-2 py-1">
|
||||||
<option value="none">None</option>
|
<option value="none">None</option>
|
||||||
<option value="asc">Policy ↑</option>
|
<option value="asc">{t('autofix.kf7a91674')}</option>
|
||||||
<option value="desc">Policy ↓</option>
|
<option value="desc">{t('autofix.kf7a91676')}</option>
|
||||||
</select>
|
</select>
|
||||||
<select value={sortByUsers} onChange={e => setSortByUsers(e.target.value as any)} className="border rounded px-2 py-1">
|
<select value={sortByUsers} onChange={e => setSortByUsers(e.target.value as any)} className="border rounded px-2 py-1">
|
||||||
<option value="desc">Users ↓</option>
|
<option value="desc">{t('autofix.k8c3085f4')}</option>
|
||||||
<option value="asc">Users ↑</option>
|
<option value="asc">{t('autofix.k8c3085f6')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -352,7 +354,7 @@ export default function MatrixManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : matricesView.length === 0 ? (
|
) : matricesView.length === 0 ? (
|
||||||
<div className="text-sm text-gray-600">No matrices found.</div>
|
<div className="text-sm text-gray-600">{t('autofix.k0dcb69ea')}</div>
|
||||||
) : (
|
) : (
|
||||||
matricesView.map(m => (
|
matricesView.map(m => (
|
||||||
<article key={m.id} className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden flex flex-col">
|
<article key={m.id} className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden flex flex-col">
|
||||||
@ -370,7 +372,7 @@ export default function MatrixManagementPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 text-sm text-gray-700">
|
<div className="mt-4 grid grid-cols-1 gap-3 text-sm text-gray-700">
|
||||||
<div className="flex items-center gap-2" title="Users count respects each matrix’s max depth policy.">
|
<div className="flex items-center gap-2" title={t('autofix.k111c49d8')}>
|
||||||
<UsersIcon className="h-5 w-5 text-gray-500" />
|
<UsersIcon className="h-5 w-5 text-gray-500" />
|
||||||
<span className="font-medium">{m.usersCount}</span>
|
<span className="font-medium">{m.usersCount}</span>
|
||||||
<span className="text-gray-500">users</span>
|
<span className="text-gray-500">users</span>
|
||||||
@ -399,9 +401,7 @@ export default function MatrixManagementPage() {
|
|||||||
? (m.status === 'active' ? 'Deactivating…' : 'Activating…')
|
? (m.status === 'active' ? 'Deactivating…' : 'Activating…')
|
||||||
: (m.status === 'active' ? 'Deactivate' : 'Activate')}
|
: (m.status === 'active' ? 'Deactivate' : 'Activate')}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-[11px] text-gray-500">
|
<span className="text-[11px] text-gray-500">{t('autofix.k27f56959')}</span>
|
||||||
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 +417,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 +433,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 +444,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 +476,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 +507,7 @@ export default function MatrixManagementPage() {
|
|||||||
onChange={e => setCreateDepth(Number(e.target.value))}
|
onChange={e => setCreateDepth(Number(e.target.value))}
|
||||||
disabled={createLoading}
|
disabled={createLoading}
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
|
||||||
placeholder="e.g., 5"
|
placeholder={t('autofix.k8f46c81e')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Header from '../../components/nav/Header'
|
import Header from '../../components/nav/Header'
|
||||||
import Footer from '../../components/Footer'
|
import Footer from '../../components/Footer'
|
||||||
@ -12,6 +15,7 @@ import { updateNews } from './hooks/updateNews'
|
|||||||
import { deleteNews } from './hooks/deleteNews'
|
import { deleteNews } from './hooks/deleteNews'
|
||||||
|
|
||||||
export default function NewsManagementPage() {
|
export default function NewsManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { items, loading, error, refresh } = useAdminNews()
|
const { items, loading, error, refresh } = useAdminNews()
|
||||||
const [showCreate, setShowCreate] = React.useState(false)
|
const [showCreate, setShowCreate] = React.useState(false)
|
||||||
const [selected, setSelected] = React.useState<any | null>(null)
|
const [selected, setSelected] = React.useState<any | null>(null)
|
||||||
@ -25,10 +29,9 @@ export default function NewsManagementPage() {
|
|||||||
<main className="bg-white min-h-screen pb-20">
|
<main className="bg-white min-h-screen pb-20">
|
||||||
<div className="mx-auto max-w-7xl px-6 pt-8 pb-12">
|
<div className="mx-auto max-w-7xl px-6 pt-8 pb-12">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-blue-900">News Manager</h1>
|
<h1 className="text-2xl font-bold text-blue-900">{t('autofix.k471ba099')}</h1>
|
||||||
<button onClick={() => setShowCreate(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-900 text-white rounded-lg hover:bg-blue-800">
|
<button onClick={() => setShowCreate(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-900 text-white rounded-lg hover:bg-blue-800">
|
||||||
<PlusIcon className="h-5 w-5" /> Add News
|
<PlusIcon className="h-5 w-5" />{t('autofix.k75078d0b')}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="mt-4 text-red-600">{error}</div>}
|
{error && <div className="mt-4 text-red-600">{error}</div>}
|
||||||
@ -101,7 +104,7 @@ export default function NewsManagementPage() {
|
|||||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
|
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
|
||||||
<div className="px-6 pt-6">
|
<div className="px-6 pt-6">
|
||||||
<h3 className="text-lg font-semibold text-blue-900">Delete news?</h3>
|
<h3 className="text-lg font-semibold text-blue-900">{t('autofix.k088d8f6c')}</h3>
|
||||||
<p className="mt-2 text-sm text-gray-700">You are about to delete "{deleteTarget.title}". This action cannot be undone.</p>
|
<p className="mt-2 text-sm text-gray-700">You are about to delete "{deleteTarget.title}". This action cannot be undone.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
||||||
@ -199,7 +202,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 +220,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 +231,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">
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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,7 +56,7 @@ 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"
|
||||||
@ -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}
|
||||||
@ -103,7 +107,7 @@ export default function CreateNewPoolModal({
|
|||||||
<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}
|
||||||
@ -122,10 +126,10 @@ 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}
|
||||||
@ -137,7 +141,7 @@ export default function CreateNewPoolModal({
|
|||||||
</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}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import React, { Suspense } from 'react' // CHANGED: add Suspense
|
import React, { Suspense } from 'react' // CHANGED: add Suspense
|
||||||
import Header from '../../../components/nav/Header'
|
import Header from '../../../components/nav/Header'
|
||||||
import Footer from '../../../components/Footer'
|
import Footer from '../../../components/Footer'
|
||||||
@ -19,6 +22,7 @@ type PoolUser = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PoolManagePageInner() {
|
function PoolManagePageInner() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
@ -281,9 +285,7 @@ function PoolManagePageInner() {
|
|||||||
}`}>
|
}`}>
|
||||||
{isCore && (
|
{isCore && (
|
||||||
<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">
|
<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">
|
||||||
<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>
|
<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>
|
||||||
Core Pool — 1¢ per capsule per member
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -315,10 +317,8 @@ function PoolManagePageInner() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => router.push('/admin/pool-management')}
|
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"
|
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"
|
title={t('autofix.k6285753a')}
|
||||||
>
|
>{t('autofix.k0ac84efe')}</button>
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -330,7 +330,7 @@ function PoolManagePageInner() {
|
|||||||
<BanknotesIcon className="h-5 w-5 text-white" />
|
<BanknotesIcon className="h-5 w-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Total in Pool</p>
|
<p className="text-sm text-gray-600">{t('autofix.ke8b9f33c')}</p>
|
||||||
<p className="text-2xl font-semibold text-gray-900">€ {totalAmount.toLocaleString()}</p>
|
<p className="text-2xl font-semibold text-gray-900">€ {totalAmount.toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -341,7 +341,7 @@ function PoolManagePageInner() {
|
|||||||
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">This Year</p>
|
<p className="text-sm text-gray-600">{t('autofix.kaa8231ec')}</p>
|
||||||
<p className="text-2xl font-semibold text-gray-900">€ {amountThisYear.toLocaleString()}</p>
|
<p className="text-2xl font-semibold text-gray-900">€ {amountThisYear.toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -352,7 +352,7 @@ function PoolManagePageInner() {
|
|||||||
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Current Month</p>
|
<p className="text-sm text-gray-600">{t('autofix.k86aa4f9c')}</p>
|
||||||
<p className="text-2xl font-semibold text-gray-900">€ {amountThisMonth.toLocaleString()}</p>
|
<p className="text-2xl font-semibold text-gray-900">€ {amountThisMonth.toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -372,9 +372,7 @@ function PoolManagePageInner() {
|
|||||||
onClick={() => { setSearchOpen(true); setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
|
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"
|
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" />
|
<PlusIcon className="h-5 w-5" />{t('autofix.k750c1eb5')}</button>
|
||||||
Add User
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{removeError && (
|
{removeError && (
|
||||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
@ -383,13 +381,13 @@ function PoolManagePageInner() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{membersLoading && (
|
{membersLoading && (
|
||||||
<div className="text-center text-gray-500 italic py-8">Loading members...</div>
|
<div className="text-center text-gray-500 italic py-8">{t('autofix.k5d4d494e')}</div>
|
||||||
)}
|
)}
|
||||||
{membersError && !membersLoading && (
|
{membersError && !membersLoading && (
|
||||||
<div className="text-center text-red-600 py-8">{membersError}</div>
|
<div className="text-center text-red-600 py-8">{membersError}</div>
|
||||||
)}
|
)}
|
||||||
{users.length === 0 && !membersLoading && !membersError && (
|
{users.length === 0 && !membersLoading && !membersError && (
|
||||||
<div className="text-center text-gray-500 italic py-8">No users in this pool yet.</div>
|
<div className="text-center text-gray-500 italic py-8">{t('autofix.kcbc17bbd')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{users.length > 0 && !membersLoading && (
|
{users.length > 0 && !membersLoading && (
|
||||||
@ -399,7 +397,7 @@ function PoolManagePageInner() {
|
|||||||
<tr>
|
<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">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">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-left font-semibold text-gray-700">{t('autofix.k7bed84a7')}</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">{isCore ? 'Total Earned' : 'Share'}</th>
|
||||||
<th className="px-4 py-3 text-right font-semibold text-gray-700" />
|
<th className="px-4 py-3 text-right font-semibold text-gray-700" />
|
||||||
</tr>
|
</tr>
|
||||||
@ -456,7 +454,7 @@ function PoolManagePageInner() {
|
|||||||
<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">
|
<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">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<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">Add user to pool</h4>
|
<h4 className="text-lg font-semibold text-blue-900">{t('autofix.ka6be28d2')}</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(false)}
|
onClick={() => setSearchOpen(false)}
|
||||||
className="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition"
|
className="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition"
|
||||||
@ -477,7 +475,7 @@ function PoolManagePageInner() {
|
|||||||
<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-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"
|
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>
|
||||||
@ -499,17 +497,13 @@ function PoolManagePageInner() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div className="px-6 pt-1 pb-3 text-right text-xs text-gray-500">
|
<div className="px-6 pt-1 pb-3 text-right text-xs text-gray-500">{t('autofix.ke4c4a858')}</div>
|
||||||
Min. 3 characters
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
<div className="px-6 py-4 overflow-y-auto min-h-0 flex-1">
|
<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 && <div className="text-sm text-red-600 mb-3">{error}</div>}
|
||||||
{!error && query.trim().length < 3 && (
|
{!error && query.trim().length < 3 && (
|
||||||
<div className="py-8 text-sm text-gray-500 text-center">
|
<div className="py-8 text-sm text-gray-500 text-center">{t('autofix.kb87eb38b')}</div>
|
||||||
Enter at least 3 characters and click Search.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!error && hasSearched && loading && candidates.length === 0 && (
|
{!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">
|
<ul className="space-y-0 divide-y divide-gray-200 border border-gray-200 rounded-md bg-gray-50">
|
||||||
@ -522,9 +516,7 @@ function PoolManagePageInner() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{!error && hasSearched && !loading && candidates.length === 0 && (
|
{!error && hasSearched && !loading && candidates.length === 0 && (
|
||||||
<div className="py-8 text-sm text-gray-500 text-center">
|
<div className="py-8 text-sm text-gray-500 text-center">{t('autofix.k54f49724')}</div>
|
||||||
No users match your search.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!error && candidates.length > 0 && (
|
{!error && candidates.length > 0 && (
|
||||||
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
|
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
|
||||||
@ -594,7 +586,7 @@ function PoolManagePageInner() {
|
|||||||
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={`This will remove ${removeConfirm?.label || 'this user'} from the pool.`}
|
||||||
confirmText="Remove"
|
confirmText="Remove"
|
||||||
onClose={() => { if (!removingMemberId) setRemoveConfirm(null) }}
|
onClose={() => { if (!removingMemberId) setRemoveConfirm(null) }}
|
||||||
|
|||||||
@ -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'
|
||||||
@ -23,6 +26,7 @@ type Pool = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PoolManagementPage() {
|
export default function PoolManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const [archiveError, setArchiveError] = React.useState<string>('')
|
const [archiveError, setArchiveError] = React.useState<string>('')
|
||||||
@ -107,31 +111,27 @@ export default function PoolManagementPage() {
|
|||||||
<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">
|
<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">
|
||||||
<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">Pool Management</h1>
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.k21440f8a')}</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">Manage system pools and members.</p>
|
<p className="text-lg text-blue-700 mt-2">{t('autofix.k67391c88')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-gray-600">Show:</span>
|
<span className="text-sm text-gray-600">{t('autofix.k0dd01c1c')}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInactive(false)}
|
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'}`}
|
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'}`}
|
||||||
>
|
>{t('autofix.k15843a06')}</button>
|
||||||
Active Pools
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInactive(true)}
|
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'}`}
|
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'}`}
|
||||||
>
|
>{t('autofix.kb5e0b861')}</button>
|
||||||
Inactive Pools
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Pools List card */}
|
{/* Pools List card */}
|
||||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
|
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
|
||||||
<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">Existing Pools</h2>
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k5857ef79')}</h2>
|
||||||
<span className="text-sm text-gray-600">{pools.length} total</span>
|
<span className="text-sm text-gray-600">{pools.length} total</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -172,9 +172,7 @@ export default function PoolManagementPage() {
|
|||||||
}`}>
|
}`}>
|
||||||
{isCore && (
|
{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">
|
<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>
|
<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>
|
||||||
Core Pool
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -225,15 +223,13 @@ export default function PoolManagementPage() {
|
|||||||
<button
|
<button
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-green-100 text-green-800 hover:bg-green-200 transition"
|
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)}
|
onClick={() => handleSetActive(pool.id)}
|
||||||
title="Activate this pool"
|
title={t('autofix.kd40c4f86')}
|
||||||
>
|
>{t('autofix.ke697b8cb')}</button>
|
||||||
Set Active
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-amber-100 text-amber-800 hover:bg-amber-200 transition"
|
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)}
|
onClick={() => handleArchive(pool.id)}
|
||||||
title="Archive this pool"
|
title={t('autofix.ke19afb3d')}
|
||||||
>
|
>
|
||||||
Archive
|
Archive
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import Cropper from 'react-easy-crop'
|
import Cropper from 'react-easy-crop'
|
||||||
import { Point, Area } from 'react-easy-crop'
|
import { Point, Area } from 'react-easy-crop'
|
||||||
@ -11,6 +14,7 @@ interface ImageCropModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageCropModal({ isOpen, imageSrc, onClose, onCropComplete }: ImageCropModalProps) {
|
export default function ImageCropModal({ isOpen, imageSrc, onClose, onCropComplete }: ImageCropModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
||||||
@ -70,7 +74,7 @@ export default function ImageCropModal({ isOpen, imageSrc, onClose, onCropComple
|
|||||||
<div className="relative w-full max-w-4xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
|
<div className="relative w-full max-w-4xl mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-white">
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-blue-50 to-white">
|
||||||
<h2 className="text-xl font-semibold text-blue-900">Crop & Adjust Image</h2>
|
<h2 className="text-xl font-semibold text-blue-900">{t('autofix.k8f528877')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-500 hover:text-gray-700 transition"
|
className="text-gray-500 hover:text-gray-700 transition"
|
||||||
@ -120,9 +124,7 @@ export default function ImageCropModal({ isOpen, imageSrc, onClose, onCropComple
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
className="px-5 py-2.5 text-sm font-semibold text-white bg-blue-900 rounded-lg hover:bg-blue-800 shadow transition"
|
||||||
>
|
>{t('autofix.kef1656df')}</button>
|
||||||
Apply Crop
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import ImageCropModal from '../components/ImageCropModal';
|
import ImageCropModal from '../components/ImageCropModal';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
|
|
||||||
export default function CreateSubscriptionPage() {
|
export default function CreateSubscriptionPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { createProduct } = useCoffeeManagement();
|
const { createProduct } = useCoffeeManagement();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -92,15 +95,13 @@ export default function CreateSubscriptionPage() {
|
|||||||
<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">Create Coffee</h1>
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.kaa30f0cd')}</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">Add a new coffee.</p>
|
<p className="text-lg text-blue-700 mt-2">{t('autofix.kf72d41db')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/subscriptions"
|
<Link href="/admin/subscriptions"
|
||||||
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"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>{t('autofix.kd8a5ad17')}</Link>
|
||||||
Back to list
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -124,10 +125,10 @@ export default function CreateSubscriptionPage() {
|
|||||||
<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-blue-400" />
|
||||||
<div className="mt-4 text-base font-medium text-blue-700">
|
<div className="mt-4 text-base font-medium text-blue-700">
|
||||||
<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-blue-600 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-gray-500 mt-2">{t('autofix.k41ab9eb6')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
@ -145,9 +146,7 @@ export default function CreateSubscriptionPage() {
|
|||||||
setShowCropModal(true);
|
setShowCropModal(true);
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
>
|
>{t('autofix.k73d1d7d7')}</button>
|
||||||
Edit Crop
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
@ -196,11 +195,11 @@ export default function CreateSubscriptionPage() {
|
|||||||
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="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"
|
||||||
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-gray-600">{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">
|
||||||
@ -242,7 +241,7 @@ export default function CreateSubscriptionPage() {
|
|||||||
{/* Subscription Billing (Locked) + Availability */}
|
{/* Subscription Billing (Locked) + 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-medium text-blue-900">{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="mt-1 text-xs text-gray-600">Fixed monthly subscription billing (interval count = 1). These settings are locked.</p>
|
||||||
<div className="mt-2 flex gap-4">
|
<div className="mt-2 flex gap-4">
|
||||||
<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-lg border-gray-300 bg-gray-100 px-4 py-3 text-sm text-gray-600" />
|
||||||
@ -264,9 +263,7 @@ export default function CreateSubscriptionPage() {
|
|||||||
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700">
|
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700">
|
||||||
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" 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">{t('autofix.kaa30f0cd')}</button>
|
||||||
Create Coffee
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|||||||
@ -6,7 +6,10 @@ 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 { 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
|
// next/navigation app router dynamic param
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -125,20 +128,18 @@ export default function EditSubscriptionPage() {
|
|||||||
<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">Edit Coffee</h1>
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.kb06fa395')}</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">Update details of the coffee.</p>
|
<p className="text-lg text-blue-700 mt-2">{t('autofix.kb9e483c4')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/subscriptions"
|
<Link href="/admin/subscriptions"
|
||||||
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"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"/></svg>{t('autofix.kd8a5ad17')}</Link>
|
||||||
Back to list
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="rounded-md bg-blue-50 p-4 text-blue-700 text-sm mb-6">Loading subscription…</div>
|
<div className="rounded-md bg-blue-50 p-4 text-blue-700 text-sm mb-6">{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-md bg-red-50 p-4 text-red-700 text-sm mb-6">{error}</div>
|
||||||
@ -220,9 +221,9 @@ export default function EditSubscriptionPage() {
|
|||||||
<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-blue-400" />
|
||||||
<div className="mt-4 text-base font-medium text-blue-700">
|
<div className="mt-4 text-base font-medium text-blue-700">
|
||||||
<span>Click or drag and drop a new image here</span>
|
<span>{t('autofix.k2e43a9c4')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-blue-600 mt-2">PNG, JPG, WebP up to 10MB</p>
|
<p className="text-sm text-blue-600 mt-2">{t('autofix.k80ac9651')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(previewUrl || (!removeExistingPicture && item.pictureUrl)) && (
|
{(previewUrl || (!removeExistingPicture && item.pictureUrl)) && (
|
||||||
@ -255,9 +256,9 @@ export default function EditSubscriptionPage() {
|
|||||||
<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-gray-400" />
|
<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">
|
<div className="mt-4 text-base font-medium text-gray-600">
|
||||||
<span>Image removed - Click to upload a new one</span>
|
<span>{t('autofix.kd2a00802')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 mt-2">PNG, JPG, WebP up to 10MB</p>
|
<p className="text-sm text-gray-500 mt-2">{t('autofix.k80ac9651')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
@ -274,9 +275,7 @@ export default function EditSubscriptionPage() {
|
|||||||
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700">
|
<Link href="/admin/subscriptions" className="text-sm font-medium text-blue-900 hover:text-blue-700">
|
||||||
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" 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">{t('autofix.k5a489751')}</button>
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
@ -142,15 +145,13 @@ export default function AdminSubscriptionsPage() {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center sm: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>
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Coffees</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">Manage all coffees.</p>
|
<p className="text-lg text-blue-700 mt-2">{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-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"
|
||||||
>
|
>
|
||||||
<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-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>{t('autofix.kaa30f0cd')}</Link>
|
||||||
Create Coffee
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -163,7 +164,7 @@ export default function AdminSubscriptionsPage() {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-blue-900">Shipping Fees (ABO)</h2>
|
<h2 className="text-xl font-semibold text-blue-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-gray-600">{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-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"
|
||||||
@ -249,7 +250,7 @@ export default function AdminSubscriptionsPage() {
|
|||||||
|
|
||||||
<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 gap-8">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="col-span-full text-sm text-gray-700">Loading…</div>
|
<div className="col-span-full text-sm text-gray-700">{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-2xl border border-gray-100 bg-white shadow-lg p-6 flex flex-col gap-3 hover:shadow-xl transition">
|
||||||
@ -304,7 +305,7 @@ 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-8 text-center text-sm text-gray-500">{t('autofix.k8c75468c')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Confirm Delete Modal */}
|
{/* Confirm Delete Modal */}
|
||||||
@ -314,7 +315,7 @@ export default function AdminSubscriptionsPage() {
|
|||||||
<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 coffee?</h3>
|
<h3 className="text-lg font-semibold text-blue-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-gray-700">You are about to delete the coffee “{deleteTarget.title}”. This action cannot be undone.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||||
import PageLayout from '../../components/PageLayout'
|
import PageLayout from '../../components/PageLayout'
|
||||||
import UserDetailModal from '../../components/UserDetailModal'
|
import UserDetailModal from '../../components/UserDetailModal'
|
||||||
@ -35,6 +38,7 @@ const TYPES: UserType[] = ['personal','company']
|
|||||||
const ROLES: UserRole[] = ['user','admin','guest']
|
const ROLES: UserRole[] = ['user','admin','guest']
|
||||||
|
|
||||||
export default function AdminUserManagementPage() {
|
export default function AdminUserManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useAdminUsers()
|
const { isAdmin } = useAdminUsers()
|
||||||
const token = useAuthStore(state => state.accessToken)
|
const token = useAuthStore(state => state.accessToken)
|
||||||
const [isClient, setIsClient] = useState(false)
|
const [isClient, setIsClient] = useState(false)
|
||||||
@ -151,8 +155,8 @@ export default function AdminUserManagementPage() {
|
|||||||
<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">
|
||||||
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
<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>
|
<h1 className="text-2xl font-bold text-red-600 mb-2">{t('autofix.k26fbc186')}</h1>
|
||||||
<p className="text-gray-600">You need admin privileges to access this page.</p>
|
<p className="text-gray-600">{t('autofix.k661c032b')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -248,10 +252,8 @@ export default function AdminUserManagementPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<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>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">User Management</h1>
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.k1af97a07')}</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">
|
<p className="text-lg text-blue-700 mt-2">{t('autofix.k79e1c459')}</p>
|
||||||
Manage all users, view statistics, and handle verification.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -259,7 +261,7 @@ export default function AdminUserManagementPage() {
|
|||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center gap-6">
|
<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="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="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('autofix.kb324fb25')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">{stats.total}</div>
|
<div className="text-xl font-semibold text-blue-900">{stats.total}</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">
|
||||||
@ -292,9 +294,7 @@ export default function AdminUserManagementPage() {
|
|||||||
type="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"
|
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'}
|
onClick={() => window.location.href = '/admin/user-verify'}
|
||||||
>
|
>{t('autofix.k2f78fabe')}</button>
|
||||||
Go to User Verification
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -303,14 +303,12 @@ export default function AdminUserManagementPage() {
|
|||||||
<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-xl border border-red-300 bg-red-50 text-red-700 px-6 py-5 flex gap-3 items-start mb-8 shadow">
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Error loading users</p>
|
<p className="font-semibold">{t('autofix.kbbefb159')}</p>
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={fetchAllUsers}
|
onClick={fetchAllUsers}
|
||||||
className="mt-2 text-sm underline hover:no-underline"
|
className="mt-2 text-sm underline hover:no-underline"
|
||||||
>
|
>{t('autofix.k3b7dd87a')}</button>
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -320,9 +318,7 @@ export default function AdminUserManagementPage() {
|
|||||||
onSubmit={applyFilter}
|
onSubmit={applyFilter}
|
||||||
className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 px-8 py-8 flex flex-col gap-6 mb-8"
|
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">
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.kd1f35ccf')}</h2>
|
||||||
Search & Filter Users
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
@ -332,7 +328,7 @@ export default function AdminUserManagementPage() {
|
|||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
placeholder="Email, name, company..."
|
placeholder={t('autofix.k8b71f0c7')}
|
||||||
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"
|
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>
|
||||||
@ -344,7 +340,7 @@ export default function AdminUserManagementPage() {
|
|||||||
onChange={e => setFType(e.target.value as any)}
|
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"
|
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="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>
|
||||||
@ -356,7 +352,7 @@ export default function AdminUserManagementPage() {
|
|||||||
onChange={e => setFStatus(e.target.value as any)}
|
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"
|
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>
|
<option value="all">{t('autofix.k2e8f3110')}</option>
|
||||||
{STATUSES.map(s => <option key={s} value={s}>{s[0].toUpperCase()+s.slice(1)}</option>)}
|
{STATUSES.map(s => <option key={s} value={s}>{s[0].toUpperCase()+s.slice(1)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -367,7 +363,7 @@ export default function AdminUserManagementPage() {
|
|||||||
onChange={e => setFRole(e.target.value as any)}
|
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"
|
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="all">{t('autofix.k110bae43')}</option>
|
||||||
{ROLES.map(r => <option key={r} value={r}>{r[0].toUpperCase()+r.slice(1)}</option>)}
|
{ROLES.map(r => <option key={r} value={r}>{r[0].toUpperCase()+r.slice(1)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -377,10 +373,8 @@ export default function AdminUserManagementPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={exportCsv}
|
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"
|
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"
|
title={t('autofix.k1387f81e')}
|
||||||
>
|
>{t('autofix.k1521a376')}</button>
|
||||||
Export all users as CSV
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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"
|
||||||
@ -393,9 +387,7 @@ export default function AdminUserManagementPage() {
|
|||||||
{/* Users Table */}
|
{/* Users Table */}
|
||||||
<div className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 overflow-hidden mb-8">
|
<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="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
|
||||||
<div className="text-lg font-semibold text-blue-900">
|
<div className="text-lg font-semibold text-blue-900">{t('autofix.k10ccb626')}</div>
|
||||||
All Users
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
Showing {current.length} of {filtered.length} users
|
Showing {current.length} of {filtered.length} users
|
||||||
</div>
|
</div>
|
||||||
@ -409,7 +401,7 @@ export default function AdminUserManagementPage() {
|
|||||||
<th className="px-4 py-3 text-left">Status</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">Role</th>
|
||||||
<th className="px-4 py-3 text-left">Created</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">{t('autofix.kb24782ec')}</th>
|
||||||
<th className="px-4 py-3 text-left">Actions</th>
|
<th className="px-4 py-3 text-left">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -419,7 +411,7 @@ export default function AdminUserManagementPage() {
|
|||||||
<td colSpan={7} className="px-4 py-10 text-center">
|
<td colSpan={7} className="px-4 py-10 text-center">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<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" />
|
<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>
|
<span className="text-sm text-blue-900">{t('autofix.k7fa2c4af')}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -478,9 +470,7 @@ export default function AdminUserManagementPage() {
|
|||||||
})}
|
})}
|
||||||
{current.length === 0 && (
|
{current.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">
|
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">{t('autofix.k748bf541')}</td>
|
||||||
No users match current filters.
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -496,16 +486,12 @@ export default function AdminUserManagementPage() {
|
|||||||
disabled={page===1}
|
disabled={page===1}
|
||||||
onClick={() => setPage(p => Math.max(1,p-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"
|
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"
|
||||||
>
|
>{t('autofix.kdb27a82d')}</button>
|
||||||
‹ Previous
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
disabled={page===totalPages}
|
disabled={page===totalPages}
|
||||||
onClick={() => setPage(p => Math.min(totalPages,p+1))}
|
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"
|
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"
|
||||||
>
|
>{t('autofix.ka8ea17b8')}</button>
|
||||||
Next ›
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
import { useMemo, useState, useEffect } from 'react'
|
||||||
import PageLayout from '../../components/PageLayout'
|
import PageLayout from '../../components/PageLayout'
|
||||||
import UserDetailModal from '../../components/UserDetailModal'
|
import UserDetailModal from '../../components/UserDetailModal'
|
||||||
@ -17,6 +20,7 @@ type VerificationReadyFilter = 'all' | 'ready' | 'not_ready'
|
|||||||
type StatusFilter = 'all' | 'pending' | 'active'
|
type StatusFilter = 'all' | 'pending' | 'active'
|
||||||
|
|
||||||
export default function AdminUserVerifyPage() {
|
export default function AdminUserVerifyPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
pendingUsers,
|
pendingUsers,
|
||||||
loading,
|
loading,
|
||||||
@ -138,8 +142,8 @@ export default function AdminUserVerifyPage() {
|
|||||||
<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">
|
||||||
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
<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>
|
<h1 className="text-2xl font-bold text-red-600 mb-2">{t('autofix.k26fbc186')}</h1>
|
||||||
<p className="text-gray-600">You need admin privileges to access this page.</p>
|
<p className="text-gray-600">{t('autofix.k661c032b')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -154,10 +158,8 @@ export default function AdminUserVerifyPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<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>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">User Verification Center</h1>
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.kccde6d86')}</h1>
|
||||||
<p className="text-lg text-blue-700 mt-2">
|
<p className="text-lg text-blue-700 mt-2">{t('autofix.k5614c806')}</p>
|
||||||
Review and verify all users who need admin approval. Users must complete all steps before verification.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -166,14 +168,12 @@ export default function AdminUserVerifyPage() {
|
|||||||
<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-xl border border-red-300 bg-red-50 text-red-700 px-6 py-5 flex gap-3 items-start mb-8 shadow">
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Error loading data</p>
|
<p className="font-semibold">{t('autofix.k62d12fab')}</p>
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={fetchPendingUsers}
|
onClick={fetchPendingUsers}
|
||||||
className="mt-2 text-sm underline hover:no-underline"
|
className="mt-2 text-sm underline hover:no-underline"
|
||||||
>
|
>{t('autofix.k3b7dd87a')}</button>
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -183,9 +183,7 @@ export default function AdminUserVerifyPage() {
|
|||||||
onSubmit={applyFilters}
|
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"
|
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">
|
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k85c66f50')}</h2>
|
||||||
Search & Filter Pending Users
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-7 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-7 gap-4">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Search</label>
|
<label className="block text-xs font-semibold text-blue-900 mb-1">Search</label>
|
||||||
@ -194,19 +192,19 @@ export default function AdminUserVerifyPage() {
|
|||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
placeholder="Email, name, company..."
|
placeholder={t('autofix.k8b71f0c7')}
|
||||||
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"
|
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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">User Type</label>
|
<label className="block text-xs font-semibold text-blue-900 mb-1">{t('autofix.k577a012c')}</label>
|
||||||
<select
|
<select
|
||||||
value={fType}
|
value={fType}
|
||||||
onChange={e => { setFType(e.target.value as any); setPage(1) }}
|
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"
|
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="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>
|
||||||
@ -218,21 +216,21 @@ export default function AdminUserVerifyPage() {
|
|||||||
onChange={e => { setFRole(e.target.value as any); setPage(1) }}
|
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"
|
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="all">{t('autofix.k110bae43')}</option>
|
||||||
<option value="user">User</option>
|
<option value="user">User</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Verification Readiness</label>
|
<label className="block text-xs font-semibold text-blue-900 mb-1">{t('autofix.k0efd830c')}</label>
|
||||||
<select
|
<select
|
||||||
value={fReady}
|
value={fReady}
|
||||||
onChange={e => { setFReady(e.target.value as VerificationReadyFilter); setPage(1) }}
|
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"
|
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="all">{t('autofix.k7ab45054')}</option>
|
||||||
<option value="ready">Ready to Verify</option>
|
<option value="ready">{t('autofix.kf27e4502')}</option>
|
||||||
<option value="not_ready">Not Ready</option>
|
<option value="not_ready">{t('autofix.k4e0c889b')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -242,13 +240,13 @@ export default function AdminUserVerifyPage() {
|
|||||||
onChange={e => { setFStatus(e.target.value as StatusFilter); setPage(1) }}
|
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"
|
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="all">{t('autofix.k0f1fc266')}</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Rows per page</label>
|
<label className="block text-xs font-semibold text-blue-900 mb-1">{t('autofix.kd2e35b08')}</label>
|
||||||
<select
|
<select
|
||||||
value={perPage}
|
value={perPage}
|
||||||
onChange={e => { setPerPage(parseInt(e.target.value, 10)); setPage(1) }}
|
onChange={e => { setPerPage(parseInt(e.target.value, 10)); setPage(1) }}
|
||||||
@ -263,9 +261,7 @@ export default function AdminUserVerifyPage() {
|
|||||||
{/* Pending Users Table */}
|
{/* Pending Users Table */}
|
||||||
<div className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 overflow-hidden mb-8">
|
<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="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
|
||||||
<div className="text-lg font-semibold text-blue-900">
|
<div className="text-lg font-semibold text-blue-900">{t('autofix.k0da2c941')}</div>
|
||||||
Users Pending Verification
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
Showing {current.length} of {filtered.length} users
|
Showing {current.length} of {filtered.length} users
|
||||||
</div>
|
</div>
|
||||||
@ -289,7 +285,7 @@ export default function AdminUserVerifyPage() {
|
|||||||
<td colSpan={7} className="px-4 py-10 text-center">
|
<td colSpan={7} className="px-4 py-10 text-center">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<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" />
|
<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>
|
<span className="text-sm text-blue-900">{t('autofix.k7fa2c4af')}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -343,9 +339,7 @@ export default function AdminUserVerifyPage() {
|
|||||||
})}
|
})}
|
||||||
{current.length === 0 && !loading && (
|
{current.length === 0 && !loading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">
|
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">{t('autofix.kb4aba3dc')}</td>
|
||||||
No unverified users match current filters.
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -361,16 +355,12 @@ export default function AdminUserVerifyPage() {
|
|||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
onClick={() => setPage(p => Math.max(1, p - 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"
|
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"
|
||||||
>
|
>{t('autofix.kdb27a82d')}</button>
|
||||||
‹ Previous
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
disabled={page === totalPages}
|
disabled={page === totalPages}
|
||||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
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"
|
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"
|
||||||
>
|
>{t('autofix.ka8ea17b8')}</button>
|
||||||
Next ›
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -39,8 +39,43 @@ interface ScanResult {
|
|||||||
uniqueKeyCount: number;
|
uniqueKeyCount: number;
|
||||||
missingKeys: Array<{ key: string; files: string[] }>;
|
missingKeys: Array<{ key: string; files: string[] }>;
|
||||||
untranslatedLiterals: Array<{ text: string; files: string[] }>;
|
untranslatedLiterals: Array<{ text: string; files: string[] }>;
|
||||||
|
autoFixEligibleFiles: string[];
|
||||||
|
autoFixForceConvertibleFiles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AutoFixChange {
|
||||||
|
file: string;
|
||||||
|
replacements: number;
|
||||||
|
addedImport: boolean;
|
||||||
|
addedHook: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoFixResult {
|
||||||
|
changedFiles: AutoFixChange[];
|
||||||
|
skippedFiles: Array<{ file: string; reason: string }>;
|
||||||
|
createdKeys: string[];
|
||||||
|
debugEntries: AutoFixDebugEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoFixDebugEntry {
|
||||||
|
file: string;
|
||||||
|
status: 'changed' | 'skipped' | 'no-op';
|
||||||
|
beforeLiteralCount: number;
|
||||||
|
textReplacements: number;
|
||||||
|
attrReplacements: number;
|
||||||
|
afterLiteralCount: number;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoFixOptions {
|
||||||
|
targetFiles?: Set<string>;
|
||||||
|
forceConvertToClient?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSLATABLE_ATTRIBUTES = ['placeholder', 'title', 'alt', 'aria-label'] as const;
|
||||||
|
const USE_CLIENT_PREFIX_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*['\"]use client['\"]\s*;?\s*/;
|
||||||
|
const LEADING_PREAMBLE_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/;
|
||||||
|
|
||||||
async function walk(dir: string, outFiles: string[], counters: { dirs: number }) {
|
async function walk(dir: string, outFiles: string[], counters: { dirs: number }) {
|
||||||
counters.dirs += 1;
|
counters.dirs += 1;
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
@ -84,6 +119,18 @@ function extractTranslationKeys(content: string): string[] {
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLikelyStaticTranslationKey(key: string): boolean {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
// Dynamic template fragments cannot be validated as missing static keys.
|
||||||
|
if (trimmed.includes('${')) return false;
|
||||||
|
// Accept typical dotted key paths only.
|
||||||
|
if (!/^[A-Za-z0-9_.-]+$/.test(trimmed)) return false;
|
||||||
|
// Ignore placeholder examples like "..." that appear in help text.
|
||||||
|
if (!/[A-Za-z0-9]/.test(trimmed)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function isUiCodeFile(filePath: string): boolean {
|
function isUiCodeFile(filePath: string): boolean {
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
return ext === '.tsx' || ext === '.jsx';
|
return ext === '.tsx' || ext === '.jsx';
|
||||||
@ -101,6 +148,14 @@ function extractPotentialUiLiterals(content: string): string[] {
|
|||||||
literals.push(text);
|
literals.push(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple quoted JSX attribute literals, e.g. placeholder="Search..."
|
||||||
|
const attrRegex = /\b(?:placeholder|title|alt|aria-label)\s*=\s*(['"])([^"'{}<>\n][^"'<>\n]*)\1/g;
|
||||||
|
while ((match = attrRegex.exec(content)) !== null) {
|
||||||
|
const text = match[2]?.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!text) continue;
|
||||||
|
literals.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
return literals;
|
return literals;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,10 +176,421 @@ function shouldIgnoreLiteral(text: string): boolean {
|
|||||||
if (/^(use client|true|false|null|undefined)$/i.test(trimmed)) return true;
|
if (/^(use client|true|false|null|undefined)$/i.test(trimmed)) return true;
|
||||||
if (/(className|onClick|href|src|aria-|data-)/.test(trimmed)) return true;
|
if (/(className|onClick|href|src|aria-|data-)/.test(trimmed)) return true;
|
||||||
if (/^[A-Za-z0-9_.]+\s*:\s*[A-Za-z0-9_.]+$/.test(trimmed)) return true;
|
if (/^[A-Za-z0-9_.]+\s*:\s*[A-Za-z0-9_.]+$/.test(trimmed)) return true;
|
||||||
|
if (/\|/.test(trimmed) && /\b(void|Promise|string|number|boolean|any|unknown|never|null|undefined|Record|React)\b/.test(trimmed)) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeForTsString(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/'/g, "\\'")
|
||||||
|
.replace(/\r?\n/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashText(input: string): string {
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
hash = ((hash << 5) + hash) ^ input.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return Math.abs(hash >>> 0).toString(16).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAutofixKey(text: string): string {
|
||||||
|
return `autofix.k${hashText(text)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUseClientDirective(content: string): boolean {
|
||||||
|
return USE_CLIENT_PREFIX_REGEX.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutoFixCandidatePath(relPath: string): boolean {
|
||||||
|
if (!relPath.startsWith('src/app/')) return false;
|
||||||
|
if (relPath.startsWith('src/app/api/')) return false;
|
||||||
|
if (relPath.startsWith('src/app/i18n/')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUseTranslationImport(content: string): boolean {
|
||||||
|
return /from\s+['\"][^'\"]*\/i18n\/useTranslation['\"];?/.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTHook(content: string): boolean {
|
||||||
|
return /const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(/.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExistingTUsage(content: string): boolean {
|
||||||
|
return /\bt\(\s*['"`][^'"`]+['"`]\s*[,\)\]]/.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasServerOnlySignals(content: string): string | null {
|
||||||
|
if (/from\s+['\"]next\/headers['\"]/.test(content)) return 'uses next/headers';
|
||||||
|
if (/from\s+['\"]next\/server['\"]/.test(content)) return 'uses next/server';
|
||||||
|
if (/\bexport\s+const\s+metadata\b/.test(content)) return 'exports metadata';
|
||||||
|
if (/\bexport\s+async\s+function\s+generateMetadata\b/.test(content)) return 'uses generateMetadata';
|
||||||
|
if (/\bexport\s+default\s+async\s+function\b/.test(content)) return 'default component is async (server-style)';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertUseClientDirective(content: string): { content: string; added: boolean } {
|
||||||
|
if (hasUseClientDirective(content)) return { content, added: false };
|
||||||
|
|
||||||
|
const match = content.match(LEADING_PREAMBLE_REGEX);
|
||||||
|
const at = match ? match[0].length : 0;
|
||||||
|
const before = content.slice(0, at);
|
||||||
|
const after = content.slice(at);
|
||||||
|
const spacer = before.endsWith('\n') || before.length === 0 ? '' : '\n';
|
||||||
|
const next = `${before}${spacer}'use client';\n\n${after}`;
|
||||||
|
return { content: next, added: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUseTranslation(
|
||||||
|
content: string,
|
||||||
|
filePathAbs: string,
|
||||||
|
options: { forceConvertToClient?: boolean } = {}
|
||||||
|
): { content: string; addedImport: boolean; addedHook: boolean; reason?: string } {
|
||||||
|
let next = content;
|
||||||
|
|
||||||
|
if (!hasUseClientDirective(content)) {
|
||||||
|
if (hasTHook(content) || hasExistingTUsage(content)) {
|
||||||
|
return { content, addedImport: false, addedHook: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.forceConvertToClient) {
|
||||||
|
return { content, addedImport: false, addedHook: false, reason: "File is not a client component ('use client')." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signal = hasServerOnlySignals(content);
|
||||||
|
if (signal) {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
addedImport: false,
|
||||||
|
addedHook: false,
|
||||||
|
reason: `Force convert skipped: file appears server-only (${signal}).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
next = insertUseClientDirective(content).content;
|
||||||
|
}
|
||||||
|
|
||||||
|
let addedImport = false;
|
||||||
|
let addedHook = false;
|
||||||
|
|
||||||
|
if (!hasUseTranslationImport(next)) {
|
||||||
|
const fileDir = path.dirname(filePathAbs);
|
||||||
|
const importTarget = path.join(process.cwd(), 'src', 'app', 'i18n', 'useTranslation');
|
||||||
|
let rel = path.relative(fileDir, importTarget).split(path.sep).join('/');
|
||||||
|
if (!rel.startsWith('.')) rel = `./${rel}`;
|
||||||
|
|
||||||
|
const importLine = `import { useTranslation } from '${rel}';\n`;
|
||||||
|
const useClientMatch = next.match(USE_CLIENT_PREFIX_REGEX);
|
||||||
|
if (!useClientMatch || typeof useClientMatch.index !== 'number') {
|
||||||
|
return { content, addedImport: false, addedHook: false, reason: 'Could not place import statement.' };
|
||||||
|
}
|
||||||
|
const at = useClientMatch.index + useClientMatch[0].length;
|
||||||
|
next = `${next.slice(0, at)}\n\n${importLine}${next.slice(at)}`;
|
||||||
|
addedImport = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasTHook(next)) {
|
||||||
|
const fnPatterns = [
|
||||||
|
/export\s+default\s+function\s+\w+\s*\([\s\S]*?\)\s*\{/m,
|
||||||
|
/function\s+\w+\s*\([\s\S]*?\)\s*\{/m,
|
||||||
|
/const\s+\w+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/m,
|
||||||
|
/const\s+\w+\s*:\s*[^=\n]+\s*=\s*\([\s\S]*?\)\s*=>\s*\{/m,
|
||||||
|
];
|
||||||
|
|
||||||
|
let inserted = false;
|
||||||
|
for (const pattern of fnPatterns) {
|
||||||
|
const match = next.match(pattern);
|
||||||
|
if (!match || typeof match.index !== 'number') continue;
|
||||||
|
const at = match.index + match[0].length;
|
||||||
|
next = `${next.slice(0, at)}\n const { t } = useTranslation();${next.slice(at)}`;
|
||||||
|
inserted = true;
|
||||||
|
addedHook = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inserted) {
|
||||||
|
return { content, addedImport: false, addedHook: false, reason: 'Could not locate a component function to inject useTranslation().' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: next, addedImport, addedHook };
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceJsxAttributeLiterals(content: string): { content: string; replacements: number; keyValueMap: Map<string, string> } {
|
||||||
|
const keyValueMap = new Map<string, string>();
|
||||||
|
let replacements = 0;
|
||||||
|
|
||||||
|
const attrsPattern = TRANSLATABLE_ATTRIBUTES.map((a) => a.replace('-', '\\-')).join('|');
|
||||||
|
const attrRegex = new RegExp(`\\b(${attrsPattern})\\s*=\\s*([\"'])([^\"'{}<>\\n][^\"'<>\\n]*)\\2`, 'g');
|
||||||
|
|
||||||
|
// Only rewrite inside JSX tag bodies, never in TS/JS function params or object literals.
|
||||||
|
const next = content.replace(/<[^>]+>/g, (tag) =>
|
||||||
|
tag.replace(attrRegex, (full, attrName: string, quote: string, captured: string) => {
|
||||||
|
const text = String(captured ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (shouldIgnoreLiteral(text)) return full;
|
||||||
|
|
||||||
|
const key = toAutofixKey(text);
|
||||||
|
keyValueMap.set(key, text);
|
||||||
|
replacements += 1;
|
||||||
|
return `${attrName}={t('${key}')}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { content: next, replacements, keyValueMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceJsxTextNodes(content: string): { content: string; replacements: number; keyValueMap: Map<string, string> } {
|
||||||
|
const keyValueMap = new Map<string, string>();
|
||||||
|
let replacements = 0;
|
||||||
|
|
||||||
|
const jsxTextRegex = />\s*([^<>{}\n][^<>{}\n]{1,})\s*</g;
|
||||||
|
const next = content.replace(jsxTextRegex, (full, captured: string) => {
|
||||||
|
const text = String(captured ?? '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (shouldIgnoreLiteral(text)) return full;
|
||||||
|
|
||||||
|
const key = toAutofixKey(text);
|
||||||
|
keyValueMap.set(key, text);
|
||||||
|
replacements += 1;
|
||||||
|
return `>{t('${key}')}<`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { content: next, replacements, keyValueMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertAutofixNamespace(content: string, entries: Map<string, string>): string {
|
||||||
|
if (entries.size === 0) return content;
|
||||||
|
|
||||||
|
const entryLines = Array.from(entries.entries())
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const shortKey = key.replace(/^autofix\./, '');
|
||||||
|
return ` ${shortKey}: '${escapeForTsString(value)}',`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const autofixBlockRegex = /\n\s{2}autofix:\s*\{([\s\S]*?)\n\s{2}\},/m;
|
||||||
|
const match = content.match(autofixBlockRegex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const blockBody = match[1] ?? '';
|
||||||
|
const existing = new Set<string>();
|
||||||
|
for (const m of blockBody.matchAll(/\n\s{4}([A-Za-z0-9_]+):\s*'/g)) {
|
||||||
|
existing.add(`autofix.${m[1]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = entryLines.filter((line) => {
|
||||||
|
const m = line.match(/^\s{4}([A-Za-z0-9_]+):/);
|
||||||
|
if (!m) return false;
|
||||||
|
return !existing.has(`autofix.${m[1]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missing.length === 0) return content;
|
||||||
|
|
||||||
|
const replacement = `\n autofix: {${blockBody}\n${missing.join('\n')}\n },`;
|
||||||
|
return content.replace(autofixBlockRegex, replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastsComment = /\n\s{2}\/\/\s*─+\s*Notifications\s*\/\s*Toasts[\s\S]*?\n\s{2}toasts:/m;
|
||||||
|
if (toastsComment.test(content)) {
|
||||||
|
const autofixBlock = `\n autofix: {\n${entryLines.join('\n')}\n },\n`;
|
||||||
|
return content.replace(toastsComment, `${autofixBlock}\n // ─── Notifications / Toasts ────────────────────────────\n toasts:`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertAutofixType(content: string): string {
|
||||||
|
// Already has a Record<string,string> entry (any indentation)
|
||||||
|
if (/\n\s*autofix:\s*Record<string,\s*string>;/.test(content)) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
// Already has a proper block entry
|
||||||
|
if (/\n\s*autofix:\s*\{/.test(content)) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertBefore = /\n\s{2}\/\/\s*─+\s*Notifications\s*\/\s*Toasts[\s\S]*?\n\s{2}toasts:/m;
|
||||||
|
if (!insertBefore.test(content)) return content;
|
||||||
|
|
||||||
|
const block = `\n autofix: Record<string, string>;\n`;
|
||||||
|
return content.replace(insertBefore, `${block}\n // ─── Notifications / Toasts ────────────────────────────\n toasts:`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAutoFix(options: AutoFixOptions = {}): Promise<AutoFixResult> {
|
||||||
|
const workspaceRoot = process.cwd();
|
||||||
|
const files: string[] = [];
|
||||||
|
const counters = { dirs: 0 };
|
||||||
|
|
||||||
|
await walk(workspaceRoot, files, counters);
|
||||||
|
|
||||||
|
const uiFiles = files.filter((f) => {
|
||||||
|
const ext = path.extname(f).toLowerCase();
|
||||||
|
if (ext !== '.tsx' && ext !== '.jsx') return false;
|
||||||
|
const rel = toRelativeWorkspacePath(f);
|
||||||
|
if (!isAutoFixCandidatePath(rel)) return false;
|
||||||
|
if (options.targetFiles && options.targetFiles.size > 0 && !options.targetFiles.has(rel)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const changedFiles: AutoFixChange[] = [];
|
||||||
|
const skippedFiles: Array<{ file: string; reason: string }> = [];
|
||||||
|
const createdEntries = new Map<string, string>();
|
||||||
|
const debugEntries: AutoFixDebugEntry[] = [];
|
||||||
|
|
||||||
|
if (options.targetFiles && options.targetFiles.size > 0) {
|
||||||
|
const discoveredSet = new Set(files.map((f) => toRelativeWorkspacePath(f)));
|
||||||
|
const eligibleSet = new Set(uiFiles.map((f) => toRelativeWorkspacePath(f)));
|
||||||
|
|
||||||
|
for (const target of options.targetFiles) {
|
||||||
|
if (!discoveredSet.has(target)) {
|
||||||
|
skippedFiles.push({ file: target, reason: 'Target file was not found in workspace scan.' });
|
||||||
|
debugEntries.push({
|
||||||
|
file: target,
|
||||||
|
status: 'skipped',
|
||||||
|
beforeLiteralCount: 0,
|
||||||
|
textReplacements: 0,
|
||||||
|
attrReplacements: 0,
|
||||||
|
afterLiteralCount: 0,
|
||||||
|
reason: 'Target file was not found in workspace scan.',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!eligibleSet.has(target)) {
|
||||||
|
skippedFiles.push({ file: target, reason: 'Target file is not eligible for auto-fix (requires TSX/JSX under src/app).' });
|
||||||
|
debugEntries.push({
|
||||||
|
file: target,
|
||||||
|
status: 'skipped',
|
||||||
|
beforeLiteralCount: 0,
|
||||||
|
textReplacements: 0,
|
||||||
|
attrReplacements: 0,
|
||||||
|
afterLiteralCount: 0,
|
||||||
|
reason: 'Target file is not eligible for auto-fix (requires TSX/JSX under src/app).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const absPath of uiFiles) {
|
||||||
|
const relPath = toRelativeWorkspacePath(absPath);
|
||||||
|
const raw = await fs.readFile(absPath, 'utf8');
|
||||||
|
|
||||||
|
const literalCheck = extractPotentialUiLiterals(raw).filter((text) => !shouldIgnoreLiteral(text));
|
||||||
|
if (literalCheck.length === 0) {
|
||||||
|
debugEntries.push({
|
||||||
|
file: relPath,
|
||||||
|
status: 'no-op',
|
||||||
|
beforeLiteralCount: 0,
|
||||||
|
textReplacements: 0,
|
||||||
|
attrReplacements: 0,
|
||||||
|
afterLiteralCount: 0,
|
||||||
|
reason: 'No potential untranslated literals remained in this file.',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensured = ensureUseTranslation(raw, absPath, { forceConvertToClient: options.forceConvertToClient });
|
||||||
|
if (ensured.reason) {
|
||||||
|
skippedFiles.push({ file: relPath, reason: ensured.reason });
|
||||||
|
debugEntries.push({
|
||||||
|
file: relPath,
|
||||||
|
status: 'skipped',
|
||||||
|
beforeLiteralCount: literalCheck.length,
|
||||||
|
textReplacements: 0,
|
||||||
|
attrReplacements: 0,
|
||||||
|
afterLiteralCount: literalCheck.length,
|
||||||
|
reason: ensured.reason,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textReplaced = replaceJsxTextNodes(ensured.content);
|
||||||
|
const attrReplaced = replaceJsxAttributeLiterals(textReplaced.content);
|
||||||
|
const totalReplacements = textReplaced.replacements + attrReplaced.replacements;
|
||||||
|
const afterLiteralCount = extractPotentialUiLiterals(attrReplaced.content).filter((text) => !shouldIgnoreLiteral(text)).length;
|
||||||
|
if (totalReplacements === 0) {
|
||||||
|
const reason = 'No supported replacement patterns matched these literals (likely complex JSX/expression cases).';
|
||||||
|
skippedFiles.push({ file: relPath, reason });
|
||||||
|
debugEntries.push({
|
||||||
|
file: relPath,
|
||||||
|
status: 'skipped',
|
||||||
|
beforeLiteralCount: literalCheck.length,
|
||||||
|
textReplacements: 0,
|
||||||
|
attrReplacements: 0,
|
||||||
|
afterLiteralCount,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [k, v] of textReplaced.keyValueMap.entries()) {
|
||||||
|
createdEntries.set(k, v);
|
||||||
|
}
|
||||||
|
for (const [k, v] of attrReplaced.keyValueMap.entries()) {
|
||||||
|
createdEntries.set(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrReplaced.content !== raw) {
|
||||||
|
await fs.writeFile(absPath, attrReplaced.content, 'utf8');
|
||||||
|
changedFiles.push({
|
||||||
|
file: relPath,
|
||||||
|
replacements: totalReplacements,
|
||||||
|
addedImport: ensured.addedImport,
|
||||||
|
addedHook: ensured.addedHook,
|
||||||
|
});
|
||||||
|
debugEntries.push({
|
||||||
|
file: relPath,
|
||||||
|
status: 'changed',
|
||||||
|
beforeLiteralCount: literalCheck.length,
|
||||||
|
textReplacements: textReplaced.replacements,
|
||||||
|
attrReplacements: attrReplaced.replacements,
|
||||||
|
afterLiteralCount,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
debugEntries.push({
|
||||||
|
file: relPath,
|
||||||
|
status: 'no-op',
|
||||||
|
beforeLiteralCount: literalCheck.length,
|
||||||
|
textReplacements: textReplaced.replacements,
|
||||||
|
attrReplacements: attrReplaced.replacements,
|
||||||
|
afterLiteralCount,
|
||||||
|
reason: 'Generated output matched input; no file write needed.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdEntries.size > 0) {
|
||||||
|
const enPath = path.join(process.cwd(), 'src', 'app', 'i18n', 'translations', 'en.ts');
|
||||||
|
const dePath = path.join(process.cwd(), 'src', 'app', 'i18n', 'translations', 'de.ts');
|
||||||
|
const typesPath = path.join(process.cwd(), 'src', 'app', 'i18n', 'types.ts');
|
||||||
|
|
||||||
|
const [enRaw, deRaw, typesRaw] = await Promise.all([
|
||||||
|
fs.readFile(enPath, 'utf8'),
|
||||||
|
fs.readFile(dePath, 'utf8'),
|
||||||
|
fs.readFile(typesPath, 'utf8'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nextEn = upsertAutofixNamespace(enRaw, createdEntries);
|
||||||
|
const nextDe = upsertAutofixNamespace(deRaw, createdEntries);
|
||||||
|
const nextTypes = upsertAutofixType(typesRaw);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
nextEn !== enRaw ? fs.writeFile(enPath, nextEn, 'utf8') : Promise.resolve(),
|
||||||
|
nextDe !== deRaw ? fs.writeFile(dePath, nextDe, 'utf8') : Promise.resolve(),
|
||||||
|
nextTypes !== typesRaw ? fs.writeFile(typesPath, nextTypes, 'utf8') : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changedFiles,
|
||||||
|
skippedFiles,
|
||||||
|
createdKeys: Array.from(createdEntries.keys()).sort(),
|
||||||
|
debugEntries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toRelativeWorkspacePath(absPath: string): string {
|
function toRelativeWorkspacePath(absPath: string): string {
|
||||||
const rel = path.relative(process.cwd(), absPath);
|
const rel = path.relative(process.cwd(), absPath);
|
||||||
return rel.split(path.sep).join('/');
|
return rel.split(path.sep).join('/');
|
||||||
@ -141,6 +607,8 @@ async function runWorkspaceScan(): Promise<ScanResult> {
|
|||||||
const uniqueUsedKeys = new Set<string>();
|
const uniqueUsedKeys = new Set<string>();
|
||||||
const missingKeyFiles: MissingKeyMap = new Map();
|
const missingKeyFiles: MissingKeyMap = new Map();
|
||||||
const untranslatedLiteralFiles: Map<string, Set<string>> = new Map();
|
const untranslatedLiteralFiles: Map<string, Set<string>> = new Map();
|
||||||
|
const autoFixEligibleFilesSet = new Set<string>();
|
||||||
|
const autoFixForceConvertibleFilesSet = new Set<string>();
|
||||||
|
|
||||||
let translationCallCount = 0;
|
let translationCallCount = 0;
|
||||||
|
|
||||||
@ -150,9 +618,10 @@ async function runWorkspaceScan(): Promise<ScanResult> {
|
|||||||
|
|
||||||
const usedKeys = extractTranslationKeys(raw);
|
const usedKeys = extractTranslationKeys(raw);
|
||||||
if (usedKeys.length > 0) {
|
if (usedKeys.length > 0) {
|
||||||
translationCallCount += usedKeys.length;
|
const staticKeys = usedKeys.filter(isLikelyStaticTranslationKey);
|
||||||
|
translationCallCount += staticKeys.length;
|
||||||
|
|
||||||
for (const key of usedKeys) {
|
for (const key of staticKeys) {
|
||||||
uniqueUsedKeys.add(key);
|
uniqueUsedKeys.add(key);
|
||||||
|
|
||||||
if (!englishKeys.has(key)) {
|
if (!englishKeys.has(key)) {
|
||||||
@ -172,6 +641,14 @@ async function runWorkspaceScan(): Promise<ScanResult> {
|
|||||||
}
|
}
|
||||||
untranslatedLiteralFiles.get(text)?.add(relativePath);
|
untranslatedLiteralFiles.get(text)?.add(relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (literals.length > 0 && isAutoFixCandidatePath(relativePath) && (hasUseClientDirective(raw) || hasExistingTUsage(raw))) {
|
||||||
|
autoFixEligibleFilesSet.add(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (literals.length > 0 && isAutoFixCandidatePath(relativePath)) {
|
||||||
|
autoFixForceConvertibleFilesSet.add(relativePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,6 +668,8 @@ async function runWorkspaceScan(): Promise<ScanResult> {
|
|||||||
uniqueKeyCount: uniqueUsedKeys.size,
|
uniqueKeyCount: uniqueUsedKeys.size,
|
||||||
missingKeys,
|
missingKeys,
|
||||||
untranslatedLiterals,
|
untranslatedLiterals,
|
||||||
|
autoFixEligibleFiles: Array.from(autoFixEligibleFilesSet).sort((a, b) => a.localeCompare(b)),
|
||||||
|
autoFixForceConvertibleFiles: Array.from(autoFixForceConvertibleFilesSet).sort((a, b) => a.localeCompare(b)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,3 +692,62 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
let targetFilesSet: Set<string> | undefined;
|
||||||
|
let forceConvertToClient = false;
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const raw = Array.isArray(body?.targetFiles) ? body.targetFiles : [];
|
||||||
|
forceConvertToClient = Boolean(body?.forceConvertToClient);
|
||||||
|
const cleaned = raw
|
||||||
|
.filter((v: unknown) => typeof v === 'string')
|
||||||
|
.map((v: string) => v.trim())
|
||||||
|
.filter((v: string) => v.length > 0);
|
||||||
|
if (cleaned.length > 0) {
|
||||||
|
targetFilesSet = new Set(cleaned);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// allow empty body (fix all eligible files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixResult = await runAutoFix({ targetFiles: targetFilesSet, forceConvertToClient });
|
||||||
|
const scanResult = await runWorkspaceScan();
|
||||||
|
|
||||||
|
console.info('[i18n-autofix] Summary', {
|
||||||
|
targetFileCount: targetFilesSet ? targetFilesSet.size : null,
|
||||||
|
forceConvertToClient,
|
||||||
|
changedFileCount: fixResult.changedFiles.length,
|
||||||
|
skippedFileCount: fixResult.skippedFiles.length,
|
||||||
|
createdKeyCount: fixResult.createdKeys.length,
|
||||||
|
});
|
||||||
|
for (const entry of fixResult.debugEntries.slice(0, 200)) {
|
||||||
|
console.info('[i18n-autofix:file]', entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
mode: 'autofix',
|
||||||
|
fixedAt: new Date().toISOString(),
|
||||||
|
forceConvertToClient,
|
||||||
|
targetFilesApplied: targetFilesSet ? Array.from(targetFilesSet).sort() : null,
|
||||||
|
changedFileCount: fixResult.changedFiles.length,
|
||||||
|
changedFiles: fixResult.changedFiles,
|
||||||
|
skippedFiles: fixResult.skippedFiles,
|
||||||
|
autoFixDebug: fixResult.debugEntries,
|
||||||
|
createdKeyCount: fixResult.createdKeys.length,
|
||||||
|
createdKeys: fixResult.createdKeys,
|
||||||
|
...scanResult,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Workspace i18n auto-fix failed:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
message: 'Workspace auto-fix failed.',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
313
src/app/api/i18n/translations/route.ts
Normal file
313
src/app/api/i18n/translations/route.ts
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { flattenObject, unflattenObject } from '@/app/i18n/dynamicTranslations';
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,10 @@ 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 { useTranslation } from '../i18n/useTranslation';
|
||||||
|
|
||||||
export default function CoffeeAbonnementPage() {
|
export default function CoffeeAbonnementPage() {
|
||||||
|
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 [bump, setBump] = useState<Record<string, boolean>>({});
|
||||||
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<number>(60);
|
const [selectedPlanCapsules, setSelectedPlanCapsules] = useState<number>(60);
|
||||||
@ -117,7 +120,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="mx-auto max-w-7xl px-4 py-10 space-y-10 bg-gradient-to-b from-white to-[#1C2B4A0D]">
|
<div className="mx-auto max-w-7xl px-4 py-10 space-y-10 bg-gradient-to-b from-white to-[#1C2B4A0D]">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
<span className="text-[#1C2B4A]">Configure Coffee Subscription</span>
|
<span className="text-[#1C2B4A]">{t('autofix.kb0b660e2')}</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Stepper */}
|
{/* Stepper */}
|
||||||
@ -135,7 +138,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
|
|
||||||
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
{/* Section 1: Multi coffee selection + per-coffee quantity */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-semibold mb-4">1. Select subscription size</h2>
|
<h2 className="text-xl font-semibold mb-4">{t('autofix.k7f48f374')}</h2>
|
||||||
<div className="mb-6 rounded-xl border border-[#1C2B4A]/20 p-4 bg-white/80 backdrop-blur-sm shadow-lg">
|
<div className="mb-6 rounded-xl border border-[#1C2B4A]/20 p-4 bg-white/80 backdrop-blur-sm shadow-lg">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@ -155,9 +158,9 @@ export default function CoffeeAbonnementPage() {
|
|||||||
>+</button>
|
>+</button>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
{shippingLoading ? (
|
{shippingLoading ? (
|
||||||
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-gray-100 text-gray-700">Shipping…</span>
|
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-gray-100 text-gray-700">{t('autofix.k12a86c71')}</span>
|
||||||
) : isFreeShippingSelected ? (
|
) : isFreeShippingSelected ? (
|
||||||
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs 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-3 py-1 text-xs font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">{t('autofix.ke7f0a9e3')}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">Shipping €{selectedShippingFee.toFixed(2)}</span>
|
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200">Shipping €{selectedShippingFee.toFixed(2)}</span>
|
||||||
)}
|
)}
|
||||||
@ -170,7 +173,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mb-4">2. Choose coffees & quantities</h2>
|
<h2 className="text-xl font-semibold mb-4">{t('autofix.k0b03e660')}</h2>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
@ -226,9 +229,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
>
|
>
|
||||||
€{coffee.pricePer10}
|
€{coffee.pricePer10}
|
||||||
</span>
|
</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">
|
<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">{t('autofix.k83deba83')}</span>
|
||||||
per 10 pcs
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@ -317,10 +318,10 @@ export default function CoffeeAbonnementPage() {
|
|||||||
|
|
||||||
{/* Section 2: Compact preview + next steps */}
|
{/* Section 2: Compact preview + next steps */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-semibold mb-4">3. Preview</h2>
|
<h2 className="text-xl font-semibold mb-4">{t('autofix.ke7b634f2')}</h2>
|
||||||
<div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
|
<div className="rounded-xl border border-[#1C2B4A]/20 p-6 bg-white/80 backdrop-blur-sm space-y-4 shadow-lg">
|
||||||
{selectedEntries.length === 0 && (
|
{selectedEntries.length === 0 && (
|
||||||
<p className="text-sm text-gray-600">No coffees selected yet.</p>
|
<p className="text-sm text-gray-600">{t('autofix.kec078e54')}</p>
|
||||||
)}
|
)}
|
||||||
{selectedEntries.map((entry) => (
|
{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 key={entry.coffee.id} className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0">
|
||||||
@ -378,9 +379,7 @@ export default function CoffeeAbonnementPage() {
|
|||||||
? 'bg-[#1C2B4A] text-white hover:bg-[#1C2B4A]/90 shadow-md hover:shadow-lg'
|
? 'bg-[#1C2B4A] text-white hover:bg-[#1C2B4A]/90 shadow-md hover:shadow-lg'
|
||||||
: 'bg-gray-200 text-gray-600 cursor-not-allowed'
|
: 'bg-gray-200 text-gray-600 cursor-not-allowed'
|
||||||
}`}
|
}`}
|
||||||
>
|
>{t('autofix.k02665163')}<svg
|
||||||
Next steps
|
|
||||||
<svg
|
|
||||||
className={`ml-2 h-5 w-5 transition-transform ${
|
className={`ml-2 h-5 w-5 transition-transform ${
|
||||||
canProceed ? 'group-hover:translate-x-0.5' : ''
|
canProceed ? 'group-hover:translate-x-0.5' : ''
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { getStandardVatRate, getVatRates } from './hooks/getTaxRate';
|
|||||||
import { subscribeAbo, type SubscribeAboInput } from './hooks/subscribeAbo';
|
import { subscribeAbo, type SubscribeAboInput } from './hooks/subscribeAbo';
|
||||||
import useAuthStore from '../../store/authStore'
|
import useAuthStore from '../../store/authStore'
|
||||||
import { useShippingFees } from '../hooks/useShippingFees';
|
import { useShippingFees } from '../hooks/useShippingFees';
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
|
|
||||||
import { useAboContractTemplateHtml } from './hooks/useAboContractTemplateHtml'
|
import { useAboContractTemplateHtml } from './hooks/useAboContractTemplateHtml'
|
||||||
import SignaturePad from './components/SignaturePad'
|
import SignaturePad from './components/SignaturePad'
|
||||||
import { Dialog, DialogActions, DialogBody, DialogTitle } from '../../components/dialog'
|
import { Dialog, DialogActions, DialogBody, DialogTitle } from '../../components/dialog'
|
||||||
@ -62,6 +64,7 @@ function pickFirstString(...values: unknown[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SummaryPage() {
|
export default function SummaryPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { coffees, loading, error } = useActiveCoffees();
|
const { coffees, loading, error } = useActiveCoffees();
|
||||||
const user = useAuthStore(state => state.user)
|
const user = useAuthStore(state => state.user)
|
||||||
@ -640,14 +643,12 @@ export default function SummaryPage() {
|
|||||||
<div className="mx-auto max-w-6xl px-4 py-10 space-y-8 bg-gradient-to-b from-white to-[#1C2B4A0D]">
|
<div className="mx-auto max-w-6xl px-4 py-10 space-y-8 bg-gradient-to-b from-white to-[#1C2B4A0D]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
<span className="text-[#1C2B4A]">Summary & Details</span>
|
<span className="text-[#1C2B4A]">{t('autofix.k21361e0d')}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<button
|
<button
|
||||||
onClick={backToSelection}
|
onClick={backToSelection}
|
||||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100"
|
className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-100"
|
||||||
>
|
>{t('autofix.k96839795')}</button>
|
||||||
Back to selection
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stepper */}
|
{/* Stepper */}
|
||||||
@ -669,9 +670,7 @@ export default function SummaryPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={backToSelection}
|
onClick={backToSelection}
|
||||||
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
|
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
|
||||||
>
|
>{t('autofix.k96839795')}</button>
|
||||||
Back to selection
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -688,35 +687,31 @@ export default function SummaryPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : selectedEntries.length === 0 ? (
|
) : selectedEntries.length === 0 ? (
|
||||||
<div className="rounded-xl border p-6 bg-white shadow-sm">
|
<div className="rounded-xl border p-6 bg-white shadow-sm">
|
||||||
<p className="text-sm text-gray-600 mb-4">No selection found.</p>
|
<p className="text-sm text-gray-600 mb-4">{t('autofix.k20127e1c')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={backToSelection}
|
onClick={backToSelection}
|
||||||
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
|
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90"
|
||||||
>
|
>{t('autofix.k96839795')}</button>
|
||||||
Back to selection
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-8 lg:grid-cols-3">
|
<div className="grid gap-8 lg:grid-cols-3">
|
||||||
{/* Left: Customer data */}
|
{/* Left: Customer data */}
|
||||||
<section className="lg:col-span-2">
|
<section className="lg:col-span-2">
|
||||||
<h2 className="text-xl font-semibold mb-4">1. Your details</h2>
|
<h2 className="text-xl font-semibold mb-4">{t('autofix.kd6f8d7e9')}</h2>
|
||||||
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg">
|
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={fillFromLoggedInData}
|
onClick={fillFromLoggedInData}
|
||||||
className="mb-4 w-full rounded-md border border-[#1C2B4A] px-3 py-2 text-sm font-medium text-[#1C2B4A] hover:bg-[#1C2B4A]/5"
|
className="mb-4 w-full rounded-md border border-[#1C2B4A] px-3 py-2 text-sm font-medium text-[#1C2B4A] hover:bg-[#1C2B4A]/5"
|
||||||
>
|
>{t('autofix.k9c1a5ecc')}</button>
|
||||||
Fill fields with logged in data
|
|
||||||
</button>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{/* inputs translated */}
|
{/* inputs translated */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">First name</label>
|
<label className="block text-sm font-medium mb-1">{t('autofix.kfe9527d8')}</label>
|
||||||
<input name="firstName" value={form.firstName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
<input name="firstName" value={form.firstName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Last name</label>
|
<label className="block text-sm font-medium mb-1">{t('autofix.k6a2c64e8')}</label>
|
||||||
<input name="lastName" value={form.lastName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
<input name="lastName" value={form.lastName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
@ -724,7 +719,7 @@ export default function SummaryPage() {
|
|||||||
<input type="email" name="email" value={form.email} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
<input type="email" name="email" value={form.email} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1">Street & No.</label>
|
<label className="block text-sm font-medium mb-1">{t('autofix.kd1a2772d')}</label>
|
||||||
<input name="street" value={form.street} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
<input name="street" value={form.street} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -751,7 +746,7 @@ export default function SummaryPage() {
|
|||||||
|
|
||||||
{/* Payment method */}
|
{/* Payment method */}
|
||||||
<div className="mt-6 border-t border-gray-200 pt-4">
|
<div className="mt-6 border-t border-gray-200 pt-4">
|
||||||
<h3 className="text-base font-semibold text-gray-900 mb-3">Payment method</h3>
|
<h3 className="text-base font-semibold text-gray-900 mb-3">{t('autofix.k3466b0e0')}</h3>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{(['sepa', 'card', 'sofort'] as const).map(method => (
|
{(['sepa', 'card', 'sofort'] as const).map(method => (
|
||||||
<label key={method} className={`flex items-center gap-2 rounded-md border px-4 py-2 cursor-pointer transition ${form.paymentMethod === method ? 'border-[#1C2B4A] bg-[#1C2B4A]/5 font-medium' : 'border-gray-300 hover:bg-gray-50'}`}>
|
<label key={method} className={`flex items-center gap-2 rounded-md border px-4 py-2 cursor-pointer transition ${form.paymentMethod === method ? 'border-[#1C2B4A] bg-[#1C2B4A]/5 font-medium' : 'border-gray-300 hover:bg-gray-50'}`}>
|
||||||
@ -761,23 +756,19 @@ export default function SummaryPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<label className="mt-3 flex items-center gap-2 text-sm">
|
<label className="mt-3 flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" name="invoiceByEmail" checked={form.invoiceByEmail} onChange={handleCheckbox} className="accent-[#1C2B4A]" />
|
<input type="checkbox" name="invoiceByEmail" checked={form.invoiceByEmail} onChange={handleCheckbox} className="accent-[#1C2B4A]" />{t('autofix.ke33e6fbf')}</label>
|
||||||
Send invoice by email
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invoice address */}
|
{/* Invoice address */}
|
||||||
<div className="mt-6 border-t border-gray-200 pt-4">
|
<div className="mt-6 border-t border-gray-200 pt-4">
|
||||||
<h3 className="text-base font-semibold text-gray-900 mb-3">Invoice address</h3>
|
<h3 className="text-base font-semibold text-gray-900 mb-3">{t('autofix.kce094582')}</h3>
|
||||||
{isCompanyCustomer && (
|
{isCompanyCustomer && (
|
||||||
<div className="mb-3 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">
|
<div className="mb-3 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">
|
||||||
Unternehmer mit gueltiger UID und Rechnungsland ausserhalb von {HOME_COUNTRY_CODE} werden per Reverse Charge ohne ausgewiesene MwSt verrechnet.
|
Unternehmer mit gueltiger UID und Rechnungsland ausserhalb von {HOME_COUNTRY_CODE} werden per Reverse Charge ohne ausgewiesene MwSt verrechnet.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label className="flex items-center gap-2 text-sm mb-3">
|
<label className="flex items-center gap-2 text-sm mb-3">
|
||||||
<input type="checkbox" name="invoiceSameAsShipping" checked={form.invoiceSameAsShipping} onChange={handleCheckbox} className="accent-[#1C2B4A]" />
|
<input type="checkbox" name="invoiceSameAsShipping" checked={form.invoiceSameAsShipping} onChange={handleCheckbox} className="accent-[#1C2B4A]" />{t('autofix.k528eede9')}</label>
|
||||||
Same as shipping address
|
|
||||||
</label>
|
|
||||||
{isCompanyCustomer && (
|
{isCompanyCustomer && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium mb-1">UID Number (optional)</label>
|
<label className="block text-sm font-medium mb-1">UID Number (optional)</label>
|
||||||
@ -785,22 +776,20 @@ export default function SummaryPage() {
|
|||||||
name="uidNumber"
|
name="uidNumber"
|
||||||
value={form.uidNumber}
|
value={form.uidNumber}
|
||||||
onChange={handleInput}
|
onChange={handleInput}
|
||||||
placeholder="z.B. SI12345678"
|
placeholder={t('autofix.kf1512f8f')}
|
||||||
className="w-full rounded border px-3 py-2 bg-white border-gray-300 uppercase focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
className="w-full rounded border px-3 py-2 bg-white border-gray-300 uppercase focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-600">
|
<p className="mt-1 text-xs text-gray-600">{t('autofix.kefe5f0dd')}</p>
|
||||||
Ohne gueltige UID wird die Rechnung mit normaler MwSt erstellt.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!form.invoiceSameAsShipping && (
|
{!form.invoiceSameAsShipping && (
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1">Full name</label>
|
<label className="block text-sm font-medium mb-1">{t('autofix.k28f1a9b1')}</label>
|
||||||
<input name="invoiceFullName" value={form.invoiceFullName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
<input name="invoiceFullName" value={form.invoiceFullName} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1">Street & No.</label>
|
<label className="block text-sm font-medium mb-1">{t('autofix.kd1a2772d')}</label>
|
||||||
<input name="invoiceStreet" value={form.invoiceStreet} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
<input name="invoiceStreet" value={form.invoiceStreet} onChange={handleInput} className="w-full rounded border px-3 py-2 bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -826,14 +815,10 @@ export default function SummaryPage() {
|
|||||||
{/* Contract preview + signature */}
|
{/* Contract preview + signature */}
|
||||||
<div className="mt-6 border-t border-gray-200 pt-6">
|
<div className="mt-6 border-t border-gray-200 pt-6">
|
||||||
<h3 className="text-base font-semibold text-gray-900 mb-2">Contract preview (ABO)</h3>
|
<h3 className="text-base font-semibold text-gray-900 mb-2">Contract preview (ABO)</h3>
|
||||||
<p className="text-xs text-gray-600 mb-3">
|
<p className="text-xs text-gray-600 mb-3">{t('autofix.k155166db')}</p>
|
||||||
Contract variables are auto-populated from your form data.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{contractLoading ? (
|
{contractLoading ? (
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
|
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">{t('autofix.k0bbc633d')}</div>
|
||||||
Loading contract preview…
|
|
||||||
</div>
|
|
||||||
) : contractError ? (
|
) : contractError ? (
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
Contract preview could not be loaded: {contractError}
|
Contract preview could not be loaded: {contractError}
|
||||||
@ -843,21 +828,17 @@ export default function SummaryPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={openContractPreview}
|
onClick={openContractPreview}
|
||||||
className="inline-flex items-center justify-center rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
|
className="inline-flex items-center justify-center rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
|
||||||
>
|
>{t('autofix.kd379df9b')}</button>
|
||||||
Open preview
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
|
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">{t('autofix.ke74b1adf')}</div>
|
||||||
Contract template is not available.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Ort (Signing City) *</label>
|
<label className="block text-sm font-medium mb-1">Ort (Signing City) *</label>
|
||||||
<input type="text" name="signingCity" value={form.signingCity} onChange={handleInput} className={`w-full max-w-xs rounded border px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-[#1C2B4A] ${!hasSigningCity && submitError ? 'border-red-400' : 'border-gray-300'}`} placeholder="z.B. Wien" />
|
<input type="text" name="signingCity" value={form.signingCity} onChange={handleInput} className={`w-full max-w-xs rounded border px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-[#1C2B4A] ${!hasSigningCity && submitError ? 'border-red-400' : 'border-gray-300'}`} placeholder={t('autofix.k1f0b2c48')} />
|
||||||
{!hasSigningCity && submitError && (
|
{!hasSigningCity && submitError && (
|
||||||
<p className="mt-1 text-xs text-red-700">Ort ist erforderlich.</p>
|
<p className="mt-1 text-xs text-red-700">{t('autofix.k516705dd')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} required error={!hasSignature && submitError ? 'Signature is required.' : null} />
|
<SignaturePad value={signatureDataUrl} onChange={setSignatureDataUrl} required error={!hasSignature && submitError ? 'Signature is required.' : null} />
|
||||||
@ -872,21 +853,17 @@ export default function SummaryPage() {
|
|||||||
PDF preview could not be generated: {contractPdfError}
|
PDF preview could not be generated: {contractPdfError}
|
||||||
</div>
|
</div>
|
||||||
) : contractPdfLoading ? (
|
) : contractPdfLoading ? (
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
|
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">{t('autofix.k0b2445d5')}</div>
|
||||||
Generating PDF preview…
|
|
||||||
</div>
|
|
||||||
) : contractPdfUrl ? (
|
) : contractPdfUrl ? (
|
||||||
<div className="rounded-lg border border-gray-300 bg-white overflow-hidden">
|
<div className="rounded-lg border border-gray-300 bg-white overflow-hidden">
|
||||||
<iframe
|
<iframe
|
||||||
title="ABO Contract PDF Preview"
|
title={t('autofix.kaa5e5363')}
|
||||||
className="w-full h-[75vh]"
|
className="w-full h-[75vh]"
|
||||||
src={contractPdfUrl}
|
src={contractPdfUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">
|
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700">{t('autofix.ka56b7b2b')}</div>
|
||||||
No PDF preview available.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@ -913,16 +890,14 @@ export default function SummaryPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{!canSubmit && (
|
{!canSubmit && (
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">{t('autofix.k1824f78d')}</p>
|
||||||
Please select coffees and fill all required buyer fields, signing city, and signature.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Right: Order summary */}
|
{/* Right: Order summary */}
|
||||||
<section className="lg:col-span-1">
|
<section className="lg:col-span-1">
|
||||||
<h2 className="text-xl font-semibold mb-4">2. Your selection</h2>
|
<h2 className="text-xl font-semibold mb-4">{t('autofix.k4aeb8688')}</h2>
|
||||||
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg lg:sticky lg:top-6">
|
<div className="rounded-xl border border-[#1C2B4A]/20 bg-white/80 backdrop-blur-sm p-6 shadow-lg lg:sticky lg:top-6">
|
||||||
{selectedEntries.map(entry => (
|
{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 key={entry.coffee.id} className="flex justify-between text-sm border-b last:border-b-0 pb-2 last:pb-0">
|
||||||
@ -965,13 +940,11 @@ export default function SummaryPage() {
|
|||||||
<span className="text-sm font-medium">€{taxAmountWithShipping.toFixed(2)}</span>
|
<span className="text-sm font-medium">€{taxAmountWithShipping.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-semibold">Total incl. tax</span>
|
<span className="text-sm font-semibold">{t('autofix.k11438b4c')}</span>
|
||||||
<span className="text-xl font-extrabold text-[#1C2B4A]">€{totalWithTax.toFixed(2)}</span>
|
<span className="text-xl font-extrabold text-[#1C2B4A]">€{totalWithTax.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
{isReverseCharge && (
|
{isReverseCharge && (
|
||||||
<div className="mt-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">
|
<div className="mt-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">{t('autofix.k74491338')}</div>
|
||||||
Reverse Charge aktiv: gueltige UID und auslaendisches Rechnungsland erkannt.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{/* Validation summary (refined design) */}
|
{/* Validation summary (refined design) */}
|
||||||
<div className="mt-2 text-xs text-gray-700">
|
<div className="mt-2 text-xs text-gray-700">
|
||||||
@ -1003,15 +976,11 @@ export default function SummaryPage() {
|
|||||||
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold">Thanks for your subscription!</h3>
|
<h3 className="text-2xl font-bold">{t('autofix.k0853cfa6')}</h3>
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-1 text-sm text-gray-600">{t('autofix.kd3092148')}</p>
|
||||||
Subscription created.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90">
|
<button onClick={() => { setShowThanks(false); backToSelection(); }} className="rounded-lg bg-[#1C2B4A] text-white px-4 py-2 font-semibold hover:bg-[#1C2B4A]/90">{t('autofix.k96839795')}</button>
|
||||||
Back to selection
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setShowThanks(false)} className="rounded-lg border border-gray-300 px-4 py-2 font-semibold hover:bg-gray-50">
|
<button onClick={() => setShowThanks(false)} className="rounded-lg border border-gray-300 px-4 py-2 font-semibold hover:bg-gray-50">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -4,28 +4,25 @@ 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';
|
||||||
|
|
||||||
// Built-in language info (code → name + flag emoji)
|
interface LangEntry { code: string; name: string; flag: string }
|
||||||
const BUILTIN_LANG_INFO: Record<string, { name: string; flag: string }> = {
|
|
||||||
|
const FALLBACK_LANG_INFO: Record<string, { name: string; flag: string }> = {
|
||||||
en: { name: 'English', flag: '🇬🇧' },
|
en: { name: 'English', flag: '🇬🇧' },
|
||||||
de: { name: 'Deutsch', flag: '🇩🇪' },
|
de: { name: 'Deutsch', flag: '🇩🇪' },
|
||||||
};
|
};
|
||||||
|
|
||||||
interface LangEntry { code: string; name: string; flag: string }
|
|
||||||
|
|
||||||
interface LanguageSwitcherProps {
|
interface LanguageSwitcherProps {
|
||||||
variant?: 'light' | 'dark';
|
variant?: 'light' | 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcherProps) {
|
export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcherProps) {
|
||||||
const { language, setLanguage, customI18n } = useTranslation();
|
const { language, setLanguage, languages } = useTranslation();
|
||||||
|
|
||||||
// Combine built-in + custom languages (deduplicated by code)
|
const allLangs: LangEntry[] = languages.map((lang) => ({
|
||||||
const allLangs: LangEntry[] = [
|
code: lang.code,
|
||||||
...Object.entries(BUILTIN_LANG_INFO).map(([code, info]) => ({ code, ...info })),
|
name: lang.name,
|
||||||
...customI18n.languages
|
flag: FALLBACK_LANG_INFO[lang.code]?.flag ?? '🏳️',
|
||||||
.filter((l) => !BUILTIN_LANG_INFO[l.code])
|
}));
|
||||||
.map((l) => ({ code: l.code, name: l.name, flag: l.flag ?? '🏳️' })),
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeLang: LangEntry =
|
const activeLang: LangEntry =
|
||||||
allLangs.find((l) => l.code === language) ?? { code: language, name: language, flag: '🏳️' };
|
allLangs.find((l) => l.code === language) ?? { code: language, name: language, flag: '🏳️' };
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import Header from './nav/Header';
|
|||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
import PageTransitionEffect from './animation/pageTransitionEffect';
|
import PageTransitionEffect from './animation/pageTransitionEffect';
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
|
||||||
// Utility to detect mobile devices
|
// Utility to detect mobile devices
|
||||||
function isMobileDevice() {
|
function isMobileDevice() {
|
||||||
if (typeof navigator === 'undefined') return false;
|
if (typeof navigator === 'undefined') return false;
|
||||||
@ -27,6 +29,7 @@ export default function PageLayout({
|
|||||||
className = 'bg-white text-gray-900',
|
className = 'bg-white text-gray-900',
|
||||||
contentClassName = 'flex-1 relative z-10 w-full',
|
contentClassName = 'flex-1 relative z-10 w-full',
|
||||||
}: PageLayoutProps) {
|
}: PageLayoutProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const isMobile = isMobileDevice();
|
const isMobile = isMobileDevice();
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false); // NEW
|
const [isLoggingOut, setIsLoggingOut] = useState(false); // NEW
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@ -72,7 +75,7 @@ export default function PageLayout({
|
|||||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
<div className="flex flex-col items-center gap-3 text-white">
|
<div className="flex flex-col items-center gap-3 text-white">
|
||||||
<div className="h-10 w-10 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
<div className="h-10 w-10 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
||||||
<p className="text-sm font-medium">Logging you out...</p>
|
<p className="text-sm font-medium">{t('autofix.kb1c1c0e5')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
import { Dialog, Transition } from '@headlessui/react'
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
import {
|
import {
|
||||||
@ -44,6 +47,7 @@ export default function TutorialModal({
|
|||||||
onNext,
|
onNext,
|
||||||
onPrevious
|
onPrevious
|
||||||
}: TutorialModalProps) {
|
}: TutorialModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const step = steps[currentStep - 1]
|
const step = steps[currentStep - 1]
|
||||||
|
|
||||||
if (!step) return null
|
if (!step) return null
|
||||||
@ -194,17 +198,13 @@ export default function TutorialModal({
|
|||||||
? 'text-slate-50 cursor-default'
|
? 'text-slate-50 cursor-default'
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>{t('autofix.kccc13f16')}</button>
|
||||||
← Go back
|
|
||||||
</button>
|
|
||||||
{!isLastStep && (
|
{!isLastStep && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
className="text-xs font-semibold text-blue-600 hover:text-blue-700 transition-colors"
|
className="text-xs font-semibold text-blue-600 hover:text-blue-700 transition-colors"
|
||||||
>
|
>{t('autofix.ka3cbb536')}</button>
|
||||||
Continue →
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -214,7 +214,7 @@ export default function TutorialModal({
|
|||||||
<div className="relative hidden lg:flex lg:flex-1 mt-4 lg:mt-0 h-32 lg:h-full lg:min-h-[150px] items-end justify-end">
|
<div className="relative hidden lg:flex lg:flex-1 mt-4 lg:mt-0 h-32 lg:h-full lg:min-h-[150px] items-end justify-end">
|
||||||
{/* <img
|
{/* <img
|
||||||
src="/images/misc/cow.png"
|
src="/images/misc/cow.png"
|
||||||
alt="Profit Planet Mascot"
|
alt={t('autofix.kcc1c5596')}
|
||||||
className="max-h-full max-w-full object-contain opacity-90 pl-30"
|
className="max-h-full max-w-full object-contain opacity-90 pl-30"
|
||||||
/> */}
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -467,21 +467,15 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{missingIdOrContract && (
|
{missingIdOrContract && (
|
||||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 mb-4">
|
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 mb-4">{t('autofix.k6b0f4f70')}</div>
|
||||||
ID documents or a signed contract are missing for this user. The user’s verification status should be checked.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{storageMissing && (
|
{storageMissing && (
|
||||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 mb-4">
|
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 mb-4">{t('autofix.kd4d50566')}</div>
|
||||||
ID documents or a signed contract are missing from object storage. The user’s verification status should be checked.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{missingIdOrContract && (
|
{missingIdOrContract && (
|
||||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 mb-4">
|
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 mb-4">{t('autofix.k6b0f4f70')}</div>
|
||||||
ID documents or a signed contract are missing for this user. The user’s verification status should be checked.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { Fragment, useState, useEffect } from 'react'
|
import { Fragment, useState, useEffect } from 'react'
|
||||||
import { Dialog, Transition } from '@headlessui/react'
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
import {
|
import {
|
||||||
@ -30,6 +33,7 @@ interface UserDetailModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
|
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
|
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@ -259,16 +263,14 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
User Details
|
User Details
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">
|
||||||
<PencilSquareIcon className="h-3 w-3" />
|
<PencilSquareIcon className="h-3 w-3" />{t('autofix.k73d110fa')}</span>
|
||||||
Edit Mode
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="h-8 w-8 rounded-full border-2 border-blue-500 border-b-transparent animate-spin" />
|
<div className="h-8 w-8 rounded-full border-2 border-blue-500 border-b-transparent animate-spin" />
|
||||||
<span className="ml-3 text-gray-600">Loading user details...</span>
|
<span className="ml-3 text-gray-600">{t('autofix.k4a9e1ebe')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -284,28 +286,28 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<UserIcon className="h-5 w-5 text-gray-600" />
|
<UserIcon className="h-5 w-5 text-gray-600" />
|
||||||
<h4 className="text-sm font-medium text-gray-900">Basic Information</h4>
|
<h4 className="text-sm font-medium text-gray-900">{t('autofix.k1d178b73')}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Email:</span>
|
<span className="font-medium text-gray-700">{t('autofix.k8bb1c673')}</span>
|
||||||
<span className="ml-2 text-gray-600">{userDetails.user.email}</span>
|
<span className="ml-2 text-gray-600">{userDetails.user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Type:</span>
|
<span className="font-medium text-gray-700">{t('autofix.k0d8cb427')}</span>
|
||||||
<span className="ml-2 text-gray-600 capitalize">{userDetails.user.user_type}</span>
|
<span className="ml-2 text-gray-600 capitalize">{userDetails.user.user_type}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Role:</span>
|
<span className="font-medium text-gray-700">{t('autofix.k0dba4c6b')}</span>
|
||||||
<span className="ml-2 text-gray-600 capitalize">{userDetails.user.role}</span>
|
<span className="ml-2 text-gray-600 capitalize">{userDetails.user.role}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Created:</span>
|
<span className="font-medium text-gray-700">{t('autofix.kf971ea7f')}</span>
|
||||||
<span className="ml-2 text-gray-600">{formatDate(userDetails.user.created_at)}</span>
|
<span className="ml-2 text-gray-600">{formatDate(userDetails.user.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
{userDetails.user.last_login_at && (
|
{userDetails.user.last_login_at && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Last Login:</span>
|
<span className="font-medium text-gray-700">{t('autofix.kfb37e056')}</span>
|
||||||
<span className="ml-2 text-gray-600">{formatDate(userDetails.user.last_login_at)}</span>
|
<span className="ml-2 text-gray-600">{formatDate(userDetails.user.last_login_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -317,7 +319,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
<div className="bg-blue-50 rounded-lg p-4">
|
<div className="bg-blue-50 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<ShieldCheckIcon className="h-5 w-5 text-blue-600" />
|
<ShieldCheckIcon className="h-5 w-5 text-blue-600" />
|
||||||
<h4 className="text-sm font-medium text-gray-900">Verification Status</h4>
|
<h4 className="text-sm font-medium text-gray-900">{t('autofix.k4e532c48')}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -337,7 +339,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
<StatusBadge status={userDetails.userStatus.contract_signed === 1} />
|
<StatusBadge status={userDetails.userStatus.contract_signed === 1} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-700">Admin Verified</span>
|
<span className="text-sm text-gray-700">{t('autofix.k022df6ac')}</span>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={userDetails.userStatus.is_admin_verified === 1}
|
status={userDetails.userStatus.is_admin_verified === 1}
|
||||||
verified={userDetails.userStatus.is_admin_verified === 1}
|
verified={userDetails.userStatus.is_admin_verified === 1}
|
||||||
@ -356,7 +358,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
) : (
|
) : (
|
||||||
<BuildingOfficeIcon className="h-5 w-5 text-green-600" />
|
<BuildingOfficeIcon className="h-5 w-5 text-green-600" />
|
||||||
)}
|
)}
|
||||||
<h4 className="text-sm font-medium text-gray-900">Profile Information</h4>
|
<h4 className="text-sm font-medium text-gray-900">{t('autofix.k228929e2')}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditing && editedProfile ? (
|
{isEditing && editedProfile ? (
|
||||||
@ -365,7 +367,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{userDetails.personalProfile && (
|
{userDetails.personalProfile && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-medium text-gray-700 mb-1">First Name</label>
|
<label className="block font-medium text-gray-700 mb-1">{t('autofix.kfe8083f8')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedProfile.first_name || ''}
|
value={editedProfile.first_name || ''}
|
||||||
@ -374,7 +376,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-medium text-gray-700 mb-1">Last Name</label>
|
<label className="block font-medium text-gray-700 mb-1">{t('autofix.k6a4108c8')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedProfile.last_name || ''}
|
value={editedProfile.last_name || ''}
|
||||||
@ -392,7 +394,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-medium text-gray-700 mb-1">Date of Birth</label>
|
<label className="block font-medium text-gray-700 mb-1">{t('autofix.kbc368b5d')}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={editedProfile.date_of_birth || ''}
|
value={editedProfile.date_of_birth || ''}
|
||||||
@ -419,7 +421,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-medium text-gray-700 mb-1">Postal Code</label>
|
<label className="block font-medium text-gray-700 mb-1">{t('autofix.kc9d9d15d')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedProfile.zip_code || ''}
|
value={editedProfile.zip_code || ''}
|
||||||
@ -442,7 +444,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{userDetails.companyProfile && (
|
{userDetails.companyProfile && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-medium text-gray-700 mb-1">Company Name</label>
|
<label className="block font-medium text-gray-700 mb-1">{t('autofix.k33918465')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedProfile.company_name || ''}
|
value={editedProfile.company_name || ''}
|
||||||
@ -451,7 +453,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-medium text-gray-700 mb-1">Tax ID</label>
|
<label className="block font-medium text-gray-700 mb-1">{t('autofix.kbfa5b4c5')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedProfile.tax_id || ''}
|
value={editedProfile.tax_id || ''}
|
||||||
@ -460,7 +462,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-medium text-gray-700 mb-1">Registration Number</label>
|
<label className="block font-medium text-gray-700 mb-1">{t('autofix.kc0d718d7')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedProfile.registration_number || ''}
|
value={editedProfile.registration_number || ''}
|
||||||
@ -496,7 +498,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-medium text-gray-700 mb-1">Postal Code</label>
|
<label className="block font-medium text-gray-700 mb-1">{t('autofix.kc9d9d15d')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedProfile.zip_code || ''}
|
value={editedProfile.zip_code || ''}
|
||||||
@ -522,26 +524,26 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{userDetails.personalProfile && (
|
{userDetails.personalProfile && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Name:</span>
|
<span className="font-medium text-gray-700">{t('autofix.k0cdde8f8')}</span>
|
||||||
<span className="ml-2 text-gray-600">
|
<span className="ml-2 text-gray-600">
|
||||||
{userDetails.personalProfile.first_name} {userDetails.personalProfile.last_name}
|
{userDetails.personalProfile.first_name} {userDetails.personalProfile.last_name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{userDetails.personalProfile.phone && (
|
{userDetails.personalProfile.phone && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Phone:</span>
|
<span className="font-medium text-gray-700">{t('autofix.kca04f5e3')}</span>
|
||||||
<span className="ml-2 text-gray-600">{userDetails.personalProfile.phone}</span>
|
<span className="ml-2 text-gray-600">{userDetails.personalProfile.phone}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{userDetails.personalProfile.date_of_birth && (
|
{userDetails.personalProfile.date_of_birth && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Date of Birth:</span>
|
<span className="font-medium text-gray-700">{t('autofix.k4307f6c7')}</span>
|
||||||
<span className="ml-2 text-gray-600">{formatDate(userDetails.personalProfile.date_of_birth)}</span>
|
<span className="ml-2 text-gray-600">{formatDate(userDetails.personalProfile.date_of_birth)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{userDetails.personalProfile.address && (
|
{userDetails.personalProfile.address && (
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<span className="font-medium text-gray-700">Address:</span>
|
<span className="font-medium text-gray-700">{t('autofix.k71d565c9')}</span>
|
||||||
<span className="ml-2 text-gray-600">
|
<span className="ml-2 text-gray-600">
|
||||||
{userDetails.personalProfile.address}, {userDetails.personalProfile.zip_code} {userDetails.personalProfile.city}, {userDetails.personalProfile.country}
|
{userDetails.personalProfile.address}, {userDetails.personalProfile.zip_code} {userDetails.personalProfile.city}, {userDetails.personalProfile.country}
|
||||||
</span>
|
</span>
|
||||||
@ -553,30 +555,30 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{userDetails.companyProfile && (
|
{userDetails.companyProfile && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Company Name:</span>
|
<span className="font-medium text-gray-700">{t('autofix.ka5c2113f')}</span>
|
||||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.company_name}</span>
|
<span className="ml-2 text-gray-600">{userDetails.companyProfile.company_name}</span>
|
||||||
</div>
|
</div>
|
||||||
{userDetails.companyProfile.tax_id && (
|
{userDetails.companyProfile.tax_id && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Tax ID:</span>
|
<span className="font-medium text-gray-700">{t('autofix.kb45c4d5f')}</span>
|
||||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.tax_id}</span>
|
<span className="ml-2 text-gray-600">{userDetails.companyProfile.tax_id}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{userDetails.companyProfile.registration_number && (
|
{userDetails.companyProfile.registration_number && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Registration Number:</span>
|
<span className="font-medium text-gray-700">{t('autofix.kdbba338d')}</span>
|
||||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.registration_number}</span>
|
<span className="ml-2 text-gray-600">{userDetails.companyProfile.registration_number}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{userDetails.companyProfile.phone && (
|
{userDetails.companyProfile.phone && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-700">Phone:</span>
|
<span className="font-medium text-gray-700">{t('autofix.kca04f5e3')}</span>
|
||||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.phone}</span>
|
<span className="ml-2 text-gray-600">{userDetails.companyProfile.phone}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{userDetails.companyProfile.address && (
|
{userDetails.companyProfile.address && (
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<span className="font-medium text-gray-700">Address:</span>
|
<span className="font-medium text-gray-700">{t('autofix.k71d565c9')}</span>
|
||||||
<span className="ml-2 text-gray-600">
|
<span className="ml-2 text-gray-600">
|
||||||
{userDetails.companyProfile.address}, {userDetails.companyProfile.zip_code} {userDetails.companyProfile.city}, {userDetails.companyProfile.country}
|
{userDetails.companyProfile.address}, {userDetails.companyProfile.zip_code} {userDetails.companyProfile.city}, {userDetails.companyProfile.country}
|
||||||
</span>
|
</span>
|
||||||
@ -600,7 +602,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{/* Regular Documents */}
|
{/* Regular Documents */}
|
||||||
{userDetails.documents.length > 0 && (
|
{userDetails.documents.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h5 className="text-xs font-medium text-gray-700 mb-2">Uploaded Documents</h5>
|
<h5 className="text-xs font-medium text-gray-700 mb-2">{t('autofix.k93b6dc1b')}</h5>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{userDetails.documents.map((doc) => (
|
{userDetails.documents.map((doc) => (
|
||||||
<div key={doc.id} className="flex items-center justify-between bg-white p-2 rounded border">
|
<div key={doc.id} className="flex items-center justify-between bg-white p-2 rounded border">
|
||||||
@ -636,7 +638,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{/* ID Documents */}
|
{/* ID Documents */}
|
||||||
{userDetails.idDocuments.length > 0 && (
|
{userDetails.idDocuments.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h5 className="text-xs font-medium text-gray-700 mb-2">ID Documents</h5>
|
<h5 className="text-xs font-medium text-gray-700 mb-2">{t('autofix.k63115bb4')}</h5>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{userDetails.idDocuments.map((idDoc) => (
|
{userDetails.idDocuments.map((idDoc) => (
|
||||||
<div key={idDoc.id} className="bg-white p-3 rounded border">
|
<div key={idDoc.id} className="bg-white p-3 rounded border">
|
||||||
@ -648,20 +650,20 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{idDoc.frontUrl && (
|
{idDoc.frontUrl && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-700 mb-1">Front:</p>
|
<p className="text-xs text-gray-700 mb-1">{t('autofix.k972cee5e')}</p>
|
||||||
<img
|
<img
|
||||||
src={idDoc.frontUrl}
|
src={idDoc.frontUrl}
|
||||||
alt="ID Front"
|
alt={t('autofix.k4a055849')}
|
||||||
className="max-w-full h-32 object-contain border rounded"
|
className="max-w-full h-32 object-contain border rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{idDoc.backUrl && (
|
{idDoc.backUrl && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-700 mb-1">Back:</p>
|
<p className="text-xs text-gray-700 mb-1">{t('autofix.k0c95a1b4')}</p>
|
||||||
<img
|
<img
|
||||||
src={idDoc.backUrl}
|
src={idDoc.backUrl}
|
||||||
alt="ID Back"
|
alt={t('autofix.kbc6a6543')}
|
||||||
className="max-w-full h-32 object-contain border rounded"
|
className="max-w-full h-32 object-contain border rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -765,9 +767,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckCircleIcon className="h-4 w-4" />
|
<CheckCircleIcon className="h-4 w-4" />{t('autofix.k5a489751')}</>
|
||||||
Save Changes
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -796,9 +796,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<PencilSquareIcon className="h-4 w-4" />
|
<PencilSquareIcon className="h-4 w-4" />{t('autofix.k70972912')}</button>
|
||||||
Edit Profile
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{userDetails?.userStatus && (
|
{userDetails?.userStatus && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
@ -7,6 +10,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
|
const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const DELAY_MS = 200;
|
const DELAY_MS = 200;
|
||||||
const EXIT_DURATION = 0.7; // slow the fade/slide-out a bit
|
const EXIT_DURATION = 0.7; // slow the fade/slide-out a bit
|
||||||
@ -82,7 +86,7 @@ const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
|
|||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<Image
|
<Image
|
||||||
src="/images/logos/pp_logo_gold_transparent.png"
|
src="/images/logos/pp_logo_gold_transparent.png"
|
||||||
alt="Profit Planet"
|
alt={t('autofix.k788633d1')}
|
||||||
width={160}
|
width={160}
|
||||||
height={160}
|
height={160}
|
||||||
className="w-32 h-32 object-contain"
|
className="w-32 h-32 object-contain"
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
@ -26,6 +29,7 @@ interface QuickAction {
|
|||||||
// UserStatus interface is now imported from useUserStatus hook
|
// UserStatus interface is now imported from useUserStatus hook
|
||||||
|
|
||||||
export default function QuickActions() {
|
export default function QuickActions() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { userStatus, loading, error, refreshStatus } = useUserStatus()
|
const { userStatus, loading, error, refreshStatus } = useUserStatus()
|
||||||
const [showEmailVerification, setShowEmailVerification] = useState(false)
|
const [showEmailVerification, setShowEmailVerification] = useState(false)
|
||||||
const [showDocumentUpload, setShowDocumentUpload] = useState(false)
|
const [showDocumentUpload, setShowDocumentUpload] = useState(false)
|
||||||
@ -54,7 +58,7 @@ export default function QuickActions() {
|
|||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<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-gray-900">Account Setup</h2>
|
<h2 className="text-xl font-semibold text-gray-900">{t('autofix.kbfd13a03')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
@ -178,12 +182,12 @@ export default function QuickActions() {
|
|||||||
if (error && !userStatus) {
|
if (error && !userStatus) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Account Setup</h2>
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('autofix.kbfd13a03')}</h2>
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
|
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-red-800">Error loading account status</h3>
|
<h3 className="text-sm font-medium text-red-800">{t('autofix.k3b03502e')}</h3>
|
||||||
<div className="mt-2 text-sm text-red-700">
|
<div className="mt-2 text-sm text-red-700">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -192,9 +196,7 @@ export default function QuickActions() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={refreshStatus}
|
onClick={refreshStatus}
|
||||||
className="bg-red-100 px-2 py-1 text-xs font-semibold text-red-800 hover:bg-red-200 rounded"
|
className="bg-red-100 px-2 py-1 text-xs font-semibold text-red-800 hover:bg-red-200 rounded"
|
||||||
>
|
>{t('autofix.k3b7dd87a')}</button>
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -207,12 +209,10 @@ export default function QuickActions() {
|
|||||||
<>
|
<>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<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-gray-900">Account Setup</h2>
|
<h2 className="text-xl font-semibold text-gray-900">{t('autofix.kbfd13a03')}</h2>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center text-sm text-gray-500">
|
<div className="flex items-center text-sm text-gray-500">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#8D6B1D] mr-2"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#8D6B1D] mr-2"></div>{t('autofix.kf2d8db2b')}</div>
|
||||||
Updating status...
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
@ -402,7 +402,7 @@ function EmailVerificationModal({ onClose, onSuccess }: { onClose: () => void, o
|
|||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Email Verification</h3>
|
<h3 className="text-lg font-semibold text-gray-900">{t('autofix.k3c3e6850')}</h3>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
<XCircleIcon className="h-6 w-6" />
|
<XCircleIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@ -410,9 +410,7 @@ function EmailVerificationModal({ onClose, onSuccess }: { onClose: () => void, o
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
<p className="text-sm text-gray-600 mb-4">{t('autofix.k20ab2fc7')}</p>
|
||||||
We'll send a verification code to your email address.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={sendVerificationEmail}
|
onClick={sendVerificationEmail}
|
||||||
@ -424,16 +422,14 @@ function EmailVerificationModal({ onClose, onSuccess }: { onClose: () => void, o
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="code" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="code" className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.kace2fe51')}</label>
|
||||||
Verification Code
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="code"
|
id="code"
|
||||||
value={verificationCode}
|
value={verificationCode}
|
||||||
onChange={(e) => setVerificationCode(e.target.value)}
|
onChange={(e) => setVerificationCode(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
placeholder="Enter 6-digit code"
|
placeholder={t('autofix.k8eb2524c')}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -568,19 +564,19 @@ function DocumentUploadModal({ userType, onClose, onSuccess }: {
|
|||||||
onChange={(e) => setIdType(e.target.value)}
|
onChange={(e) => setIdType(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="">Select Document Type</option>
|
<option value="">{t('autofix.kb846955a')}</option>
|
||||||
{userType === 'company' ? (
|
{userType === 'company' ? (
|
||||||
<>
|
<>
|
||||||
<option value="business_registration">Business Registration</option>
|
<option value="business_registration">{t('autofix.ke17859b2')}</option>
|
||||||
<option value="tax_certificate">Tax Certificate</option>
|
<option value="tax_certificate">{t('autofix.k97abed7d')}</option>
|
||||||
<option value="business_license">Business License</option>
|
<option value="business_license">{t('autofix.kbe9355f8')}</option>
|
||||||
<option value="other">Other</option>
|
<option value="other">Other</option>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<option value="passport">Passport</option>
|
<option value="passport">Passport</option>
|
||||||
<option value="driver_license">Driver's License</option>
|
<option value="driver_license">{t('autofix.k5d85b354')}</option>
|
||||||
<option value="national_id">National ID</option>
|
<option value="national_id">{t('autofix.k8eab7c16')}</option>
|
||||||
<option value="other">Other</option>
|
<option value="other">Other</option>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -601,8 +597,7 @@ function DocumentUploadModal({ userType, onClose, onSuccess }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('autofix.k1ddc749e')}<span className="text-red-500">*</span>
|
||||||
Expiry Date <span className="text-red-500">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@ -685,7 +680,7 @@ function ProfileCompletionModal({ userType, onClose, onSuccess }: {
|
|||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Complete Profile</h3>
|
<h3 className="text-lg font-semibold text-gray-900">{t('autofix.k91f24187')}</h3>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
<XCircleIcon className="h-6 w-6" />
|
<XCircleIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@ -695,7 +690,7 @@ function ProfileCompletionModal({ userType, onClose, onSuccess }: {
|
|||||||
{userType === 'company' ? (
|
{userType === 'company' ? (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Company Name</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.k33918465')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.companyName || ''}
|
value={formData.companyName || ''}
|
||||||
@ -716,7 +711,7 @@ function ProfileCompletionModal({ userType, onClose, onSuccess }: {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Phone Number</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.k2a2fe15a')}</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={formData.phone || ''}
|
value={formData.phone || ''}
|
||||||
@ -812,7 +807,7 @@ function ContractSigningModal({ userType, onClose, onSuccess }: {
|
|||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Sign Contract</h3>
|
<h3 className="text-lg font-semibold text-gray-900">{t('autofix.kbd8b3364')}</h3>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
<XCircleIcon className="h-6 w-6" />
|
<XCircleIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@ -820,13 +815,9 @@ function ContractSigningModal({ userType, onClose, onSuccess }: {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
<p className="text-sm text-gray-600 mb-4">{t('autofix.k9860434f')}</p>
|
||||||
Please review and upload your signed service agreement.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('autofix.k93f03bca')}</label>
|
||||||
Signed Contract Document
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,.pdf"
|
accept="image/*,.pdf"
|
||||||
@ -843,9 +834,7 @@ function ContractSigningModal({ userType, onClose, onSuccess }: {
|
|||||||
onChange={(e) => setAgreed(e.target.checked)}
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
className="mt-1 h-4 w-4 text-[#8D6B1D] focus:ring-[#8D6B1D] border-gray-300 rounded"
|
className="mt-1 h-4 w-4 text-[#8D6B1D] focus:ring-[#8D6B1D] border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="agreement" className="ml-2 text-sm text-gray-600">
|
<label htmlFor="agreement" className="ml-2 text-sm text-gray-600">{t('autofix.k98519a5e')}</label>
|
||||||
I have read, understood, and agree to the terms and conditions of this service agreement.
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ConfirmActionModal from "../modals/ConfirmActionModal";
|
import ConfirmActionModal from "../modals/ConfirmActionModal";
|
||||||
|
|
||||||
@ -15,7 +18,7 @@ type DeleteConfirmationModalProps = {
|
|||||||
|
|
||||||
export default function DeleteConfirmationModal({
|
export default function DeleteConfirmationModal({
|
||||||
open,
|
open,
|
||||||
title = "Delete Item",
|
title,
|
||||||
description = "Are you sure you want to delete this item? This action cannot be undone.",
|
description = "Are you sure you want to delete this item? This action cannot be undone.",
|
||||||
confirmText = "Delete",
|
confirmText = "Delete",
|
||||||
cancelText = "Cancel",
|
cancelText = "Cancel",
|
||||||
@ -24,12 +27,15 @@ export default function DeleteConfirmationModal({
|
|||||||
onCancel,
|
onCancel,
|
||||||
children,
|
children,
|
||||||
}: DeleteConfirmationModalProps) {
|
}: DeleteConfirmationModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedTitle = title ?? t('autofix.k74914369');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmActionModal
|
<ConfirmActionModal
|
||||||
open={open}
|
open={open}
|
||||||
pending={loading}
|
pending={loading}
|
||||||
intent="danger"
|
intent="danger"
|
||||||
title={title}
|
title={resolvedTitle}
|
||||||
description={description}
|
description={description}
|
||||||
confirmText={confirmText}
|
confirmText={confirmText}
|
||||||
cancelText={cancelText}
|
cancelText={cancelText}
|
||||||
|
|||||||
@ -450,7 +450,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
<span className="sr-only">ProfitPlanet</span>
|
<span className="sr-only">ProfitPlanet</span>
|
||||||
<Image
|
<Image
|
||||||
src="/images/logos/pp_logo_gold_transparent.png"
|
src="/images/logos/pp_logo_gold_transparent.png"
|
||||||
alt="ProfitPlanet Logo"
|
alt={t('autofix.k91eb415a')}
|
||||||
width={280}
|
width={280}
|
||||||
height={84}
|
height={84}
|
||||||
className="h-14 w-auto flex-shrink-0 sm:h-16 lg:h-[4.5rem]"
|
className="h-14 w-auto flex-shrink-0 sm:h-16 lg:h-[4.5rem]"
|
||||||
@ -513,9 +513,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => router.push('/personal-matrix')}
|
onClick={() => router.push('/personal-matrix')}
|
||||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
>
|
>{t('autofix.k73831c06')}</button>
|
||||||
Personal Matrix
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -524,9 +522,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => router.push('/coffee-abonnements')}
|
onClick={() => router.push('/coffee-abonnements')}
|
||||||
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||||
>
|
>{t('autofix.k4e168c01')}</button>
|
||||||
Coffee Abonnements
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Information dropdown already removed here */}
|
{/* Information dropdown already removed here */}
|
||||||
@ -612,21 +608,19 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
<span className="sr-only">ProfitPlanet</span>
|
<span className="sr-only">ProfitPlanet</span>
|
||||||
<Image
|
<Image
|
||||||
src="/images/logos/pp_logo_gold_transparent.png"
|
src="/images/logos/pp_logo_gold_transparent.png"
|
||||||
alt="ProfitPlanet Logo"
|
alt={t('autofix.k91eb415a')}
|
||||||
width={190}
|
width={190}
|
||||||
height={60}
|
height={60}
|
||||||
className="h-12 w-auto flex-shrink-0"
|
className="h-12 w-auto flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="text-xl font-bold tracking-tight text-[#D4AF37]">
|
<span className="text-xl font-bold tracking-tight text-[#D4AF37]">{t('autofix.k788633d1')}</span>
|
||||||
Profit Planet
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
className="rounded-md p-2.5 text-gray-400 hover:text-gray-100 hover:bg-white/10 transition-transform duration-300 hover:scale-110"
|
className="rounded-md p-2.5 text-gray-400 hover:text-gray-100 hover:bg-white/10 transition-transform duration-300 hover:scale-110"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Close menu</span>
|
<span className="sr-only">{t('autofix.kd4eb7ee0')}</span>
|
||||||
<XMarkIcon aria-hidden="true" className="size-6" />
|
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { Button } from './button'
|
import { Button } from './button'
|
||||||
@ -7,6 +12,7 @@ export function Pagination({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentPropsWithoutRef<'nav'>) {
|
}: React.ComponentPropsWithoutRef<'nav'>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return <nav aria-label={ariaLabel} {...props} className={clsx(className, 'flex gap-x-2')} />
|
return <nav aria-label={ariaLabel} {...props} className={clsx(className, 'flex gap-x-2')} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,7 +23,7 @@ export function PaginationPrevious({
|
|||||||
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
|
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
|
||||||
return (
|
return (
|
||||||
<span className={clsx(className, 'grow basis-0')}>
|
<span className={clsx(className, 'grow basis-0')}>
|
||||||
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label="Previous page">
|
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label={t('autofix.k5b7042c7')}>
|
||||||
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M2.75 8H13.25M2.75 8L5.25 5.5M2.75 8L5.25 10.5"
|
d="M2.75 8H13.25M2.75 8L5.25 5.5M2.75 8L5.25 10.5"
|
||||||
@ -39,7 +45,7 @@ export function PaginationNext({
|
|||||||
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
|
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
|
||||||
return (
|
return (
|
||||||
<span className={clsx(className, 'flex grow basis-0 justify-end')}>
|
<span className={clsx(className, 'flex grow basis-0 justify-end')}>
|
||||||
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label="Next page">
|
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label={t('autofix.k17581b31')}>
|
||||||
{children}
|
{children}
|
||||||
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import * as Headless from '@headlessui/react'
|
import * as Headless from '@headlessui/react'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { NavbarItem } from './navbar'
|
import { NavbarItem } from './navbar'
|
||||||
|
|
||||||
function OpenMenuIcon() {
|
function OpenMenuIcon() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
|
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
|
||||||
@ -33,7 +37,7 @@ function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open
|
|||||||
>
|
>
|
||||||
<div className="flex h-full flex-col rounded-lg bg-zinc-900 shadow-xs ring-1 ring-white/10">
|
<div className="flex h-full flex-col rounded-lg bg-zinc-900 shadow-xs ring-1 ring-white/10">
|
||||||
<div className="-mb-3 px-4 pt-3">
|
<div className="-mb-3 px-4 pt-3">
|
||||||
<Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
|
<Headless.CloseButton as={NavbarItem} aria-label={t('autofix.k91912619')}>
|
||||||
<CloseMenuIcon />
|
<CloseMenuIcon />
|
||||||
</Headless.CloseButton>
|
</Headless.CloseButton>
|
||||||
</div>
|
</div>
|
||||||
@ -64,7 +68,7 @@ export function SidebarLayout({
|
|||||||
{/* Navbar on mobile */}
|
{/* Navbar on mobile */}
|
||||||
<header className="flex items-center px-4 lg:hidden">
|
<header className="flex items-center px-4 lg:hidden">
|
||||||
<div className="py-2.5">
|
<div className="py-2.5">
|
||||||
<NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
|
<NavbarItem onClick={() => setShowSidebar(true)} aria-label={t('autofix.k6af9037b')}>
|
||||||
<OpenMenuIcon />
|
<OpenMenuIcon />
|
||||||
</NavbarItem>
|
</NavbarItem>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import * as Headless from '@headlessui/react'
|
import * as Headless from '@headlessui/react'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { NavbarItem } from './navbar'
|
import { NavbarItem } from './navbar'
|
||||||
|
|
||||||
function OpenMenuIcon() {
|
function OpenMenuIcon() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
|
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
|
||||||
@ -33,7 +37,7 @@ function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open
|
|||||||
>
|
>
|
||||||
<div className="flex h-full flex-col rounded-lg bg-zinc-900 shadow-xs ring-1 ring-white/10">
|
<div className="flex h-full flex-col rounded-lg bg-zinc-900 shadow-xs ring-1 ring-white/10">
|
||||||
<div className="-mb-3 px-4 pt-3">
|
<div className="-mb-3 px-4 pt-3">
|
||||||
<Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
|
<Headless.CloseButton as={NavbarItem} aria-label={t('autofix.k91912619')}>
|
||||||
<CloseMenuIcon />
|
<CloseMenuIcon />
|
||||||
</Headless.CloseButton>
|
</Headless.CloseButton>
|
||||||
</div>
|
</div>
|
||||||
@ -61,7 +65,7 @@ export function StackedLayout({
|
|||||||
{/* Navbar */}
|
{/* Navbar */}
|
||||||
<header className="flex items-center px-4">
|
<header className="flex items-center px-4">
|
||||||
<div className="py-2.5 lg:hidden">
|
<div className="py-2.5 lg:hidden">
|
||||||
<NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
|
<NavbarItem onClick={() => setShowSidebar(true)} aria-label={t('autofix.k6af9037b')}>
|
||||||
<OpenMenuIcon />
|
<OpenMenuIcon />
|
||||||
</NavbarItem>
|
</NavbarItem>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -45,6 +48,7 @@ let toastPortalRoot: ReturnType<typeof createRoot> | null = null
|
|||||||
let toastPortalMounted = false
|
let toastPortalMounted = false
|
||||||
|
|
||||||
function notifyToastListeners() {
|
function notifyToastListeners() {
|
||||||
|
const { t } = useTranslation();
|
||||||
for (const listener of toastListeners) {
|
for (const listener of toastListeners) {
|
||||||
listener(globalToasts)
|
listener(globalToasts)
|
||||||
}
|
}
|
||||||
@ -271,7 +275,7 @@ function ToastItem({ toast, onClose }: ToastItemProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="ml-1 mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-xs text-slate-300 hover:bg-slate-800/70 hover:text-white focus:outline-none focus:ring-2 focus:ring-slate-500"
|
className="ml-1 mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-xs text-slate-300 hover:bg-slate-800/70 hover:text-white focus:outline-none focus:ring-2 focus:ring-slate-500"
|
||||||
aria-label="Close notification"
|
aria-label={t('autofix.k77767b9e')}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import { useUserStatus } from '../hooks/useUserStatus'
|
import { useUserStatus } from '../hooks/useUserStatus'
|
||||||
|
|
||||||
export default function DebugAuthPage() {
|
export default function DebugAuthPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [debugInfo, setDebugInfo] = useState<any>({})
|
const [debugInfo, setDebugInfo] = useState<any>({})
|
||||||
const { accessToken, user, isAuthReady, refreshAuthToken, getAuthState } = useAuthStore()
|
const { accessToken, user, isAuthReady, refreshAuthToken, getAuthState } = useAuthStore()
|
||||||
const { userStatus, loading, error } = useUserStatus()
|
const { userStatus, loading, error } = useUserStatus()
|
||||||
@ -58,12 +62,12 @@ export default function DebugAuthPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 p-8">
|
<div className="min-h-screen bg-gray-100 p-8">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-8">Auth Debug Page</h1>
|
<h1 className="text-3xl font-bold mb-8">{t('autofix.kadc6abcf')}</h1>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{/* Auth Store State */}
|
{/* Auth Store State */}
|
||||||
<div className="bg-white rounded-lg p-6 shadow">
|
<div className="bg-white rounded-lg p-6 shadow">
|
||||||
<h2 className="text-xl font-semibold mb-4">Auth Store State</h2>
|
<h2 className="text-xl font-semibold mb-4">{t('autofix.ka15f5ec5')}</h2>
|
||||||
<pre className="text-xs bg-gray-100 p-4 rounded overflow-auto">
|
<pre className="text-xs bg-gray-100 p-4 rounded overflow-auto">
|
||||||
{JSON.stringify(debugInfo, null, 2)}
|
{JSON.stringify(debugInfo, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
@ -71,25 +75,21 @@ export default function DebugAuthPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleRefreshToken}
|
onClick={handleRefreshToken}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
>
|
>{t('autofix.k54c06343')}</button>
|
||||||
Refresh Token
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleTestApiCall}
|
onClick={handleTestApiCall}
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||||
>
|
>{t('autofix.k7f57b169')}</button>
|
||||||
Test API Call
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Status */}
|
{/* User Status */}
|
||||||
<div className="bg-white rounded-lg p-6 shadow">
|
<div className="bg-white rounded-lg p-6 shadow">
|
||||||
<h2 className="text-xl font-semibold mb-4">User Status Hook</h2>
|
<h2 className="text-xl font-semibold mb-4">{t('autofix.k1eedcda3')}</h2>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<p><strong>Loading:</strong> {loading ? 'Yes' : 'No'}</p>
|
<p><strong>{t('autofix.k8323a7d9')}</strong> {loading ? 'Yes' : 'No'}</p>
|
||||||
<p><strong>Error:</strong> {error || 'None'}</p>
|
<p><strong>{t('autofix.k8be14d47')}</strong> {error || 'None'}</p>
|
||||||
<p><strong>Status:</strong></p>
|
<p><strong>{t('autofix.k81c0b74b')}</strong></p>
|
||||||
<pre className="text-xs bg-gray-100 p-4 rounded overflow-auto">
|
<pre className="text-xs bg-gray-100 p-4 rounded overflow-auto">
|
||||||
{JSON.stringify(userStatus, null, 2)}
|
{JSON.stringify(userStatus, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
@ -100,9 +100,9 @@ export default function DebugAuthPage() {
|
|||||||
<div className="bg-white rounded-lg p-6 shadow md:col-span-2">
|
<div className="bg-white rounded-lg p-6 shadow md:col-span-2">
|
||||||
<h2 className="text-xl font-semibold mb-4">Environment</h2>
|
<h2 className="text-xl font-semibold mb-4">Environment</h2>
|
||||||
<div className="text-sm space-y-1">
|
<div className="text-sm space-y-1">
|
||||||
<p><strong>API Base URL:</strong> {process.env.NEXT_PUBLIC_API_BASE_URL}</p>
|
<p><strong>{t('autofix.k811fbc99')}</strong> {process.env.NEXT_PUBLIC_API_BASE_URL}</p>
|
||||||
<p><strong>Node Env:</strong> {process.env.NODE_ENV}</p>
|
<p><strong>{t('autofix.kfce271a2')}</strong> {process.env.NODE_ENV}</p>
|
||||||
<p><strong>Current URL:</strong> {typeof window !== 'undefined' ? window.location.href : 'SSR'}</p>
|
<p><strong>{t('autofix.k49f254bd')}</strong> {typeof window !== 'undefined' ? window.location.href : 'SSR'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -32,52 +32,3 @@ export function unflattenObject(flat: Record<string, string>): Record<string, an
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomLanguageEntry {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
flag?: string; // emoji flag, e.g. '🇫🇷'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomI18nData {
|
|
||||||
/** Extra languages added by admins (does not include built-in en/de) */
|
|
||||||
languages: CustomLanguageEntry[];
|
|
||||||
/** Flat translation overrides per language code (includes overrides for built-in langs too) */
|
|
||||||
translations: Record<string, Record<string, string>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'pp_i18n_custom';
|
|
||||||
|
|
||||||
export function loadCustomI18n(): CustomI18nData {
|
|
||||||
if (typeof window === 'undefined') return { languages: [], translations: {} };
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return { languages: [], translations: {} };
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return {
|
|
||||||
languages: Array.isArray(parsed.languages) ? parsed.languages : [],
|
|
||||||
translations:
|
|
||||||
parsed.translations && typeof parsed.translations === 'object'
|
|
||||||
? parsed.translations
|
|
||||||
: {},
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { languages: [], translations: {} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveCustomI18n(data: CustomI18nData): void {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve a flat translation value for a given language, with fallback to English flat map */
|
|
||||||
export function resolveKey(
|
|
||||||
key: string,
|
|
||||||
langCode: string,
|
|
||||||
customTranslations: Record<string, Record<string, string>>,
|
|
||||||
enFlat: Record<string, string>
|
|
||||||
): string {
|
|
||||||
const langOverride = customTranslations[langCode]?.[key];
|
|
||||||
if (langOverride !== undefined && langOverride !== '') return langOverride;
|
|
||||||
return enFlat[key] ?? key;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -163,6 +163,36 @@ export const de: Translations = {
|
|||||||
sessionDetectedMessage: 'Du bist bereits angemeldet. Möchtest du dich abmelden und ein neues Konto registrieren?',
|
sessionDetectedMessage: 'Du bist bereits angemeldet. Möchtest du dich abmelden und ein neues Konto registrieren?',
|
||||||
sessionContinue: 'Zum Dashboard',
|
sessionContinue: 'Zum Dashboard',
|
||||||
sessionLogout: 'Abmelden und registrieren',
|
sessionLogout: 'Abmelden und registrieren',
|
||||||
|
formTitle: 'Registrierung für Profit Planet',
|
||||||
|
guestRegistration: 'Gäste-Registrierung',
|
||||||
|
registerNow: 'Jetzt registrieren',
|
||||||
|
guestDescription: 'Als Gast registrieren, um auf dein Kaffee-Abonnement zuzugreifen.',
|
||||||
|
personalDescription: 'Erstelle dein persönliches oder Firmenkonto bei Profit Planet.',
|
||||||
|
invitedBy: 'Du wurdest eingeladen von',
|
||||||
|
tabIndividual: 'Privat',
|
||||||
|
submitCompany: 'Unternehmen registrieren',
|
||||||
|
submitGuest: 'Als Gast registrieren',
|
||||||
|
successRedirecting: 'Registrierung erfolgreich – Weiterleitung...',
|
||||||
|
guestNote: 'Du registrierst dich als Gast. Du hast nur Zugang zu deinen Kaffee-Abonnements.',
|
||||||
|
errorBothCountryCodes: 'Bitte wähle Ländervorwahlen (Dropdown) für Unternehmens- und Kontakttelefonnummern.',
|
||||||
|
successCompanyMessage: 'Du kannst dich jetzt mit deinem neuen Unternehmenskonto anmelden.',
|
||||||
|
successGuestMessage: 'Du kannst dich jetzt anmelden, um dein Kaffee-Abonnement zu sehen.',
|
||||||
|
failedTitle: 'Registrierung fehlgeschlagen',
|
||||||
|
failedMessage: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.',
|
||||||
|
networkErrorGeneric: 'Netzwerkfehler. Bitte versuche es später erneut.',
|
||||||
|
passwordRequirements: 'Passwortanforderungen:',
|
||||||
|
pwdMinLength: 'Mindestens 8 Zeichen',
|
||||||
|
pwdLowercase: 'Kleinbuchstaben (a-z)',
|
||||||
|
pwdUppercase: 'Großbuchstaben (A-Z)',
|
||||||
|
pwdDigits: 'Ziffern (0-9)',
|
||||||
|
pwdSpecial: 'Sonderzeichen (!@#$...)',
|
||||||
|
invalidLinkTitle: 'Ungültiger Einladungslink',
|
||||||
|
invalidLinkMessage: 'Dieser Registrierungslink ist ungültig oder nicht mehr aktiv. Bitte fordere einen neuen Link an.',
|
||||||
|
tokenLabel: 'Token',
|
||||||
|
sessionDescription: 'Du bist bereits angemeldet. Um dich zu registrieren, musst du dich zuerst abmelden oder zum Dashboard gehen.',
|
||||||
|
goToDashboard: 'Zum Dashboard',
|
||||||
|
goToHomepage: 'Zur Startseite',
|
||||||
|
loginHere: 'Hier anmelden',
|
||||||
},
|
},
|
||||||
|
|
||||||
passwordReset: {
|
passwordReset: {
|
||||||
@ -398,16 +428,111 @@ export const de: Translations = {
|
|||||||
referralManagement: {
|
referralManagement: {
|
||||||
title: 'Empfehlungsverwaltung',
|
title: 'Empfehlungsverwaltung',
|
||||||
subtitle: 'Verwalte deine Empfehlungslinks.',
|
subtitle: 'Verwalte deine Empfehlungslinks.',
|
||||||
|
description: 'Erstelle und verwalte deine Empfehlungslinks. Verfolge die Performance auf einen Blick.',
|
||||||
createLink: 'Empfehlungslink erstellen',
|
createLink: 'Empfehlungslink erstellen',
|
||||||
copyLink: 'Link kopieren',
|
copyLink: 'Link kopieren',
|
||||||
|
copy: 'Kopieren',
|
||||||
|
copyMobile: 'Link kopieren',
|
||||||
|
copied: 'Kopiert',
|
||||||
copiedToClipboard: 'In die Zwischenablage kopiert!',
|
copiedToClipboard: 'In die Zwischenablage kopiert!',
|
||||||
linkExpiry: 'Läuft ab',
|
linkExpiry: 'Läuft ab',
|
||||||
noLinks: 'Noch keine Empfehlungslinks.',
|
noLinks: 'Keine Empfehlungslinks gefunden.',
|
||||||
generating: 'Wird erstellt…',
|
generating: 'Wird erstellt…',
|
||||||
|
generateLink: 'Link generieren',
|
||||||
usesRemaining: 'verbleibende Nutzungen',
|
usesRemaining: 'verbleibende Nutzungen',
|
||||||
unlimited: 'Unbegrenzt',
|
unlimited: 'Unbegrenzt',
|
||||||
|
never: 'Nie',
|
||||||
createSuccess: 'Empfehlungslink erfolgreich erstellt.',
|
createSuccess: 'Empfehlungslink erfolgreich erstellt.',
|
||||||
createError: 'Empfehlungslink konnte nicht erstellt werden.',
|
createError: 'Empfehlungslink konnte nicht erstellt werden.',
|
||||||
|
deactivate: 'Deaktivieren',
|
||||||
|
deactivated: 'Link deaktiviert',
|
||||||
|
deactivatedMessage: 'Der Empfehlungslink wurde erfolgreich deaktiviert.',
|
||||||
|
deactivateFailed: 'Deaktivierung fehlgeschlagen',
|
||||||
|
deactivateFailedMessage: 'Der Empfehlungslink konnte nicht deaktiviert werden.',
|
||||||
|
deactivateNetworkError: 'Netzwerkfehler beim Deaktivieren des Empfehlungslinks.',
|
||||||
|
deactivateModalTitle: 'Empfehlungslink deaktivieren?',
|
||||||
|
deactivateModalDescription: 'Dies deaktiviert den ausgewählten Empfehlungslink sofort, sodass er nicht mehr verwendet werden kann.',
|
||||||
|
linkLabel: 'Link',
|
||||||
|
accessCheckFailed: 'Zugriffsprüfung fehlgeschlagen',
|
||||||
|
userIdMissing: 'Benutzer-ID fehlt. Weiterleitung…',
|
||||||
|
accessDenied: 'Zugriff verweigert',
|
||||||
|
accessDeniedMessage: 'Du hast keine Berechtigung für die Empfehlungsverwaltung.',
|
||||||
|
permCheckFailed: 'Berechtigungsprüfung fehlgeschlagen',
|
||||||
|
permCheckFailedMessage: 'Berechtigungen konnten nicht verifiziert werden. Weiterleitung…',
|
||||||
|
loadFailed: 'Laden fehlgeschlagen',
|
||||||
|
loadStatsError: 'Empfehlungsstatistiken konnten nicht geladen werden.',
|
||||||
|
loadLinksError: 'Empfehlungslinks konnten nicht geladen werden.',
|
||||||
|
copyFailed: 'Kopieren fehlgeschlagen',
|
||||||
|
copyFailedMessage: 'Link konnte nicht in die Zwischenablage kopiert werden.',
|
||||||
|
copiedMessage: 'Link in die Zwischenablage kopiert.',
|
||||||
|
allLinks: 'Alle Empfehlungslinks',
|
||||||
|
allLinksSubtitle: 'Verwalte deine Links und sieh deren Status.',
|
||||||
|
colLink: 'Link',
|
||||||
|
colCreated: 'Erstellt',
|
||||||
|
colExpires: 'Läuft ab',
|
||||||
|
colUsage: 'Nutzung',
|
||||||
|
colStatus: 'Status',
|
||||||
|
generateTitle: 'Empfehlungslink generieren',
|
||||||
|
maxUsesLabel: 'Max. Nutzungen',
|
||||||
|
expiresIn: 'Läuft ab in',
|
||||||
|
lockedByNeverExpires: 'Gesperrt durch „Läuft nie ab".',
|
||||||
|
lockedByUnlimited: 'Gesperrt durch „Unbegrenzte Nutzungen".',
|
||||||
|
statsActiveLinks: 'Aktive Links',
|
||||||
|
statsLinksUsed: 'Genutzte Links',
|
||||||
|
statsPersonalUsers: 'Privatnutzer',
|
||||||
|
statsCompanyUsers: 'Firmennutzer',
|
||||||
|
statsTotalLinks: 'Links gesamt',
|
||||||
|
levelStarter: 'Starter',
|
||||||
|
levelNovice: 'Novize',
|
||||||
|
levelHustler: 'Hustler',
|
||||||
|
levelEntrepreneur: 'Unternehmer',
|
||||||
|
levelPrestige: 'Prestige',
|
||||||
|
levelMax: 'MAX',
|
||||||
|
levelLabel: 'Level',
|
||||||
|
referrals: 'Empfehlungen',
|
||||||
|
of: 'von',
|
||||||
|
maxLevelReached: 'Maximales Level erreicht',
|
||||||
|
nextMilestone: 'Nächster Meilenstein',
|
||||||
|
registeredUsersTitle: 'Registrierte Nutzer über deine Empfehlung',
|
||||||
|
totalRefBadge: 'GESAMT REGISTRIERTE NUTZER MIT DEINEM REF-LINK',
|
||||||
|
registeredUsersSubtitle: 'Nutzer, die sich über einen deiner Empfehlungslinks registriert haben.',
|
||||||
|
showingLatest5: 'Zeigt die neuesten 5 Nutzer. Klicke auf „Alle anzeigen" für die vollständige Liste.',
|
||||||
|
viewAll: 'Alle anzeigen',
|
||||||
|
colUser: 'Nutzer',
|
||||||
|
colEmail: 'E-Mail',
|
||||||
|
colType: 'Typ',
|
||||||
|
colRegistered: 'Registriert',
|
||||||
|
noRegisteredUsers: 'Keine registrierten Nutzer gefunden.',
|
||||||
|
typeCompany: 'Unternehmen',
|
||||||
|
typePersonal: 'Privat',
|
||||||
|
allRegisteredUsersTitle: 'Alle registrierten Nutzer über deine Empfehlung',
|
||||||
|
allRegisteredUsersSubtitle: 'Suchen, filtern, blättern oder die vollständige Liste exportieren.',
|
||||||
|
exportCsv: 'CSV exportieren',
|
||||||
|
searchPlaceholder: 'Name oder E-Mail suchen…',
|
||||||
|
filterAllTypes: 'Alle Typen',
|
||||||
|
filterAllStatus: 'Alle Status',
|
||||||
|
filterActive: 'Aktiv',
|
||||||
|
filterInactive: 'Inaktiv',
|
||||||
|
filterPending: 'Ausstehend',
|
||||||
|
filterBlocked: 'Gesperrt',
|
||||||
|
noUsersMatchFilters: 'Keine Nutzer entsprechen deinen Filtern.',
|
||||||
|
showing: 'Zeige',
|
||||||
|
pagePrev: 'Zurück',
|
||||||
|
pageNext: 'Weiter',
|
||||||
|
pageOf: 'von',
|
||||||
|
expiry1Day: '1 Tag',
|
||||||
|
expiry2Days: '2 Tage',
|
||||||
|
expiry3Days: '3 Tage',
|
||||||
|
expiry4Days: '4 Tage',
|
||||||
|
expiry5Days: '5 Tage',
|
||||||
|
expiry6Days: '6 Tage',
|
||||||
|
expiry7Days: '7 Tage',
|
||||||
|
expiryNever: 'Läuft nie ab',
|
||||||
|
maxUses1: '1 Nutzung',
|
||||||
|
maxUses5: '5 Nutzungen',
|
||||||
|
maxUses10: '10 Nutzungen',
|
||||||
|
maxUses50: '50 Nutzungen',
|
||||||
|
maxUsesUnlimited: 'Unbegrenzt',
|
||||||
},
|
},
|
||||||
|
|
||||||
quickactionDashboard: {
|
quickactionDashboard: {
|
||||||
@ -793,6 +918,7 @@ export const de: Translations = {
|
|||||||
codeDuplicate: 'Sprache existiert bereits.',
|
codeDuplicate: 'Sprache existiert bereits.',
|
||||||
codeRequired: 'Sprachcode ist erforderlich.',
|
codeRequired: 'Sprachcode ist erforderlich.',
|
||||||
nameRequired: 'Sprachname ist erforderlich.',
|
nameRequired: 'Sprachname ist erforderlich.',
|
||||||
|
wizardInputPlaceholder: 'Übersetzung eingeben',
|
||||||
},
|
},
|
||||||
|
|
||||||
contractManagement: {
|
contractManagement: {
|
||||||
@ -926,6 +1052,646 @@ export const de: Translations = {
|
|||||||
reasonNoActivePools: 'Keine aktiven System-Pools gefunden',
|
reasonNoActivePools: 'Keine aktiven System-Pools gefunden',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
autofix: {
|
||||||
|
k02665163: 'Next steps',
|
||||||
|
k027bd82e: 'Edit the shipping prices for 60 and 120 pieces.',
|
||||||
|
k047a175d: 'No contracts found.',
|
||||||
|
k06d4487f: 'Cancel editing',
|
||||||
|
k0853cfa6: 'Thanks for your subscription!',
|
||||||
|
k096f4013: 'Manage your company stamps. One active at a time.',
|
||||||
|
k0af6c6be: 'Create & Activate',
|
||||||
|
k0affa826: 'Shown to users in the shop and checkout.',
|
||||||
|
k0b03e660: '2. Choose coffees & quantities',
|
||||||
|
k0b2445d5: 'Generating PDF preview…',
|
||||||
|
k0bbc633d: 'Loading contract preview…',
|
||||||
|
k0d9c63c5: 'Scanning workspace files and component subdirectories...',
|
||||||
|
k11438b4c: 'Total incl. tax',
|
||||||
|
k12a86c71: 'Shipping…',
|
||||||
|
k14eb468b: 'Potential untranslated UI text detected',
|
||||||
|
k155166db: 'Contract variables are auto-populated from your form data.',
|
||||||
|
k15bea9bb: 'Address details used on invoices.',
|
||||||
|
k1824f78d: 'Please select coffees and fill all required buyer fields, signing city, and signature.',
|
||||||
|
k18872b63: 'No image',
|
||||||
|
k1bf4ffa4: 'Untranslated literals',
|
||||||
|
k20127e1c: 'No selection found.',
|
||||||
|
k21361e0d: 'Summary & Details',
|
||||||
|
k221fa311: 'Invoice template variables',
|
||||||
|
k22c8f7f1: 'Create Template',
|
||||||
|
k28f1a9b1: 'Full name',
|
||||||
|
k2d0798a6: 'Loading subscription…',
|
||||||
|
k2e43a9c4: 'Click or drag and drop a new image here',
|
||||||
|
k3466b0e0: 'Payment method',
|
||||||
|
k346a2c64: 'Language Management',
|
||||||
|
k39791457: 'Manage contract templates, company stamp, and create new templates.',
|
||||||
|
k41ab9eb6: 'You\'ll be able to crop and adjust the image after uploading',
|
||||||
|
k41afd863: 'Editing:',
|
||||||
|
k4aeb8688: '2. Your selection',
|
||||||
|
k4be6f631: 'Save changes',
|
||||||
|
k516705dd: 'Ort ist erforderlich.',
|
||||||
|
k528eede9: 'Same as shipping address',
|
||||||
|
k56717603: 'no image',
|
||||||
|
k56a52520: 'Skipped files',
|
||||||
|
k5a489751: 'Save Changes',
|
||||||
|
k5ad4d864: 'Auto-fixed files',
|
||||||
|
k6070f6e3: 'Add New Stamp',
|
||||||
|
k60874ea3: 'Keys auto-created',
|
||||||
|
k67cb36a4: 'Contract Management',
|
||||||
|
k6a2c64e8: 'Last name',
|
||||||
|
k6a892262: 'No keys match your search.',
|
||||||
|
k6ee0a1b6: 'Click or drag and drop an image here',
|
||||||
|
k73d1d7d7: 'Edit Crop',
|
||||||
|
k74491338: 'Reverse Charge aktiv: gueltige UID und auslaendisches Rechnungsland erkannt.',
|
||||||
|
k7775eddb: 'Your Company Stamps',
|
||||||
|
k788633d1: 'Profit Planet',
|
||||||
|
k7a3a6ea3: 'to render invoice line items.',
|
||||||
|
k7f48f374: '1. Select subscription size',
|
||||||
|
k7fe72eff: 'No platforms available.',
|
||||||
|
k80ac9651: 'PNG, JPG, WebP up to 10MB',
|
||||||
|
k825359ab: 'Accepted types: PNG, JPEG, WebP, SVG. Max size: 5MB.',
|
||||||
|
k832387c5: 'Loading…',
|
||||||
|
k83deba83: 'per 10 pcs',
|
||||||
|
k875f4054: 'Manage all coffees.',
|
||||||
|
k8c75468c: 'No subscriptions found.',
|
||||||
|
k8cf40180: 'Missing keys in en.ts',
|
||||||
|
k90a6e795: 'Unique keys used',
|
||||||
|
k91052e3f: 'Translation calls',
|
||||||
|
k92639a9a: 'Language code',
|
||||||
|
k926966d0: 'Language name',
|
||||||
|
k96839795: 'Back to selection',
|
||||||
|
k99bffb65: 'Fill all fields to proceed.',
|
||||||
|
k9b173204: 'Files auto-fixed',
|
||||||
|
k9c1a5ecc: 'Fill fields with logged in data',
|
||||||
|
ka3ee9ded: 'Subscription Billing',
|
||||||
|
ka56b7b2b: 'No PDF preview available.',
|
||||||
|
ka5f38d19: 'Company Stamp',
|
||||||
|
ka802064d: 'Applying i18n auto-fixes to client components and updating translation files...',
|
||||||
|
kaa30f0cd: 'Create Coffee',
|
||||||
|
kaa8bbc8e: 'Company Information',
|
||||||
|
kac6cedc7: 'Saving…',
|
||||||
|
kae63e46a: 'Missing translation keys detected in workspace',
|
||||||
|
kb06fa395: 'Edit Coffee',
|
||||||
|
kb0b660e2: 'Configure Coffee Subscription',
|
||||||
|
kb1c1c0e5: 'Logging you out...',
|
||||||
|
kb2217bdf: 'Translation Coverage Scan',
|
||||||
|
kb791958e: 'Use these placeholders in your HTML: invoiceNumber, customerName, issuedAt, totalNet, totalTax, totalGross, itemsHtml.',
|
||||||
|
kb8f33873: 'Translation progress',
|
||||||
|
kb9e483c4: 'Update details of the coffee.',
|
||||||
|
kba6bd6f3: 'or click to browse',
|
||||||
|
kcc4adbcc: 'Navigation shortcuts',
|
||||||
|
kce094582: 'Invoice address',
|
||||||
|
kd1a2772d: 'Street & No.',
|
||||||
|
kd2a00802: 'Image removed - Click to upload a new one',
|
||||||
|
kd3092148: 'Subscription created.',
|
||||||
|
kd379df9b: 'Open preview',
|
||||||
|
kd63c8219: 'You have unsaved changes.',
|
||||||
|
kd6f8d7e9: '1. Your details',
|
||||||
|
kd8a5ad17: 'Back to list',
|
||||||
|
kda5f982e: 'Delete Language',
|
||||||
|
kddd4832f: 'Delete coffee?',
|
||||||
|
kde5c689e: 'Pick a platform to continue.',
|
||||||
|
ke33e6fbf: 'Send invoice by email',
|
||||||
|
ke58b7627: 'Drag and drop your stamp here',
|
||||||
|
ke74b1adf: 'Contract template is not available.',
|
||||||
|
ke7b634f2: '3. Preview',
|
||||||
|
ke7f0a9e3: 'FREE SHIPPING',
|
||||||
|
kea7cde7a: 'Back to Admin',
|
||||||
|
kec078e54: 'No coffees selected yet.',
|
||||||
|
kefe5f0dd: 'Ohne gueltige UID wird die Rechnung mit normaler MwSt erstellt.',
|
||||||
|
kf1a9384b: 'Auto-applied to documents where applicable.',
|
||||||
|
kf4e45236: 'Add Language',
|
||||||
|
kf72d41db: 'Add a new coffee.',
|
||||||
|
kfe9527d8: 'First name',
|
||||||
|
kfeac3f7e: 'Choose file',
|
||||||
|
k0c51fa85: 'Activate template now?',
|
||||||
|
k134e3932: 'Active stamp',
|
||||||
|
k1f0b2c48: 'z.B. Wien',
|
||||||
|
k2fac9ff2: 'Template name',
|
||||||
|
k3477c83a: 'Describe the product',
|
||||||
|
k35ac864e: 'Search templates…',
|
||||||
|
ka8f53660: 'Delete Company Stamp',
|
||||||
|
kaa5e5363: 'ABO Contract PDF Preview',
|
||||||
|
kcb65c692: 'e.g., Company Seal 2025',
|
||||||
|
kd9e4bcbd: 'Contract Preview',
|
||||||
|
kf1512f8f: 'z.B. SI12345678',
|
||||||
|
k00016501: '🧪 Token Refresh Test',
|
||||||
|
k002455d8: 'Total Gross / Brutto',
|
||||||
|
k00394342: 'Welcome back! Log in to continue.',
|
||||||
|
k01ad6d49: 'Overview of taxes, revenue, and invoices.',
|
||||||
|
k022df6ac: 'Admin Verified',
|
||||||
|
k039e629b: 'Overview meta',
|
||||||
|
k03cd9b72: 'Commission:',
|
||||||
|
k04b5cbca: 'Loose Files',
|
||||||
|
k051e8ac8: 'Confirm password',
|
||||||
|
k055bba0c: 'Are you sure you want to delete',
|
||||||
|
k05626798: 'Click to upload logo',
|
||||||
|
k0778fa87: 'Make sure JWT_EXPIRES_IN=2m in backend .env for fast testing',
|
||||||
|
k07fe11b2: 'Company / Holder name',
|
||||||
|
k088d8f6c: 'Delete news?',
|
||||||
|
k08c92a12: 'Welcome to Profit Planet Community 🌍',
|
||||||
|
k0925e287: 'e.g., VIP Members',
|
||||||
|
k098ec0b9: 'Manage the “Platforms” cards shown on the user dashboard.',
|
||||||
|
k09def344: 'Edit Affiliate',
|
||||||
|
k09f4290f: 'Reset password',
|
||||||
|
k0ac84efe: '← Back',
|
||||||
|
k0c838ec3: 'Min €',
|
||||||
|
k0c87d75d: 'Max €',
|
||||||
|
k0c95a1b4: 'Back:',
|
||||||
|
k0cc2a3ba: 'Versuche andere Suchbegriffe oder Filter',
|
||||||
|
k0cdde8f8: 'Name:',
|
||||||
|
k0d6626e3: '👤 User Info',
|
||||||
|
k0d8cb427: 'Type:',
|
||||||
|
k0da2c941: 'Users Pending Verification',
|
||||||
|
k0dba4c6b: 'Role:',
|
||||||
|
k0dca1445: 'Sort:',
|
||||||
|
k0dcb69ea: 'No matrices found.',
|
||||||
|
k0dd01c1c: 'Show:',
|
||||||
|
k0efd830c: 'Verification Readiness',
|
||||||
|
k0f0395ca: 'Multi-statement SQL and dump files are supported. Use with caution.',
|
||||||
|
k0f1fc266: 'All Statuses',
|
||||||
|
k0fbaa1a9: 'Jetzt registrieren',
|
||||||
|
k0fe28e0b: 'Affiliate Management',
|
||||||
|
k10ccb626: 'All Users',
|
||||||
|
k10e2568f: 'All Types',
|
||||||
|
k110bae43: 'All Roles',
|
||||||
|
k111c49d8: 'Users count respects each matrix’s max depth policy.',
|
||||||
|
k11974e0f: 'Advanced: choose parent manually',
|
||||||
|
k12a7170a: 'No ghost directories found. Run Refresh to scan again.',
|
||||||
|
k1387f81e: 'Export all filtered users to CSV',
|
||||||
|
k1405afab: 'Login and watch the countdown timer',
|
||||||
|
k14a4b43e: 'Refreshing…',
|
||||||
|
k1521a376: 'Export all users as CSV',
|
||||||
|
k15843a06: 'Active Pools',
|
||||||
|
k15da24d8: 'Join Group',
|
||||||
|
k16b60f69: 'View All',
|
||||||
|
k17ba59ff: 'Community Hands - Profit Planet',
|
||||||
|
k17f65c37: 'Example: /shop or https://example.com',
|
||||||
|
k1882bd75: 'Max Mustermann',
|
||||||
|
k199db5f1: 'your.email@example.com',
|
||||||
|
k19f2c5dc: 'No affiliates found',
|
||||||
|
k1a1ca621: 'e.g. DE89 3704 0044 0532 0130 00',
|
||||||
|
k1af107a4: 'Logo preview',
|
||||||
|
k1af97a07: 'User Management',
|
||||||
|
k1b9c46e5: 'Affiliate Partners',
|
||||||
|
k1d178b73: 'Basic Information',
|
||||||
|
k1db0c7cd: 'No loose files found. Run Refresh to scan again.',
|
||||||
|
k1ddc749e: 'Expiry Date',
|
||||||
|
k1df74994: 'All subscriptions',
|
||||||
|
k1e5d5139: 'No users match your filters.',
|
||||||
|
k1e62338a: 'Commission Rate',
|
||||||
|
k1eedcda3: 'User Status Hook',
|
||||||
|
k1f269263: 'DE89 3704 0044 0532 0130 00',
|
||||||
|
k209ba561: 'Create New Pool',
|
||||||
|
k20ab2fc7: 'We\'ll send a verification code to your email address.',
|
||||||
|
k21440f8a: 'Pool Management',
|
||||||
|
k21db276a: 'Auf Lager',
|
||||||
|
k228929e2: 'Profile Information',
|
||||||
|
k23c9f0ff: 'No results yet. Import a SQL dump to see output.',
|
||||||
|
k258c3515: '892 members',
|
||||||
|
k26ecadfd: 'My Groups',
|
||||||
|
k26fbc186: 'Access Denied',
|
||||||
|
k2786bc5f: 'Signing in...',
|
||||||
|
k27e93fd7: 'Stay informed with our latest announcements and insights',
|
||||||
|
k27f56959: 'State change will affect add/remove operations.',
|
||||||
|
k290e3aab: 'tt.mm jjjj',
|
||||||
|
k2a2fe15a: 'Phone Number',
|
||||||
|
k2a37c394: 'Brief description of the affiliate partner...',
|
||||||
|
k2af2916f: 'Your account is fully submitted. Our team will verify your account shortly.',
|
||||||
|
k2cd79a3d: 'Browse all favorites',
|
||||||
|
k2e8f3110: 'All Status',
|
||||||
|
k2f176a63: 'No news articles available yet.',
|
||||||
|
k2f4ebc32: 'Rows per page:',
|
||||||
|
k2f78fabe: 'Go to User Verification',
|
||||||
|
k31cadca6: 'Partner Name *',
|
||||||
|
k31d46514: 'Top node:',
|
||||||
|
k33918465: 'Company Name',
|
||||||
|
k354a026b: 'Subscription ID',
|
||||||
|
k35f67931: 'Changes apply from your next billing cycle.',
|
||||||
|
k3777e830: 'Our team',
|
||||||
|
k37d7b9c4: 'Checking pool inflow...',
|
||||||
|
k383672e3: 'owner@example.com',
|
||||||
|
k39437388: 'Core Pool — 1¢ per capsule per member',
|
||||||
|
k39e2c5db: 'Add Platform',
|
||||||
|
k3ac8ca10: 'Only SQL dump files are supported.',
|
||||||
|
k3b03502e: 'Error loading account status',
|
||||||
|
k3b7dd87a: 'Try again',
|
||||||
|
k3b8e0964: 'Subscription details',
|
||||||
|
k3c32c87f: 'Connect with like-minded individuals, share sustainable practices, and make a positive impact together.',
|
||||||
|
k3c3e6850: 'Email Verification',
|
||||||
|
k3d01de91: 'PROFIT PLANET',
|
||||||
|
k3d5fe74a: 'Expires At:',
|
||||||
|
k3def5ebf: 'Category *',
|
||||||
|
k3ee27b4f: 'Top-node Email',
|
||||||
|
k3f833ce6: 'e.g., Platinum Matrix',
|
||||||
|
k40f4552a: 'Level 2+',
|
||||||
|
k410ff9a9: 'Total Affiliates',
|
||||||
|
k416bfe70: 'No further status changes are available for this subscription.',
|
||||||
|
k4191cdba: 'Edit VAT',
|
||||||
|
k41f7c81d: 'Delete Account',
|
||||||
|
k4307f6c7: 'Date of Birth:',
|
||||||
|
k431328cf: 'No affiliate partners available at the moment.',
|
||||||
|
k471ba099: 'News Manager',
|
||||||
|
k47b952de: 'Has Token:',
|
||||||
|
k47bbd37e: 'Sign in to Profit Planet',
|
||||||
|
k483aa95a: '• Share authentic experiences',
|
||||||
|
k48852b8d: 'Customer Email',
|
||||||
|
k49568342: 'Manage your affiliate partners and tracking links',
|
||||||
|
k4968eb2a: 'Abonement:',
|
||||||
|
k49f254bd: 'Current URL:',
|
||||||
|
k4a055849: 'ID Front',
|
||||||
|
k4a9e1ebe: 'Loading user details...',
|
||||||
|
k4b6c7681: 'Open subscriptions',
|
||||||
|
k4c5e8e87: 'Export CSV',
|
||||||
|
k4c5ecd73: 'Export PDF',
|
||||||
|
k4cb62cff: 'Keine Produkte gefunden',
|
||||||
|
k4db68c96: 'SQL Import',
|
||||||
|
k4e0c889b: 'Not Ready',
|
||||||
|
k4e168c01: 'Coffee Abonnements',
|
||||||
|
k4e532c48: 'Verification Status',
|
||||||
|
k4e61bc77: 'Root not yet loaded.',
|
||||||
|
k4ed7f4d1: 'Time Left:',
|
||||||
|
k502a0057: 'Last 7 days',
|
||||||
|
k5122ab54: 'Request again',
|
||||||
|
k51ee3aae: 'email@example.com',
|
||||||
|
k52af8b8d: 'Quick Actions',
|
||||||
|
k533db977: 'Your new password',
|
||||||
|
k54c06343: 'Refresh Token',
|
||||||
|
k54f49724: 'No users match your search.',
|
||||||
|
k55aba973: 'Produkte gefunden',
|
||||||
|
k5614c806: 'Review and verify all users who need admin approval. Users must complete all steps before verification.',
|
||||||
|
k56435c9b: 'Verfügbarkeit',
|
||||||
|
k5738c039: 'Matrix created successfully.',
|
||||||
|
k577a012c: 'User Type',
|
||||||
|
k578dcc0b: 'PNG, JPG, WebP, SVG up to 5MB',
|
||||||
|
k58344b74: 'No direct children.',
|
||||||
|
k5834cbed: 'Loading affiliate partners...',
|
||||||
|
k58424b1d: 'Eco Warriors',
|
||||||
|
k5857ef79: 'Existing Pools',
|
||||||
|
k59422f07: 'Linked Subscription',
|
||||||
|
k5aae8706: 'New password',
|
||||||
|
k5c598bc0: 'Trending Groups',
|
||||||
|
k5d4d494e: 'Loading members...',
|
||||||
|
k5d85b354: 'Driver\'s License',
|
||||||
|
k5e580e3f: 'Filter zurücksetzen',
|
||||||
|
k5ef19112: 'Join our team',
|
||||||
|
k5f74c123: 'Last 30 days',
|
||||||
|
k5fb70267: 'Shop wird geladen...',
|
||||||
|
k5fbf1824: 'Masked names for deeper descendants.',
|
||||||
|
k61c2a732: 'Angemeldet bleiben',
|
||||||
|
k61f6cd4e: 'Token Preview:',
|
||||||
|
k6285753a: 'Back to Pool Management',
|
||||||
|
k62bc3c59: 'e.g. Berlin',
|
||||||
|
k62d12fab: 'Error loading data',
|
||||||
|
k63115bb4: 'ID Documents',
|
||||||
|
k633438a0: 'Discover our trusted partners and earn commissions through affiliate links.',
|
||||||
|
k63458f03: 'Produkte durchsuchen...',
|
||||||
|
k65b67dc3: 'Back to matrices',
|
||||||
|
k65e33378: 'Total users fetched',
|
||||||
|
k661c032b: 'You need admin privileges to access this page.',
|
||||||
|
k664072a1: 'Dev Management',
|
||||||
|
k67391c88: 'Manage system pools and members.',
|
||||||
|
k678d2b40: 'Super reduced',
|
||||||
|
k67cace8b: 'Profile Settings',
|
||||||
|
k67dd8a82: 'Contact name',
|
||||||
|
k6828cdd9: 'Affiliate URL *',
|
||||||
|
k6838438d: 'Ghost Directories',
|
||||||
|
k6a4108c8: 'Last Name',
|
||||||
|
k6a486e3e: 'Create Group',
|
||||||
|
k6aa2d843: 'Read full guidelines',
|
||||||
|
k6af9037b: 'Open navigation',
|
||||||
|
k6b0f4f70: 'ID documents or a signed contract are missing for this user. The user’s verification status should be checked.',
|
||||||
|
k6b76bd0e: 'Willkommen bei Profit Planet',
|
||||||
|
k6c6e5c0f: 'Use with caution',
|
||||||
|
k6ca85cda: 'Trending right now',
|
||||||
|
k6d85810b: 'Your password',
|
||||||
|
k6de13000: 'Zero Waste Living',
|
||||||
|
k6e4a6069: 'Import SQL dump files to run database migrations.',
|
||||||
|
k70972912: 'Edit Profile',
|
||||||
|
k70bcafbd: 'Recent Discussions',
|
||||||
|
k71d565c9: 'Address:',
|
||||||
|
k72428656: 'Highest full level:',
|
||||||
|
k73831c06: 'Personal Matrix',
|
||||||
|
k73cf4fb6: 'Edit News',
|
||||||
|
k73d110fa: 'Edit Mode',
|
||||||
|
k73d4a156: 'Check Network tab for /api/refresh requests',
|
||||||
|
k744fda01: 'My Subscriptions',
|
||||||
|
k748bf541: 'No users match current filters.',
|
||||||
|
k75078d0b: 'Add News',
|
||||||
|
k750c1eb5: 'Add User',
|
||||||
|
k7572cceb: 'Edit VAT rates',
|
||||||
|
k75cb45a7: 'This value is stored as net price.',
|
||||||
|
k75d83433: '• Help others learn and grow',
|
||||||
|
k77049179: '• Reason:',
|
||||||
|
k77444d5b: 'Exoscale directories that do not have a matching user in the database.',
|
||||||
|
k776b751c: 'Policy Max Depth',
|
||||||
|
k777299de: 'Finance Management',
|
||||||
|
k77767b9e: 'Close notification',
|
||||||
|
k77a56aae: 'News & Updates',
|
||||||
|
k77d5ecd9: 'Copy referral link',
|
||||||
|
k7938d4fd: 'Result Sets',
|
||||||
|
k79e1c459: 'Manage all users, view statistics, and handle verification.',
|
||||||
|
k7ab45054: 'All Readiness',
|
||||||
|
k7bed84a7: 'Member Since',
|
||||||
|
k7c19388f: 'e.g., 10%',
|
||||||
|
k7c740cd5: 'Ready to search. Click the Search button to fetch candidates.',
|
||||||
|
k7db4e5a9: 'Visit Affiliate Link',
|
||||||
|
k7f57b169: 'Test API Call',
|
||||||
|
k7f9568ec: 'Highest full level',
|
||||||
|
k7fa2c4af: 'Loading users...',
|
||||||
|
k811fbc99: 'API Base URL:',
|
||||||
|
k815ca9ba: 'A matrix configuration already exists for this selection.',
|
||||||
|
k8193b7a2: 'Loading loose files...',
|
||||||
|
k81a1b900: 'Loading settings…',
|
||||||
|
k81b056f2: 'See our job postings',
|
||||||
|
k81c0b74b: 'Status:',
|
||||||
|
k81c7c2f2: 'Musterstraße 1',
|
||||||
|
k8323a7d9: 'Loading:',
|
||||||
|
k832a032b: 'Search affiliates...',
|
||||||
|
k8358f1d1: 'Loading folder issues...',
|
||||||
|
k84d5cfcb: 'View overview',
|
||||||
|
k85446b89: 'Next billing',
|
||||||
|
k85682289: 'e.g. FN123456a',
|
||||||
|
k85c66f50: 'Search & Filter Pending Users',
|
||||||
|
k867f8265: 'Due Date',
|
||||||
|
k86aa4f9c: 'Current Month',
|
||||||
|
k87e4b9a2: 'Core Pool',
|
||||||
|
k883ea8c5: 'Loading ghost directories...',
|
||||||
|
k88d8bb9d: 'Passwort vergessen?',
|
||||||
|
k890ff52f: 'e.g., Coffee Equipment Co.',
|
||||||
|
k8a35cc53: 'SQL dumps run immediately and can modify production data.',
|
||||||
|
k8a59b156: 'Import SQL',
|
||||||
|
k8b71f0c7: 'Email, name, company...',
|
||||||
|
k8b89f863: 'External partner website.',
|
||||||
|
k8bb1c673: 'Email:',
|
||||||
|
k8be14d47: 'Error:',
|
||||||
|
k8c3085f4: 'Users ↓',
|
||||||
|
k8c3085f6: 'Users ↑',
|
||||||
|
k8d84b4c5: 'Add New Affiliate',
|
||||||
|
k8dda5201: 'you@example.com',
|
||||||
|
k8eaa7b3b: 'e.g. +43 1 234567',
|
||||||
|
k8eab7c16: 'National ID',
|
||||||
|
k8eb2524c: 'Enter 6-digit code',
|
||||||
|
k8f46c81e: 'e.g., 5',
|
||||||
|
k8f528877: 'Crop & Adjust Image',
|
||||||
|
k915115a9: 'Last 90 days',
|
||||||
|
k91912619: 'Close navigation',
|
||||||
|
k91e69df1: 'ProfitPlanet GmbH',
|
||||||
|
k91eb415a: 'ProfitPlanet Logo',
|
||||||
|
k91f24187: 'Complete Profile',
|
||||||
|
k9213db6e: '📋 Testing Instructions',
|
||||||
|
k93165aea: '12345 Berlin',
|
||||||
|
k93b6dc1b: 'Uploaded Documents',
|
||||||
|
k93f03bca: 'Signed Contract Document',
|
||||||
|
k941fd092: 'Last Folder Structure Action',
|
||||||
|
k955b1cbe: 'No results',
|
||||||
|
k959fb1a6: 'Remove member from pool?',
|
||||||
|
k961ba411: 'Community Guidelines',
|
||||||
|
k9683262f: 'Fill %',
|
||||||
|
k96dbbe05: 'Street & House Number',
|
||||||
|
k972cee5e: 'Front:',
|
||||||
|
k9772afa4: 'No included items were returned for this subscription.',
|
||||||
|
k97abed7d: 'Tax Certificate',
|
||||||
|
k981b1f1a: 'SQL Dump Import',
|
||||||
|
k98519a5e: 'I have read, understood, and agree to the terms and conditions of this service agreement.',
|
||||||
|
k9860434f: 'Please review and upload your signed service agreement.',
|
||||||
|
k9b3266b5: 'Pending backend wiring to MatrixController.listVacancies. This section will surface empty slots and allow reassignment.',
|
||||||
|
k9bc83f50: 'Change coffees for next month',
|
||||||
|
k9c3db145: 'Start Discussion',
|
||||||
|
k9d0c063d: 'Password saved. Redirecting to login...',
|
||||||
|
k9e609523: 'No missing folders found. Run Refresh to scan again.',
|
||||||
|
k9f29dbfb: 'Entdecke nachhaltige Produkte und verdiene dabei. Deine Plattform für bewussten Konsum und finanzielle Vorteile.',
|
||||||
|
k9f56d4ac: 'e.g. +43 676 1234567',
|
||||||
|
ka00fc5db: 'Manage your account information and preferences',
|
||||||
|
ka15f5ec5: 'Auth Store State',
|
||||||
|
ka1d0b6ff: 'No deeper descendants.',
|
||||||
|
ka29ac729: 'Saved successfully',
|
||||||
|
ka3c41ff8: 'View details →',
|
||||||
|
ka3cbb536: 'Continue →',
|
||||||
|
ka4ecb6cd: 'Search…',
|
||||||
|
ka5bf342b: 'Select…',
|
||||||
|
ka5c2113f: 'Company Name:',
|
||||||
|
ka5d50257: 'Loading VAT rates…',
|
||||||
|
ka6be28d2: 'Add user to pool',
|
||||||
|
ka7073aee: 'Profit Planet Store',
|
||||||
|
ka72e833f: 'Policy filter:',
|
||||||
|
ka8ea17b8: 'Next ›',
|
||||||
|
ka991f523: 'Loading affiliates...',
|
||||||
|
ka9d6e905: 'Total users under me',
|
||||||
|
kaa656770: 'You are not part of any matrix yet.',
|
||||||
|
kaa8231ec: 'This Year',
|
||||||
|
kab4f5159: 'Each node can hold up to 5 direct children. Depth unbounded.',
|
||||||
|
kab99811e: 'Disabled message',
|
||||||
|
kace2fe51: 'Verification Code',
|
||||||
|
kadc6abcf: 'Auth Debug Page',
|
||||||
|
kadd80fbc: 'Clear selection',
|
||||||
|
kaf787fe5: '1,284 members',
|
||||||
|
kb0031873: 'Browse all trending',
|
||||||
|
kb01addda: 'Token should automatically renew without user action',
|
||||||
|
kb1341138: 'Ensures both contract and gdpr folders exist for each user.',
|
||||||
|
kb24782ec: 'Last Login',
|
||||||
|
kb2dfe482: 'Read More',
|
||||||
|
kb324fb25: 'Total Users',
|
||||||
|
kb337d94e: 'No entries found.',
|
||||||
|
kb343460d: 'Rogue users',
|
||||||
|
kb35549bb: 'Search name or email…',
|
||||||
|
kb383a3e8: 'Included in your subscription',
|
||||||
|
kb45c4d5f: 'Tax ID:',
|
||||||
|
kb4675362: 'Import Results',
|
||||||
|
kb4aba3dc: 'No unverified users match current filters.',
|
||||||
|
kb573897d: 'Short description of the pool',
|
||||||
|
kb5e0b861: 'Inactive Pools',
|
||||||
|
kb6b367b7: 'When time left ≤ 3 minutes, auto-refresh should trigger',
|
||||||
|
kb6eacc9d: 'Select a .sql dump file using Import SQL.',
|
||||||
|
kb74d7c51: '🔧 Manual Controls',
|
||||||
|
kb7849a5a: 'Create Matrix',
|
||||||
|
kb846955a: 'Select Document Type',
|
||||||
|
kb87eb38b: 'Enter at least 3 characters and click Search.',
|
||||||
|
kb8cd2810: 'Account Status',
|
||||||
|
kb8d6f3f7: 'Shop Collection',
|
||||||
|
kbbefb159: 'Error loading users',
|
||||||
|
kbc368b5d: 'Date of Birth',
|
||||||
|
kbc6a6543: 'ID Back',
|
||||||
|
kbce9fbea: 'No platforms configured.',
|
||||||
|
kbd8b3364: 'Sign Contract',
|
||||||
|
kbd979e13: 'We are a community',
|
||||||
|
kbdb02e32: 'Keine Rechnungen gefunden.',
|
||||||
|
kbe9355f8: 'Business License',
|
||||||
|
kbf4b7789: 'You are already logged in. Redirecting...',
|
||||||
|
kbf7bde57: 'Select any subscription to view details and included items.',
|
||||||
|
kbfa5b4c5: 'Tax ID',
|
||||||
|
kbfd13a03: 'Account Setup',
|
||||||
|
kbff01823: 'Shows files directly under the user folder that are not in contract or gdpr.',
|
||||||
|
kc0d718d7: 'Registration Number',
|
||||||
|
kc0e3b03d: 'Total:',
|
||||||
|
kc3d181e2: 'Forgot password?',
|
||||||
|
kc4315932: 'Dashboard Management',
|
||||||
|
kc4d7816e: 'Levels filled',
|
||||||
|
kc7bb0c06: 'Filter by country or code',
|
||||||
|
kc7c429a6: 'Add users to matrix',
|
||||||
|
kc813a103: 'Loading available coffees…',
|
||||||
|
kc8652e34: 'You don’t have any subscriptions yet.',
|
||||||
|
kc9d9d15d: 'Postal Code',
|
||||||
|
kca04f5e3: 'Phone:',
|
||||||
|
kcada239b: 'View your active subscriptions, included items and subscription details on a dedicated page.',
|
||||||
|
kcb491706: 'Folder Structure',
|
||||||
|
kcbc17bbd: 'No users in this pool yet.',
|
||||||
|
kcc15636b: 'Coffee content can only be changed while a subscription is issued, ongoing, or paused.',
|
||||||
|
kcc1c5596: 'Profit Planet Mascot',
|
||||||
|
kccbc54c1: 'Delete Affiliate',
|
||||||
|
kccc13f16: '← Go back',
|
||||||
|
kccde6d86: 'User Verification Center',
|
||||||
|
kccf7593a: '• Be respectful and kind',
|
||||||
|
kcd7a1625: 'deine@email.com',
|
||||||
|
kcd9890e5: 'PNG, JPG, WebP up to 5MB',
|
||||||
|
kcdfef775: 'Loading subscriptions…',
|
||||||
|
kce0ab46c: 'Dein Passwort',
|
||||||
|
kcf4ba87d: 'Crop Affiliate Logo',
|
||||||
|
kcf61fc9e: 'Last Loose Files Action',
|
||||||
|
kd00443f2: 'Go to Dashboard',
|
||||||
|
kd04a7c59: 'Matrix Name',
|
||||||
|
kd058bb7b: 'Missing:',
|
||||||
|
kd09be3cd: 'Matrix Management',
|
||||||
|
kd1c17b3f: 'Alle Marken',
|
||||||
|
kd1f35ccf: 'Search & Filter Users',
|
||||||
|
kd2e35b08: 'Rows per page',
|
||||||
|
kd2e5e813: '• Already booked:',
|
||||||
|
kd304af2e: 'Global search...',
|
||||||
|
kd40c4f86: 'Activate this pool',
|
||||||
|
kd49dc1e1: 'Pool Type',
|
||||||
|
kd4a0fd1e: 'Pool Name',
|
||||||
|
kd4af6368: 'Issue Date',
|
||||||
|
kd4d50566: 'ID documents or a signed contract are missing from object storage. The user’s verification status should be checked.',
|
||||||
|
kd4eb7ee0: 'Close menu',
|
||||||
|
kd51f320c: 'Exoscale Folder Structure',
|
||||||
|
kd56a13f2: 'Recipient Email',
|
||||||
|
kd5cca6e9: '? This action cannot be undone.',
|
||||||
|
kd6024811: 'PDF File',
|
||||||
|
kd642e230: 'Search by name or email. Minimum 3 characters. Existing matrix members are hidden.',
|
||||||
|
kd68da70d: 'Nachhaltige Produkte für deinen Erfolg',
|
||||||
|
kd89474fa: 'Back to News',
|
||||||
|
kda96f5b3: 'Matrix Depth',
|
||||||
|
kdb27a82d: '‹ Previous',
|
||||||
|
kdbba338d: 'Registration Number:',
|
||||||
|
kdc22ad8a: 'Manage matrices, see stats, and create new ones.',
|
||||||
|
kdc47630b: 'Matrix fill:',
|
||||||
|
kdca959c3: 'Discover a curated selection of high-quality products that cater to your every need.',
|
||||||
|
kde1c3c69: 'Logo Image',
|
||||||
|
kde2b4fa0: 'Result Summary',
|
||||||
|
ke0a3528a: 'Passwords do not match.',
|
||||||
|
ke17859b2: 'Business Registration',
|
||||||
|
ke19afb3d: 'Archive this pool',
|
||||||
|
ke1abc7d9: 'Add Affiliate',
|
||||||
|
ke24abf9c: 'Edit coffee content',
|
||||||
|
ke3889dc2: 'Loading news...',
|
||||||
|
ke4c4a858: 'Min. 3 characters',
|
||||||
|
ke697b8cb: 'Set Active',
|
||||||
|
ke8b9f33c: 'Total in Pool',
|
||||||
|
ke9e71971: 'Oder weiter mit',
|
||||||
|
kebf33594: 'Filter by category:',
|
||||||
|
kec5a5357: 'Upload Invoice',
|
||||||
|
keccee79f: 'Email address',
|
||||||
|
ked60db76: 'Back to login',
|
||||||
|
kee28b8c6: '🔑 Token Status',
|
||||||
|
kef1656df: 'Apply Crop',
|
||||||
|
kefd5231d: 'Depth 5',
|
||||||
|
kf0646f35: 'Our values',
|
||||||
|
kf0d33884: 'Check browser console for detailed logs',
|
||||||
|
kf0eef57e: 'Total members:',
|
||||||
|
kf2147f07: 'Not provided',
|
||||||
|
kf2180ff6: 'Manage VAT rates',
|
||||||
|
kf27e4502: 'Ready to Verify',
|
||||||
|
kf2a1257e: 'Back to profile',
|
||||||
|
kf2b5c1a6: 'Customer Name',
|
||||||
|
kf2d8db2b: 'Updating status...',
|
||||||
|
kf340aa10: 'Loose files:',
|
||||||
|
kf3557acd: '5‑ary Tree',
|
||||||
|
kf3b81ba3: 'Used in the URL. Auto-generated from title unless edited.',
|
||||||
|
kf4868273: 'Click to upload',
|
||||||
|
kf4f44e2f: 'e.g. ATU12345678',
|
||||||
|
kf530c357: 'Anmeldung läuft...',
|
||||||
|
kf663ef67: 'Shop with an infinite variety of products',
|
||||||
|
kf69154f8: '• Stay on topic',
|
||||||
|
kf70b9896: 'e.g. 12345',
|
||||||
|
kf7189e80: '🔄 Manual Refresh Token',
|
||||||
|
kf78c9087: 'Immediate children',
|
||||||
|
kf7a91674: 'Policy ↑',
|
||||||
|
kf7a91676: 'Policy ↓',
|
||||||
|
kf823daf7: 'Fallback to root if referral parent not in matrix',
|
||||||
|
kf8c220d3: 'kunde@example.com',
|
||||||
|
kf971ea7f: 'Created:',
|
||||||
|
kfaa8fc4a: '• Will book:',
|
||||||
|
kfb1676b0: 'Phone number *',
|
||||||
|
kfb37e056: 'Last Login:',
|
||||||
|
kfb92efe9: 'Description *',
|
||||||
|
kfce271a2: 'Node Env:',
|
||||||
|
kfdcad59b: 'Send Email Report',
|
||||||
|
kfe8083f8: 'First Name',
|
||||||
|
k17581b31: 'Next page',
|
||||||
|
k2108b5a0: 'No invoices found for this subscription.',
|
||||||
|
k34a0a2e4: 'Select the files where you want to run i18n auto-fix.',
|
||||||
|
k41f3daea: 'Billed monthly',
|
||||||
|
k43218db0: 'Fix Targets',
|
||||||
|
k49e51b5f: 'Current plan',
|
||||||
|
k4bfb4f28: 'Feature comparison',
|
||||||
|
k4c6eb72c: 'Select all',
|
||||||
|
k4f209a66: 'You currently don’t have an active subscription.',
|
||||||
|
k5b7042c7: 'Previous page',
|
||||||
|
k60b1e339: 'No media or documents found.',
|
||||||
|
k6569783c: 'Use this to include server-style files. Files with server-only Next.js APIs are skipped for safety.',
|
||||||
|
k68c88f41: 'Force convert selected files to client components before auto-fix',
|
||||||
|
k74914369: 'Delete Item',
|
||||||
|
k772cc77b: 'Complete your profile to unlock all features',
|
||||||
|
k7fa55432: 'My Subscription',
|
||||||
|
k86b03343: 'Billed annually',
|
||||||
|
k8953de89: 'Finance & Invoices',
|
||||||
|
k947d8777: 'Invoice #',
|
||||||
|
ka5603827: 'Loading invoices…',
|
||||||
|
ka86bdc9b: 'Payment frequency',
|
||||||
|
kb3243742: 'No file',
|
||||||
|
kc48b877b: 'No subscription selected. Invoices will appear once you have an active subscription.',
|
||||||
|
kd08b698a: 'Profile Completion',
|
||||||
|
ke3480838: 'No fixable hardcoded UI text detected in eligible components.',
|
||||||
|
ked7d533b: 'Media & Documents',
|
||||||
|
kf5ac16fb: 'Pricing that grows with you',
|
||||||
|
kf9f94d5e: 'Buy this plan',
|
||||||
|
kfd632d02: 'Export all invoices',
|
||||||
|
k5d4f6b2f: 'Bank Information',
|
||||||
|
k9dafde30: 'Contact Person',
|
||||||
|
kada9d61c: 'Account Holder',
|
||||||
|
kde6d477f: 'Email Address',
|
||||||
|
kfc6b6a29: 'Editing disabled',
|
||||||
|
k03538639: 'e.g. fr, es, zh-TW',
|
||||||
|
k5fcc9b0e: 'Delete language',
|
||||||
|
k9bd0812b: 'Shows why a file was changed, skipped, or left untouched after a fix attempt.',
|
||||||
|
ka019b3c0: 'e.g. Français',
|
||||||
|
kbe30c353: 'Coverage by namespace',
|
||||||
|
kbf49d59b: 'Search keys or English text…',
|
||||||
|
kc8034db6: 'Auto-fix debug logs',
|
||||||
|
k0cdc3ee9: 'Assign namespace...',
|
||||||
|
k1db86f96: 'Create tab',
|
||||||
|
k505ebdae: 'Unassigned namespaces',
|
||||||
|
k5f978731: 'Category tabs',
|
||||||
|
k66edf1eb: 'Drag into a tab card',
|
||||||
|
k741a01f7: 'All namespaces are categorized.',
|
||||||
|
ka6791a02: 'Remove from tab',
|
||||||
|
kb7a30760: 'Filter namespaces quickly or open manager to create and assign tabs.',
|
||||||
|
kc4671abe: 'Create tabs and assign namespaces by drag-and-drop or dropdown.',
|
||||||
|
kd6e42900: 'Manage tabs',
|
||||||
|
ke52ed6e9: 'New tab name',
|
||||||
|
kef9de7f0: 'Manage category tabs',
|
||||||
|
kf3c3223a: 'Drop a namespace here.',
|
||||||
|
k0700b1f2: 'No global keys yet. Mark keys as global in the translation wizard, or add one from the dropdown above.',
|
||||||
|
k47bce570: 'Select key to add to global',
|
||||||
|
k5e5e8744: 'Translation wizard available',
|
||||||
|
k6aba2cb0: 'Back to panels',
|
||||||
|
k6cfeedd3: 'Global terms',
|
||||||
|
k725dd1d6: 'Start wizard',
|
||||||
|
kad7d8c49: 'Use this list for cross-context terms like IBAN.',
|
||||||
|
kc02b17c3: 'Remove from global terms',
|
||||||
|
kc518ff5c: 'English reference',
|
||||||
|
kcd190bdd: 'Translation wizard',
|
||||||
|
kfd1e0089: 'Auto-scroll on panel open',
|
||||||
|
},
|
||||||
|
|
||||||
// ─── Notifications / Toasts ────────────────────────────
|
// ─── Notifications / Toasts ────────────────────────────
|
||||||
toasts: {
|
toasts: {
|
||||||
loginSuccess: 'Anmeldung erfolgreich',
|
loginSuccess: 'Anmeldung erfolgreich',
|
||||||
|
|||||||
@ -163,6 +163,36 @@ export const en: Translations = {
|
|||||||
sessionDetectedMessage: 'You are already logged in. Do you want to log out and register a new account?',
|
sessionDetectedMessage: 'You are already logged in. Do you want to log out and register a new account?',
|
||||||
sessionContinue: 'Continue to dashboard',
|
sessionContinue: 'Continue to dashboard',
|
||||||
sessionLogout: 'Log out and register',
|
sessionLogout: 'Log out and register',
|
||||||
|
formTitle: 'Registration for Profit Planet',
|
||||||
|
guestRegistration: 'Guest Registration',
|
||||||
|
registerNow: 'Register now',
|
||||||
|
guestDescription: 'Register as a guest to access your coffee abonnement.',
|
||||||
|
personalDescription: 'Create your personal or company account with Profit Planet.',
|
||||||
|
invitedBy: 'You were invited by',
|
||||||
|
tabIndividual: 'Individual',
|
||||||
|
submitCompany: 'Register company',
|
||||||
|
submitGuest: 'Register as Guest',
|
||||||
|
successRedirecting: 'Registration successful – redirecting...',
|
||||||
|
guestNote: 'You are registering as a guest. You will have access to your coffee abonnements only.',
|
||||||
|
errorBothCountryCodes: 'Please select country codes (dropdown) for both company and contact phone numbers.',
|
||||||
|
successCompanyMessage: 'You can now log in with your new company account.',
|
||||||
|
successGuestMessage: 'You can now log in to view your coffee abonnement.',
|
||||||
|
failedTitle: 'Registration failed',
|
||||||
|
failedMessage: 'Registration failed. Please try again.',
|
||||||
|
networkErrorGeneric: 'Network error. Please try again later.',
|
||||||
|
passwordRequirements: 'Password requirements:',
|
||||||
|
pwdMinLength: 'At least 8 characters',
|
||||||
|
pwdLowercase: 'Lowercase letters (a-z)',
|
||||||
|
pwdUppercase: 'Uppercase letters (A-Z)',
|
||||||
|
pwdDigits: 'Digits (0-9)',
|
||||||
|
pwdSpecial: 'Special characters (!@#$...)',
|
||||||
|
invalidLinkTitle: 'Invalid invitation link',
|
||||||
|
invalidLinkMessage: 'This registration link is invalid or no longer active. Please request a new link.',
|
||||||
|
tokenLabel: 'Token',
|
||||||
|
sessionDescription: 'You are already logged in. To register, you must first log out or you can go to the dashboard.',
|
||||||
|
goToDashboard: 'Go to dashboard',
|
||||||
|
goToHomepage: 'Go to homepage',
|
||||||
|
loginHere: 'Login here',
|
||||||
},
|
},
|
||||||
|
|
||||||
passwordReset: {
|
passwordReset: {
|
||||||
@ -398,16 +428,111 @@ export const en: Translations = {
|
|||||||
referralManagement: {
|
referralManagement: {
|
||||||
title: 'Referral Management',
|
title: 'Referral Management',
|
||||||
subtitle: 'Manage your referral links.',
|
subtitle: 'Manage your referral links.',
|
||||||
|
description: 'Create and manage your referral links. Track performance at a glance.',
|
||||||
createLink: 'Create referral link',
|
createLink: 'Create referral link',
|
||||||
copyLink: 'Copy link',
|
copyLink: 'Copy link',
|
||||||
|
copy: 'Copy',
|
||||||
|
copyMobile: 'Copy link',
|
||||||
|
copied: 'Copied',
|
||||||
copiedToClipboard: 'Copied to clipboard!',
|
copiedToClipboard: 'Copied to clipboard!',
|
||||||
linkExpiry: 'Expires',
|
linkExpiry: 'Expires',
|
||||||
noLinks: 'No referral links yet.',
|
noLinks: 'No referral links found.',
|
||||||
generating: 'Generating…',
|
generating: 'Generating…',
|
||||||
|
generateLink: 'Generate Link',
|
||||||
usesRemaining: 'uses remaining',
|
usesRemaining: 'uses remaining',
|
||||||
unlimited: 'Unlimited',
|
unlimited: 'Unlimited',
|
||||||
|
never: 'Never',
|
||||||
createSuccess: 'Referral link created successfully.',
|
createSuccess: 'Referral link created successfully.',
|
||||||
createError: 'Could not create referral link.',
|
createError: 'Could not create referral link.',
|
||||||
|
deactivate: 'Deactivate',
|
||||||
|
deactivated: 'Link deactivated',
|
||||||
|
deactivatedMessage: 'The referral link has been deactivated successfully.',
|
||||||
|
deactivateFailed: 'Deactivate failed',
|
||||||
|
deactivateFailedMessage: 'Could not deactivate the referral link.',
|
||||||
|
deactivateNetworkError: 'Network error while deactivating the referral link.',
|
||||||
|
deactivateModalTitle: 'Deactivate referral link?',
|
||||||
|
deactivateModalDescription: 'This will immediately deactivate the selected referral link so it can no longer be used.',
|
||||||
|
linkLabel: 'Link',
|
||||||
|
accessCheckFailed: 'Access check failed',
|
||||||
|
userIdMissing: 'User id is missing. Redirecting…',
|
||||||
|
accessDenied: 'Access denied',
|
||||||
|
accessDeniedMessage: 'You do not have permission to access Referral Management.',
|
||||||
|
permCheckFailed: 'Permission check failed',
|
||||||
|
permCheckFailedMessage: 'Could not verify permissions. Redirecting…',
|
||||||
|
loadFailed: 'Load failed',
|
||||||
|
loadStatsError: 'Could not load referral statistics.',
|
||||||
|
loadLinksError: 'Could not load referral links.',
|
||||||
|
copyFailed: 'Copy failed',
|
||||||
|
copyFailedMessage: 'Could not copy link to clipboard.',
|
||||||
|
copiedMessage: 'Link copied to clipboard.',
|
||||||
|
allLinks: 'All Referral Links',
|
||||||
|
allLinksSubtitle: 'Manage your links and see their status.',
|
||||||
|
colLink: 'Link',
|
||||||
|
colCreated: 'Created',
|
||||||
|
colExpires: 'Expires',
|
||||||
|
colUsage: 'Usage',
|
||||||
|
colStatus: 'Status',
|
||||||
|
generateTitle: 'Generate Referral Link',
|
||||||
|
maxUsesLabel: 'Max Uses',
|
||||||
|
expiresIn: 'Expires In',
|
||||||
|
lockedByNeverExpires: 'Locked by "Never expires".',
|
||||||
|
lockedByUnlimited: 'Locked by "Unlimited uses".',
|
||||||
|
statsActiveLinks: 'Active Links',
|
||||||
|
statsLinksUsed: 'Links Used',
|
||||||
|
statsPersonalUsers: 'Personal Users',
|
||||||
|
statsCompanyUsers: 'Company Users',
|
||||||
|
statsTotalLinks: 'Total Links',
|
||||||
|
levelStarter: 'Starter',
|
||||||
|
levelNovice: 'Novice',
|
||||||
|
levelHustler: 'Hustler',
|
||||||
|
levelEntrepreneur: 'Entrepreneur',
|
||||||
|
levelPrestige: 'Prestige',
|
||||||
|
levelMax: 'MAX',
|
||||||
|
levelLabel: 'Level',
|
||||||
|
referrals: 'referrals',
|
||||||
|
of: 'of',
|
||||||
|
maxLevelReached: 'Max level reached',
|
||||||
|
nextMilestone: 'Next milestone',
|
||||||
|
registeredUsersTitle: 'Registered Users via Your Referral',
|
||||||
|
totalRefBadge: 'TOTAL REGISTERED USER WITH YOUR REF LINK',
|
||||||
|
registeredUsersSubtitle: 'Users who signed up using one of your referral links.',
|
||||||
|
showingLatest5: 'Showing the latest 5 users. Use “View all” to see the complete list.',
|
||||||
|
viewAll: 'View all',
|
||||||
|
colUser: 'User',
|
||||||
|
colEmail: 'Email',
|
||||||
|
colType: 'Type',
|
||||||
|
colRegistered: 'Registered',
|
||||||
|
noRegisteredUsers: 'No registered users found.',
|
||||||
|
typeCompany: 'Company',
|
||||||
|
typePersonal: 'Personal',
|
||||||
|
allRegisteredUsersTitle: 'All Registered Users via Your Referral',
|
||||||
|
allRegisteredUsersSubtitle: 'Search, filter, paginate, or export the full list.',
|
||||||
|
exportCsv: 'Export CSV',
|
||||||
|
searchPlaceholder: 'Search name or email…',
|
||||||
|
filterAllTypes: 'All Types',
|
||||||
|
filterAllStatus: 'All Status',
|
||||||
|
filterActive: 'Active',
|
||||||
|
filterInactive: 'Inactive',
|
||||||
|
filterPending: 'Pending',
|
||||||
|
filterBlocked: 'Blocked',
|
||||||
|
noUsersMatchFilters: 'No users match your filters.',
|
||||||
|
showing: 'Showing',
|
||||||
|
pagePrev: 'Previous',
|
||||||
|
pageNext: 'Next',
|
||||||
|
pageOf: 'of',
|
||||||
|
expiry1Day: '1 day',
|
||||||
|
expiry2Days: '2 days',
|
||||||
|
expiry3Days: '3 days',
|
||||||
|
expiry4Days: '4 days',
|
||||||
|
expiry5Days: '5 days',
|
||||||
|
expiry6Days: '6 days',
|
||||||
|
expiry7Days: '7 days',
|
||||||
|
expiryNever: 'Never expires',
|
||||||
|
maxUses1: '1 use',
|
||||||
|
maxUses5: '5 uses',
|
||||||
|
maxUses10: '10 uses',
|
||||||
|
maxUses50: '50 uses',
|
||||||
|
maxUsesUnlimited: 'Unlimited',
|
||||||
},
|
},
|
||||||
|
|
||||||
quickactionDashboard: {
|
quickactionDashboard: {
|
||||||
@ -793,6 +918,7 @@ export const en: Translations = {
|
|||||||
codeDuplicate: 'Language already exists.',
|
codeDuplicate: 'Language already exists.',
|
||||||
codeRequired: 'Language code is required.',
|
codeRequired: 'Language code is required.',
|
||||||
nameRequired: 'Language name is required.',
|
nameRequired: 'Language name is required.',
|
||||||
|
wizardInputPlaceholder: 'Enter translated text',
|
||||||
},
|
},
|
||||||
|
|
||||||
contractManagement: {
|
contractManagement: {
|
||||||
@ -926,6 +1052,646 @@ export const en: Translations = {
|
|||||||
reasonNoActivePools: 'No active system pools found',
|
reasonNoActivePools: 'No active system pools found',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
autofix: {
|
||||||
|
k02665163: 'Next steps',
|
||||||
|
k027bd82e: 'Edit the shipping prices for 60 and 120 pieces.',
|
||||||
|
k047a175d: 'No contracts found.',
|
||||||
|
k06d4487f: 'Cancel editing',
|
||||||
|
k0853cfa6: 'Thanks for your subscription!',
|
||||||
|
k096f4013: 'Manage your company stamps. One active at a time.',
|
||||||
|
k0af6c6be: 'Create & Activate',
|
||||||
|
k0affa826: 'Shown to users in the shop and checkout.',
|
||||||
|
k0b03e660: '2. Choose coffees & quantities',
|
||||||
|
k0b2445d5: 'Generating PDF preview…',
|
||||||
|
k0bbc633d: 'Loading contract preview…',
|
||||||
|
k0d9c63c5: 'Scanning workspace files and component subdirectories...',
|
||||||
|
k11438b4c: 'Total incl. tax',
|
||||||
|
k12a86c71: 'Shipping…',
|
||||||
|
k14eb468b: 'Potential untranslated UI text detected',
|
||||||
|
k155166db: 'Contract variables are auto-populated from your form data.',
|
||||||
|
k15bea9bb: 'Address details used on invoices.',
|
||||||
|
k1824f78d: 'Please select coffees and fill all required buyer fields, signing city, and signature.',
|
||||||
|
k18872b63: 'No image',
|
||||||
|
k1bf4ffa4: 'Untranslated literals',
|
||||||
|
k20127e1c: 'No selection found.',
|
||||||
|
k21361e0d: 'Summary & Details',
|
||||||
|
k221fa311: 'Invoice template variables',
|
||||||
|
k22c8f7f1: 'Create Template',
|
||||||
|
k28f1a9b1: 'Full name',
|
||||||
|
k2d0798a6: 'Loading subscription…',
|
||||||
|
k2e43a9c4: 'Click or drag and drop a new image here',
|
||||||
|
k3466b0e0: 'Payment method',
|
||||||
|
k346a2c64: 'Language Management',
|
||||||
|
k39791457: 'Manage contract templates, company stamp, and create new templates.',
|
||||||
|
k41ab9eb6: 'You\'ll be able to crop and adjust the image after uploading',
|
||||||
|
k41afd863: 'Editing:',
|
||||||
|
k4aeb8688: '2. Your selection',
|
||||||
|
k4be6f631: 'Save changes',
|
||||||
|
k516705dd: 'Ort ist erforderlich.',
|
||||||
|
k528eede9: 'Same as shipping address',
|
||||||
|
k56717603: 'no image',
|
||||||
|
k56a52520: 'Skipped files',
|
||||||
|
k5a489751: 'Save Changes',
|
||||||
|
k5ad4d864: 'Auto-fixed files',
|
||||||
|
k6070f6e3: 'Add New Stamp',
|
||||||
|
k60874ea3: 'Keys auto-created',
|
||||||
|
k67cb36a4: 'Contract Management',
|
||||||
|
k6a2c64e8: 'Last name',
|
||||||
|
k6a892262: 'No keys match your search.',
|
||||||
|
k6ee0a1b6: 'Click or drag and drop an image here',
|
||||||
|
k73d1d7d7: 'Edit Crop',
|
||||||
|
k74491338: 'Reverse Charge aktiv: gueltige UID und auslaendisches Rechnungsland erkannt.',
|
||||||
|
k7775eddb: 'Your Company Stamps',
|
||||||
|
k788633d1: 'Profit Planet',
|
||||||
|
k7a3a6ea3: 'to render invoice line items.',
|
||||||
|
k7f48f374: '1. Select subscription size',
|
||||||
|
k7fe72eff: 'No platforms available.',
|
||||||
|
k80ac9651: 'PNG, JPG, WebP up to 10MB',
|
||||||
|
k825359ab: 'Accepted types: PNG, JPEG, WebP, SVG. Max size: 5MB.',
|
||||||
|
k832387c5: 'Loading…',
|
||||||
|
k83deba83: 'per 10 pcs',
|
||||||
|
k875f4054: 'Manage all coffees.',
|
||||||
|
k8c75468c: 'No subscriptions found.',
|
||||||
|
k8cf40180: 'Missing keys in en.ts',
|
||||||
|
k90a6e795: 'Unique keys used',
|
||||||
|
k91052e3f: 'Translation calls',
|
||||||
|
k92639a9a: 'Language code',
|
||||||
|
k926966d0: 'Language name',
|
||||||
|
k96839795: 'Back to selection',
|
||||||
|
k99bffb65: 'Fill all fields to proceed.',
|
||||||
|
k9b173204: 'Files auto-fixed',
|
||||||
|
k9c1a5ecc: 'Fill fields with logged in data',
|
||||||
|
ka3ee9ded: 'Subscription Billing',
|
||||||
|
ka56b7b2b: 'No PDF preview available.',
|
||||||
|
ka5f38d19: 'Company Stamp',
|
||||||
|
ka802064d: 'Applying i18n auto-fixes to client components and updating translation files...',
|
||||||
|
kaa30f0cd: 'Create Coffee',
|
||||||
|
kaa8bbc8e: 'Company Information',
|
||||||
|
kac6cedc7: 'Saving…',
|
||||||
|
kae63e46a: 'Missing translation keys detected in workspace',
|
||||||
|
kb06fa395: 'Edit Coffee',
|
||||||
|
kb0b660e2: 'Configure Coffee Subscription',
|
||||||
|
kb1c1c0e5: 'Logging you out...',
|
||||||
|
kb2217bdf: 'Translation Coverage Scan',
|
||||||
|
kb791958e: 'Use these placeholders in your HTML: invoiceNumber, customerName, issuedAt, totalNet, totalTax, totalGross, itemsHtml.',
|
||||||
|
kb8f33873: 'Translation progress',
|
||||||
|
kb9e483c4: 'Update details of the coffee.',
|
||||||
|
kba6bd6f3: 'or click to browse',
|
||||||
|
kcc4adbcc: 'Navigation shortcuts',
|
||||||
|
kce094582: 'Invoice address',
|
||||||
|
kd1a2772d: 'Street & No.',
|
||||||
|
kd2a00802: 'Image removed - Click to upload a new one',
|
||||||
|
kd3092148: 'Subscription created.',
|
||||||
|
kd379df9b: 'Open preview',
|
||||||
|
kd63c8219: 'You have unsaved changes.',
|
||||||
|
kd6f8d7e9: '1. Your details',
|
||||||
|
kd8a5ad17: 'Back to list',
|
||||||
|
kda5f982e: 'Delete Language',
|
||||||
|
kddd4832f: 'Delete coffee?',
|
||||||
|
kde5c689e: 'Pick a platform to continue.',
|
||||||
|
ke33e6fbf: 'Send invoice by email',
|
||||||
|
ke58b7627: 'Drag and drop your stamp here',
|
||||||
|
ke74b1adf: 'Contract template is not available.',
|
||||||
|
ke7b634f2: '3. Preview',
|
||||||
|
ke7f0a9e3: 'FREE SHIPPING',
|
||||||
|
kea7cde7a: 'Back to Admin',
|
||||||
|
kec078e54: 'No coffees selected yet.',
|
||||||
|
kefe5f0dd: 'Ohne gueltige UID wird die Rechnung mit normaler MwSt erstellt.',
|
||||||
|
kf1a9384b: 'Auto-applied to documents where applicable.',
|
||||||
|
kf4e45236: 'Add Language',
|
||||||
|
kf72d41db: 'Add a new coffee.',
|
||||||
|
kfe9527d8: 'First name',
|
||||||
|
kfeac3f7e: 'Choose file',
|
||||||
|
k0c51fa85: 'Activate template now?',
|
||||||
|
k134e3932: 'Active stamp',
|
||||||
|
k1f0b2c48: 'z.B. Wien',
|
||||||
|
k2fac9ff2: 'Template name',
|
||||||
|
k3477c83a: 'Describe the product',
|
||||||
|
k35ac864e: 'Search templates…',
|
||||||
|
ka8f53660: 'Delete Company Stamp',
|
||||||
|
kaa5e5363: 'ABO Contract PDF Preview',
|
||||||
|
kcb65c692: 'e.g., Company Seal 2025',
|
||||||
|
kd9e4bcbd: 'Contract Preview',
|
||||||
|
kf1512f8f: 'z.B. SI12345678',
|
||||||
|
k00016501: '🧪 Token Refresh Test',
|
||||||
|
k002455d8: 'Total Gross / Brutto',
|
||||||
|
k00394342: 'Welcome back! Log in to continue.',
|
||||||
|
k01ad6d49: 'Overview of taxes, revenue, and invoices.',
|
||||||
|
k022df6ac: 'Admin Verified',
|
||||||
|
k039e629b: 'Overview meta',
|
||||||
|
k03cd9b72: 'Commission:',
|
||||||
|
k04b5cbca: 'Loose Files',
|
||||||
|
k051e8ac8: 'Confirm password',
|
||||||
|
k055bba0c: 'Are you sure you want to delete',
|
||||||
|
k05626798: 'Click to upload logo',
|
||||||
|
k0778fa87: 'Make sure JWT_EXPIRES_IN=2m in backend .env for fast testing',
|
||||||
|
k07fe11b2: 'Company / Holder name',
|
||||||
|
k088d8f6c: 'Delete news?',
|
||||||
|
k08c92a12: 'Welcome to Profit Planet Community 🌍',
|
||||||
|
k0925e287: 'e.g., VIP Members',
|
||||||
|
k098ec0b9: 'Manage the “Platforms” cards shown on the user dashboard.',
|
||||||
|
k09def344: 'Edit Affiliate',
|
||||||
|
k09f4290f: 'Reset password',
|
||||||
|
k0ac84efe: '← Back',
|
||||||
|
k0c838ec3: 'Min €',
|
||||||
|
k0c87d75d: 'Max €',
|
||||||
|
k0c95a1b4: 'Back:',
|
||||||
|
k0cc2a3ba: 'Versuche andere Suchbegriffe oder Filter',
|
||||||
|
k0cdde8f8: 'Name:',
|
||||||
|
k0d6626e3: '👤 User Info',
|
||||||
|
k0d8cb427: 'Type:',
|
||||||
|
k0da2c941: 'Users Pending Verification',
|
||||||
|
k0dba4c6b: 'Role:',
|
||||||
|
k0dca1445: 'Sort:',
|
||||||
|
k0dcb69ea: 'No matrices found.',
|
||||||
|
k0dd01c1c: 'Show:',
|
||||||
|
k0efd830c: 'Verification Readiness',
|
||||||
|
k0f0395ca: 'Multi-statement SQL and dump files are supported. Use with caution.',
|
||||||
|
k0f1fc266: 'All Statuses',
|
||||||
|
k0fbaa1a9: 'Jetzt registrieren',
|
||||||
|
k0fe28e0b: 'Affiliate Management',
|
||||||
|
k10ccb626: 'All Users',
|
||||||
|
k10e2568f: 'All Types',
|
||||||
|
k110bae43: 'All Roles',
|
||||||
|
k111c49d8: 'Users count respects each matrix’s max depth policy.',
|
||||||
|
k11974e0f: 'Advanced: choose parent manually',
|
||||||
|
k12a7170a: 'No ghost directories found. Run Refresh to scan again.',
|
||||||
|
k1387f81e: 'Export all filtered users to CSV',
|
||||||
|
k1405afab: 'Login and watch the countdown timer',
|
||||||
|
k14a4b43e: 'Refreshing…',
|
||||||
|
k1521a376: 'Export all users as CSV',
|
||||||
|
k15843a06: 'Active Pools',
|
||||||
|
k15da24d8: 'Join Group',
|
||||||
|
k16b60f69: 'View All',
|
||||||
|
k17ba59ff: 'Community Hands - Profit Planet',
|
||||||
|
k17f65c37: 'Example: /shop or https://example.com',
|
||||||
|
k1882bd75: 'Max Mustermann',
|
||||||
|
k199db5f1: 'your.email@example.com',
|
||||||
|
k19f2c5dc: 'No affiliates found',
|
||||||
|
k1a1ca621: 'e.g. DE89 3704 0044 0532 0130 00',
|
||||||
|
k1af107a4: 'Logo preview',
|
||||||
|
k1af97a07: 'User Management',
|
||||||
|
k1b9c46e5: 'Affiliate Partners',
|
||||||
|
k1d178b73: 'Basic Information',
|
||||||
|
k1db0c7cd: 'No loose files found. Run Refresh to scan again.',
|
||||||
|
k1ddc749e: 'Expiry Date',
|
||||||
|
k1df74994: 'All subscriptions',
|
||||||
|
k1e5d5139: 'No users match your filters.',
|
||||||
|
k1e62338a: 'Commission Rate',
|
||||||
|
k1eedcda3: 'User Status Hook',
|
||||||
|
k1f269263: 'DE89 3704 0044 0532 0130 00',
|
||||||
|
k209ba561: 'Create New Pool',
|
||||||
|
k20ab2fc7: 'We\'ll send a verification code to your email address.',
|
||||||
|
k21440f8a: 'Pool Management',
|
||||||
|
k21db276a: 'Auf Lager',
|
||||||
|
k228929e2: 'Profile Information',
|
||||||
|
k23c9f0ff: 'No results yet. Import a SQL dump to see output.',
|
||||||
|
k258c3515: '892 members',
|
||||||
|
k26ecadfd: 'My Groups',
|
||||||
|
k26fbc186: 'Access Denied',
|
||||||
|
k2786bc5f: 'Signing in...',
|
||||||
|
k27e93fd7: 'Stay informed with our latest announcements and insights',
|
||||||
|
k27f56959: 'State change will affect add/remove operations.',
|
||||||
|
k290e3aab: 'tt.mm jjjj',
|
||||||
|
k2a2fe15a: 'Phone Number',
|
||||||
|
k2a37c394: 'Brief description of the affiliate partner...',
|
||||||
|
k2af2916f: 'Your account is fully submitted. Our team will verify your account shortly.',
|
||||||
|
k2cd79a3d: 'Browse all favorites',
|
||||||
|
k2e8f3110: 'All Status',
|
||||||
|
k2f176a63: 'No news articles available yet.',
|
||||||
|
k2f4ebc32: 'Rows per page:',
|
||||||
|
k2f78fabe: 'Go to User Verification',
|
||||||
|
k31cadca6: 'Partner Name *',
|
||||||
|
k31d46514: 'Top node:',
|
||||||
|
k33918465: 'Company Name',
|
||||||
|
k354a026b: 'Subscription ID',
|
||||||
|
k35f67931: 'Changes apply from your next billing cycle.',
|
||||||
|
k3777e830: 'Our team',
|
||||||
|
k37d7b9c4: 'Checking pool inflow...',
|
||||||
|
k383672e3: 'owner@example.com',
|
||||||
|
k39437388: 'Core Pool — 1¢ per capsule per member',
|
||||||
|
k39e2c5db: 'Add Platform',
|
||||||
|
k3ac8ca10: 'Only SQL dump files are supported.',
|
||||||
|
k3b03502e: 'Error loading account status',
|
||||||
|
k3b7dd87a: 'Try again',
|
||||||
|
k3b8e0964: 'Subscription details',
|
||||||
|
k3c32c87f: 'Connect with like-minded individuals, share sustainable practices, and make a positive impact together.',
|
||||||
|
k3c3e6850: 'Email Verification',
|
||||||
|
k3d01de91: 'PROFIT PLANET',
|
||||||
|
k3d5fe74a: 'Expires At:',
|
||||||
|
k3def5ebf: 'Category *',
|
||||||
|
k3ee27b4f: 'Top-node Email',
|
||||||
|
k3f833ce6: 'e.g., Platinum Matrix',
|
||||||
|
k40f4552a: 'Level 2+',
|
||||||
|
k410ff9a9: 'Total Affiliates',
|
||||||
|
k416bfe70: 'No further status changes are available for this subscription.',
|
||||||
|
k4191cdba: 'Edit VAT',
|
||||||
|
k41f7c81d: 'Delete Account',
|
||||||
|
k4307f6c7: 'Date of Birth:',
|
||||||
|
k431328cf: 'No affiliate partners available at the moment.',
|
||||||
|
k471ba099: 'News Manager',
|
||||||
|
k47b952de: 'Has Token:',
|
||||||
|
k47bbd37e: 'Sign in to Profit Planet',
|
||||||
|
k483aa95a: '• Share authentic experiences',
|
||||||
|
k48852b8d: 'Customer Email',
|
||||||
|
k49568342: 'Manage your affiliate partners and tracking links',
|
||||||
|
k4968eb2a: 'Abonement:',
|
||||||
|
k49f254bd: 'Current URL:',
|
||||||
|
k4a055849: 'ID Front',
|
||||||
|
k4a9e1ebe: 'Loading user details...',
|
||||||
|
k4b6c7681: 'Open subscriptions',
|
||||||
|
k4c5e8e87: 'Export CSV',
|
||||||
|
k4c5ecd73: 'Export PDF',
|
||||||
|
k4cb62cff: 'Keine Produkte gefunden',
|
||||||
|
k4db68c96: 'SQL Import',
|
||||||
|
k4e0c889b: 'Not Ready',
|
||||||
|
k4e168c01: 'Coffee Abonnements',
|
||||||
|
k4e532c48: 'Verification Status',
|
||||||
|
k4e61bc77: 'Root not yet loaded.',
|
||||||
|
k4ed7f4d1: 'Time Left:',
|
||||||
|
k502a0057: 'Last 7 days',
|
||||||
|
k5122ab54: 'Request again',
|
||||||
|
k51ee3aae: 'email@example.com',
|
||||||
|
k52af8b8d: 'Quick Actions',
|
||||||
|
k533db977: 'Your new password',
|
||||||
|
k54c06343: 'Refresh Token',
|
||||||
|
k54f49724: 'No users match your search.',
|
||||||
|
k55aba973: 'Produkte gefunden',
|
||||||
|
k5614c806: 'Review and verify all users who need admin approval. Users must complete all steps before verification.',
|
||||||
|
k56435c9b: 'Verfügbarkeit',
|
||||||
|
k5738c039: 'Matrix created successfully.',
|
||||||
|
k577a012c: 'User Type',
|
||||||
|
k578dcc0b: 'PNG, JPG, WebP, SVG up to 5MB',
|
||||||
|
k58344b74: 'No direct children.',
|
||||||
|
k5834cbed: 'Loading affiliate partners...',
|
||||||
|
k58424b1d: 'Eco Warriors',
|
||||||
|
k5857ef79: 'Existing Pools',
|
||||||
|
k59422f07: 'Linked Subscription',
|
||||||
|
k5aae8706: 'New password',
|
||||||
|
k5c598bc0: 'Trending Groups',
|
||||||
|
k5d4d494e: 'Loading members...',
|
||||||
|
k5d85b354: 'Driver\'s License',
|
||||||
|
k5e580e3f: 'Filter zurücksetzen',
|
||||||
|
k5ef19112: 'Join our team',
|
||||||
|
k5f74c123: 'Last 30 days',
|
||||||
|
k5fb70267: 'Shop wird geladen...',
|
||||||
|
k5fbf1824: 'Masked names for deeper descendants.',
|
||||||
|
k61c2a732: 'Angemeldet bleiben',
|
||||||
|
k61f6cd4e: 'Token Preview:',
|
||||||
|
k6285753a: 'Back to Pool Management',
|
||||||
|
k62bc3c59: 'e.g. Berlin',
|
||||||
|
k62d12fab: 'Error loading data',
|
||||||
|
k63115bb4: 'ID Documents',
|
||||||
|
k633438a0: 'Discover our trusted partners and earn commissions through affiliate links.',
|
||||||
|
k63458f03: 'Produkte durchsuchen...',
|
||||||
|
k65b67dc3: 'Back to matrices',
|
||||||
|
k65e33378: 'Total users fetched',
|
||||||
|
k661c032b: 'You need admin privileges to access this page.',
|
||||||
|
k664072a1: 'Dev Management',
|
||||||
|
k67391c88: 'Manage system pools and members.',
|
||||||
|
k678d2b40: 'Super reduced',
|
||||||
|
k67cace8b: 'Profile Settings',
|
||||||
|
k67dd8a82: 'Contact name',
|
||||||
|
k6828cdd9: 'Affiliate URL *',
|
||||||
|
k6838438d: 'Ghost Directories',
|
||||||
|
k6a4108c8: 'Last Name',
|
||||||
|
k6a486e3e: 'Create Group',
|
||||||
|
k6aa2d843: 'Read full guidelines',
|
||||||
|
k6af9037b: 'Open navigation',
|
||||||
|
k6b0f4f70: 'ID documents or a signed contract are missing for this user. The user’s verification status should be checked.',
|
||||||
|
k6b76bd0e: 'Willkommen bei Profit Planet',
|
||||||
|
k6c6e5c0f: 'Use with caution',
|
||||||
|
k6ca85cda: 'Trending right now',
|
||||||
|
k6d85810b: 'Your password',
|
||||||
|
k6de13000: 'Zero Waste Living',
|
||||||
|
k6e4a6069: 'Import SQL dump files to run database migrations.',
|
||||||
|
k70972912: 'Edit Profile',
|
||||||
|
k70bcafbd: 'Recent Discussions',
|
||||||
|
k71d565c9: 'Address:',
|
||||||
|
k72428656: 'Highest full level:',
|
||||||
|
k73831c06: 'Personal Matrix',
|
||||||
|
k73cf4fb6: 'Edit News',
|
||||||
|
k73d110fa: 'Edit Mode',
|
||||||
|
k73d4a156: 'Check Network tab for /api/refresh requests',
|
||||||
|
k744fda01: 'My Subscriptions',
|
||||||
|
k748bf541: 'No users match current filters.',
|
||||||
|
k75078d0b: 'Add News',
|
||||||
|
k750c1eb5: 'Add User',
|
||||||
|
k7572cceb: 'Edit VAT rates',
|
||||||
|
k75cb45a7: 'This value is stored as net price.',
|
||||||
|
k75d83433: '• Help others learn and grow',
|
||||||
|
k77049179: '• Reason:',
|
||||||
|
k77444d5b: 'Exoscale directories that do not have a matching user in the database.',
|
||||||
|
k776b751c: 'Policy Max Depth',
|
||||||
|
k777299de: 'Finance Management',
|
||||||
|
k77767b9e: 'Close notification',
|
||||||
|
k77a56aae: 'News & Updates',
|
||||||
|
k77d5ecd9: 'Copy referral link',
|
||||||
|
k7938d4fd: 'Result Sets',
|
||||||
|
k79e1c459: 'Manage all users, view statistics, and handle verification.',
|
||||||
|
k7ab45054: 'All Readiness',
|
||||||
|
k7bed84a7: 'Member Since',
|
||||||
|
k7c19388f: 'e.g., 10%',
|
||||||
|
k7c740cd5: 'Ready to search. Click the Search button to fetch candidates.',
|
||||||
|
k7db4e5a9: 'Visit Affiliate Link',
|
||||||
|
k7f57b169: 'Test API Call',
|
||||||
|
k7f9568ec: 'Highest full level',
|
||||||
|
k7fa2c4af: 'Loading users...',
|
||||||
|
k811fbc99: 'API Base URL:',
|
||||||
|
k815ca9ba: 'A matrix configuration already exists for this selection.',
|
||||||
|
k8193b7a2: 'Loading loose files...',
|
||||||
|
k81a1b900: 'Loading settings…',
|
||||||
|
k81b056f2: 'See our job postings',
|
||||||
|
k81c0b74b: 'Status:',
|
||||||
|
k81c7c2f2: 'Musterstraße 1',
|
||||||
|
k8323a7d9: 'Loading:',
|
||||||
|
k832a032b: 'Search affiliates...',
|
||||||
|
k8358f1d1: 'Loading folder issues...',
|
||||||
|
k84d5cfcb: 'View overview',
|
||||||
|
k85446b89: 'Next billing',
|
||||||
|
k85682289: 'e.g. FN123456a',
|
||||||
|
k85c66f50: 'Search & Filter Pending Users',
|
||||||
|
k867f8265: 'Due Date',
|
||||||
|
k86aa4f9c: 'Current Month',
|
||||||
|
k87e4b9a2: 'Core Pool',
|
||||||
|
k883ea8c5: 'Loading ghost directories...',
|
||||||
|
k88d8bb9d: 'Passwort vergessen?',
|
||||||
|
k890ff52f: 'e.g., Coffee Equipment Co.',
|
||||||
|
k8a35cc53: 'SQL dumps run immediately and can modify production data.',
|
||||||
|
k8a59b156: 'Import SQL',
|
||||||
|
k8b71f0c7: 'Email, name, company...',
|
||||||
|
k8b89f863: 'External partner website.',
|
||||||
|
k8bb1c673: 'Email:',
|
||||||
|
k8be14d47: 'Error:',
|
||||||
|
k8c3085f4: 'Users ↓',
|
||||||
|
k8c3085f6: 'Users ↑',
|
||||||
|
k8d84b4c5: 'Add New Affiliate',
|
||||||
|
k8dda5201: 'you@example.com',
|
||||||
|
k8eaa7b3b: 'e.g. +43 1 234567',
|
||||||
|
k8eab7c16: 'National ID',
|
||||||
|
k8eb2524c: 'Enter 6-digit code',
|
||||||
|
k8f46c81e: 'e.g., 5',
|
||||||
|
k8f528877: 'Crop & Adjust Image',
|
||||||
|
k915115a9: 'Last 90 days',
|
||||||
|
k91912619: 'Close navigation',
|
||||||
|
k91e69df1: 'ProfitPlanet GmbH',
|
||||||
|
k91eb415a: 'ProfitPlanet Logo',
|
||||||
|
k91f24187: 'Complete Profile',
|
||||||
|
k9213db6e: '📋 Testing Instructions',
|
||||||
|
k93165aea: '12345 Berlin',
|
||||||
|
k93b6dc1b: 'Uploaded Documents',
|
||||||
|
k93f03bca: 'Signed Contract Document',
|
||||||
|
k941fd092: 'Last Folder Structure Action',
|
||||||
|
k955b1cbe: 'No results',
|
||||||
|
k959fb1a6: 'Remove member from pool?',
|
||||||
|
k961ba411: 'Community Guidelines',
|
||||||
|
k9683262f: 'Fill %',
|
||||||
|
k96dbbe05: 'Street & House Number',
|
||||||
|
k972cee5e: 'Front:',
|
||||||
|
k9772afa4: 'No included items were returned for this subscription.',
|
||||||
|
k97abed7d: 'Tax Certificate',
|
||||||
|
k981b1f1a: 'SQL Dump Import',
|
||||||
|
k98519a5e: 'I have read, understood, and agree to the terms and conditions of this service agreement.',
|
||||||
|
k9860434f: 'Please review and upload your signed service agreement.',
|
||||||
|
k9b3266b5: 'Pending backend wiring to MatrixController.listVacancies. This section will surface empty slots and allow reassignment.',
|
||||||
|
k9bc83f50: 'Change coffees for next month',
|
||||||
|
k9c3db145: 'Start Discussion',
|
||||||
|
k9d0c063d: 'Password saved. Redirecting to login...',
|
||||||
|
k9e609523: 'No missing folders found. Run Refresh to scan again.',
|
||||||
|
k9f29dbfb: 'Entdecke nachhaltige Produkte und verdiene dabei. Deine Plattform für bewussten Konsum und finanzielle Vorteile.',
|
||||||
|
k9f56d4ac: 'e.g. +43 676 1234567',
|
||||||
|
ka00fc5db: 'Manage your account information and preferences',
|
||||||
|
ka15f5ec5: 'Auth Store State',
|
||||||
|
ka1d0b6ff: 'No deeper descendants.',
|
||||||
|
ka29ac729: 'Saved successfully',
|
||||||
|
ka3c41ff8: 'View details →',
|
||||||
|
ka3cbb536: 'Continue →',
|
||||||
|
ka4ecb6cd: 'Search…',
|
||||||
|
ka5bf342b: 'Select…',
|
||||||
|
ka5c2113f: 'Company Name:',
|
||||||
|
ka5d50257: 'Loading VAT rates…',
|
||||||
|
ka6be28d2: 'Add user to pool',
|
||||||
|
ka7073aee: 'Profit Planet Store',
|
||||||
|
ka72e833f: 'Policy filter:',
|
||||||
|
ka8ea17b8: 'Next ›',
|
||||||
|
ka991f523: 'Loading affiliates...',
|
||||||
|
ka9d6e905: 'Total users under me',
|
||||||
|
kaa656770: 'You are not part of any matrix yet.',
|
||||||
|
kaa8231ec: 'This Year',
|
||||||
|
kab4f5159: 'Each node can hold up to 5 direct children. Depth unbounded.',
|
||||||
|
kab99811e: 'Disabled message',
|
||||||
|
kace2fe51: 'Verification Code',
|
||||||
|
kadc6abcf: 'Auth Debug Page',
|
||||||
|
kadd80fbc: 'Clear selection',
|
||||||
|
kaf787fe5: '1,284 members',
|
||||||
|
kb0031873: 'Browse all trending',
|
||||||
|
kb01addda: 'Token should automatically renew without user action',
|
||||||
|
kb1341138: 'Ensures both contract and gdpr folders exist for each user.',
|
||||||
|
kb24782ec: 'Last Login',
|
||||||
|
kb2dfe482: 'Read More',
|
||||||
|
kb324fb25: 'Total Users',
|
||||||
|
kb337d94e: 'No entries found.',
|
||||||
|
kb343460d: 'Rogue users',
|
||||||
|
kb35549bb: 'Search name or email…',
|
||||||
|
kb383a3e8: 'Included in your subscription',
|
||||||
|
kb45c4d5f: 'Tax ID:',
|
||||||
|
kb4675362: 'Import Results',
|
||||||
|
kb4aba3dc: 'No unverified users match current filters.',
|
||||||
|
kb573897d: 'Short description of the pool',
|
||||||
|
kb5e0b861: 'Inactive Pools',
|
||||||
|
kb6b367b7: 'When time left ≤ 3 minutes, auto-refresh should trigger',
|
||||||
|
kb6eacc9d: 'Select a .sql dump file using Import SQL.',
|
||||||
|
kb74d7c51: '🔧 Manual Controls',
|
||||||
|
kb7849a5a: 'Create Matrix',
|
||||||
|
kb846955a: 'Select Document Type',
|
||||||
|
kb87eb38b: 'Enter at least 3 characters and click Search.',
|
||||||
|
kb8cd2810: 'Account Status',
|
||||||
|
kb8d6f3f7: 'Shop Collection',
|
||||||
|
kbbefb159: 'Error loading users',
|
||||||
|
kbc368b5d: 'Date of Birth',
|
||||||
|
kbc6a6543: 'ID Back',
|
||||||
|
kbce9fbea: 'No platforms configured.',
|
||||||
|
kbd8b3364: 'Sign Contract',
|
||||||
|
kbd979e13: 'We are a community',
|
||||||
|
kbdb02e32: 'Keine Rechnungen gefunden.',
|
||||||
|
kbe9355f8: 'Business License',
|
||||||
|
kbf4b7789: 'You are already logged in. Redirecting...',
|
||||||
|
kbf7bde57: 'Select any subscription to view details and included items.',
|
||||||
|
kbfa5b4c5: 'Tax ID',
|
||||||
|
kbfd13a03: 'Account Setup',
|
||||||
|
kbff01823: 'Shows files directly under the user folder that are not in contract or gdpr.',
|
||||||
|
kc0d718d7: 'Registration Number',
|
||||||
|
kc0e3b03d: 'Total:',
|
||||||
|
kc3d181e2: 'Forgot password?',
|
||||||
|
kc4315932: 'Dashboard Management',
|
||||||
|
kc4d7816e: 'Levels filled',
|
||||||
|
kc7bb0c06: 'Filter by country or code',
|
||||||
|
kc7c429a6: 'Add users to matrix',
|
||||||
|
kc813a103: 'Loading available coffees…',
|
||||||
|
kc8652e34: 'You don’t have any subscriptions yet.',
|
||||||
|
kc9d9d15d: 'Postal Code',
|
||||||
|
kca04f5e3: 'Phone:',
|
||||||
|
kcada239b: 'View your active subscriptions, included items and subscription details on a dedicated page.',
|
||||||
|
kcb491706: 'Folder Structure',
|
||||||
|
kcbc17bbd: 'No users in this pool yet.',
|
||||||
|
kcc15636b: 'Coffee content can only be changed while a subscription is issued, ongoing, or paused.',
|
||||||
|
kcc1c5596: 'Profit Planet Mascot',
|
||||||
|
kccbc54c1: 'Delete Affiliate',
|
||||||
|
kccc13f16: '← Go back',
|
||||||
|
kccde6d86: 'User Verification Center',
|
||||||
|
kccf7593a: '• Be respectful and kind',
|
||||||
|
kcd7a1625: 'deine@email.com',
|
||||||
|
kcd9890e5: 'PNG, JPG, WebP up to 5MB',
|
||||||
|
kcdfef775: 'Loading subscriptions…',
|
||||||
|
kce0ab46c: 'Dein Passwort',
|
||||||
|
kcf4ba87d: 'Crop Affiliate Logo',
|
||||||
|
kcf61fc9e: 'Last Loose Files Action',
|
||||||
|
kd00443f2: 'Go to Dashboard',
|
||||||
|
kd04a7c59: 'Matrix Name',
|
||||||
|
kd058bb7b: 'Missing:',
|
||||||
|
kd09be3cd: 'Matrix Management',
|
||||||
|
kd1c17b3f: 'Alle Marken',
|
||||||
|
kd1f35ccf: 'Search & Filter Users',
|
||||||
|
kd2e35b08: 'Rows per page',
|
||||||
|
kd2e5e813: '• Already booked:',
|
||||||
|
kd304af2e: 'Global search...',
|
||||||
|
kd40c4f86: 'Activate this pool',
|
||||||
|
kd49dc1e1: 'Pool Type',
|
||||||
|
kd4a0fd1e: 'Pool Name',
|
||||||
|
kd4af6368: 'Issue Date',
|
||||||
|
kd4d50566: 'ID documents or a signed contract are missing from object storage. The user’s verification status should be checked.',
|
||||||
|
kd4eb7ee0: 'Close menu',
|
||||||
|
kd51f320c: 'Exoscale Folder Structure',
|
||||||
|
kd56a13f2: 'Recipient Email',
|
||||||
|
kd5cca6e9: '? This action cannot be undone.',
|
||||||
|
kd6024811: 'PDF File',
|
||||||
|
kd642e230: 'Search by name or email. Minimum 3 characters. Existing matrix members are hidden.',
|
||||||
|
kd68da70d: 'Nachhaltige Produkte für deinen Erfolg',
|
||||||
|
kd89474fa: 'Back to News',
|
||||||
|
kda96f5b3: 'Matrix Depth',
|
||||||
|
kdb27a82d: '‹ Previous',
|
||||||
|
kdbba338d: 'Registration Number:',
|
||||||
|
kdc22ad8a: 'Manage matrices, see stats, and create new ones.',
|
||||||
|
kdc47630b: 'Matrix fill:',
|
||||||
|
kdca959c3: 'Discover a curated selection of high-quality products that cater to your every need.',
|
||||||
|
kde1c3c69: 'Logo Image',
|
||||||
|
kde2b4fa0: 'Result Summary',
|
||||||
|
ke0a3528a: 'Passwords do not match.',
|
||||||
|
ke17859b2: 'Business Registration',
|
||||||
|
ke19afb3d: 'Archive this pool',
|
||||||
|
ke1abc7d9: 'Add Affiliate',
|
||||||
|
ke24abf9c: 'Edit coffee content',
|
||||||
|
ke3889dc2: 'Loading news...',
|
||||||
|
ke4c4a858: 'Min. 3 characters',
|
||||||
|
ke697b8cb: 'Set Active',
|
||||||
|
ke8b9f33c: 'Total in Pool',
|
||||||
|
ke9e71971: 'Oder weiter mit',
|
||||||
|
kebf33594: 'Filter by category:',
|
||||||
|
kec5a5357: 'Upload Invoice',
|
||||||
|
keccee79f: 'Email address',
|
||||||
|
ked60db76: 'Back to login',
|
||||||
|
kee28b8c6: '🔑 Token Status',
|
||||||
|
kef1656df: 'Apply Crop',
|
||||||
|
kefd5231d: 'Depth 5',
|
||||||
|
kf0646f35: 'Our values',
|
||||||
|
kf0d33884: 'Check browser console for detailed logs',
|
||||||
|
kf0eef57e: 'Total members:',
|
||||||
|
kf2147f07: 'Not provided',
|
||||||
|
kf2180ff6: 'Manage VAT rates',
|
||||||
|
kf27e4502: 'Ready to Verify',
|
||||||
|
kf2a1257e: 'Back to profile',
|
||||||
|
kf2b5c1a6: 'Customer Name',
|
||||||
|
kf2d8db2b: 'Updating status...',
|
||||||
|
kf340aa10: 'Loose files:',
|
||||||
|
kf3557acd: '5‑ary Tree',
|
||||||
|
kf3b81ba3: 'Used in the URL. Auto-generated from title unless edited.',
|
||||||
|
kf4868273: 'Click to upload',
|
||||||
|
kf4f44e2f: 'e.g. ATU12345678',
|
||||||
|
kf530c357: 'Anmeldung läuft...',
|
||||||
|
kf663ef67: 'Shop with an infinite variety of products',
|
||||||
|
kf69154f8: '• Stay on topic',
|
||||||
|
kf70b9896: 'e.g. 12345',
|
||||||
|
kf7189e80: '🔄 Manual Refresh Token',
|
||||||
|
kf78c9087: 'Immediate children',
|
||||||
|
kf7a91674: 'Policy ↑',
|
||||||
|
kf7a91676: 'Policy ↓',
|
||||||
|
kf823daf7: 'Fallback to root if referral parent not in matrix',
|
||||||
|
kf8c220d3: 'kunde@example.com',
|
||||||
|
kf971ea7f: 'Created:',
|
||||||
|
kfaa8fc4a: '• Will book:',
|
||||||
|
kfb1676b0: 'Phone number *',
|
||||||
|
kfb37e056: 'Last Login:',
|
||||||
|
kfb92efe9: 'Description *',
|
||||||
|
kfce271a2: 'Node Env:',
|
||||||
|
kfdcad59b: 'Send Email Report',
|
||||||
|
kfe8083f8: 'First Name',
|
||||||
|
k17581b31: 'Next page',
|
||||||
|
k2108b5a0: 'No invoices found for this subscription.',
|
||||||
|
k34a0a2e4: 'Select the files where you want to run i18n auto-fix.',
|
||||||
|
k41f3daea: 'Billed monthly',
|
||||||
|
k43218db0: 'Fix Targets',
|
||||||
|
k49e51b5f: 'Current plan',
|
||||||
|
k4bfb4f28: 'Feature comparison',
|
||||||
|
k4c6eb72c: 'Select all',
|
||||||
|
k4f209a66: 'You currently don’t have an active subscription.',
|
||||||
|
k5b7042c7: 'Previous page',
|
||||||
|
k60b1e339: 'No media or documents found.',
|
||||||
|
k6569783c: 'Use this to include server-style files. Files with server-only Next.js APIs are skipped for safety.',
|
||||||
|
k68c88f41: 'Force convert selected files to client components before auto-fix',
|
||||||
|
k74914369: 'Delete Item',
|
||||||
|
k772cc77b: 'Complete your profile to unlock all features',
|
||||||
|
k7fa55432: 'My Subscription',
|
||||||
|
k86b03343: 'Billed annually',
|
||||||
|
k8953de89: 'Finance & Invoices',
|
||||||
|
k947d8777: 'Invoice #',
|
||||||
|
ka5603827: 'Loading invoices…',
|
||||||
|
ka86bdc9b: 'Payment frequency',
|
||||||
|
kb3243742: 'No file',
|
||||||
|
kc48b877b: 'No subscription selected. Invoices will appear once you have an active subscription.',
|
||||||
|
kd08b698a: 'Profile Completion',
|
||||||
|
ke3480838: 'No fixable hardcoded UI text detected in eligible components.',
|
||||||
|
ked7d533b: 'Media & Documents',
|
||||||
|
kf5ac16fb: 'Pricing that grows with you',
|
||||||
|
kf9f94d5e: 'Buy this plan',
|
||||||
|
kfd632d02: 'Export all invoices',
|
||||||
|
k5d4f6b2f: 'Bank Information',
|
||||||
|
k9dafde30: 'Contact Person',
|
||||||
|
kada9d61c: 'Account Holder',
|
||||||
|
kde6d477f: 'Email Address',
|
||||||
|
kfc6b6a29: 'Editing disabled',
|
||||||
|
k03538639: 'e.g. fr, es, zh-TW',
|
||||||
|
k5fcc9b0e: 'Delete language',
|
||||||
|
k9bd0812b: 'Shows why a file was changed, skipped, or left untouched after a fix attempt.',
|
||||||
|
ka019b3c0: 'e.g. Français',
|
||||||
|
kbe30c353: 'Coverage by namespace',
|
||||||
|
kbf49d59b: 'Search keys or English text…',
|
||||||
|
kc8034db6: 'Auto-fix debug logs',
|
||||||
|
k0cdc3ee9: 'Assign namespace...',
|
||||||
|
k1db86f96: 'Create tab',
|
||||||
|
k505ebdae: 'Unassigned namespaces',
|
||||||
|
k5f978731: 'Category tabs',
|
||||||
|
k66edf1eb: 'Drag into a tab card',
|
||||||
|
k741a01f7: 'All namespaces are categorized.',
|
||||||
|
ka6791a02: 'Remove from tab',
|
||||||
|
kb7a30760: 'Filter namespaces quickly or open manager to create and assign tabs.',
|
||||||
|
kc4671abe: 'Create tabs and assign namespaces by drag-and-drop or dropdown.',
|
||||||
|
kd6e42900: 'Manage tabs',
|
||||||
|
ke52ed6e9: 'New tab name',
|
||||||
|
kef9de7f0: 'Manage category tabs',
|
||||||
|
kf3c3223a: 'Drop a namespace here.',
|
||||||
|
k0700b1f2: 'No global keys yet. Mark keys as global in the translation wizard, or add one from the dropdown above.',
|
||||||
|
k47bce570: 'Select key to add to global',
|
||||||
|
k5e5e8744: 'Translation wizard available',
|
||||||
|
k6aba2cb0: 'Back to panels',
|
||||||
|
k6cfeedd3: 'Global terms',
|
||||||
|
k725dd1d6: 'Start wizard',
|
||||||
|
kad7d8c49: 'Use this list for cross-context terms like IBAN.',
|
||||||
|
kc02b17c3: 'Remove from global terms',
|
||||||
|
kc518ff5c: 'English reference',
|
||||||
|
kcd190bdd: 'Translation wizard',
|
||||||
|
kfd1e0089: 'Auto-scroll on panel open',
|
||||||
|
},
|
||||||
|
|
||||||
// ─── Notifications / Toasts ────────────────────────────
|
// ─── Notifications / Toasts ────────────────────────────
|
||||||
toasts: {
|
toasts: {
|
||||||
loginSuccess: 'Login successful',
|
loginSuccess: 'Login successful',
|
||||||
|
|||||||
@ -145,6 +145,36 @@ export interface Translations {
|
|||||||
sessionDetectedMessage: string;
|
sessionDetectedMessage: string;
|
||||||
sessionContinue: string;
|
sessionContinue: string;
|
||||||
sessionLogout: string;
|
sessionLogout: string;
|
||||||
|
formTitle: string;
|
||||||
|
guestRegistration: string;
|
||||||
|
registerNow: string;
|
||||||
|
guestDescription: string;
|
||||||
|
personalDescription: string;
|
||||||
|
invitedBy: string;
|
||||||
|
tabIndividual: string;
|
||||||
|
submitCompany: string;
|
||||||
|
submitGuest: string;
|
||||||
|
successRedirecting: string;
|
||||||
|
guestNote: string;
|
||||||
|
errorBothCountryCodes: string;
|
||||||
|
successCompanyMessage: string;
|
||||||
|
successGuestMessage: string;
|
||||||
|
failedTitle: string;
|
||||||
|
failedMessage: string;
|
||||||
|
networkErrorGeneric: string;
|
||||||
|
passwordRequirements: string;
|
||||||
|
pwdMinLength: string;
|
||||||
|
pwdLowercase: string;
|
||||||
|
pwdUppercase: string;
|
||||||
|
pwdDigits: string;
|
||||||
|
pwdSpecial: string;
|
||||||
|
invalidLinkTitle: string;
|
||||||
|
invalidLinkMessage: string;
|
||||||
|
tokenLabel: string;
|
||||||
|
sessionDescription: string;
|
||||||
|
goToDashboard: string;
|
||||||
|
goToHomepage: string;
|
||||||
|
loginHere: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
passwordReset: {
|
passwordReset: {
|
||||||
@ -368,16 +398,111 @@ export interface Translations {
|
|||||||
referralManagement: {
|
referralManagement: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
description: string;
|
||||||
createLink: string;
|
createLink: string;
|
||||||
copyLink: string;
|
copyLink: string;
|
||||||
|
copy: string;
|
||||||
|
copyMobile: string;
|
||||||
|
copied: string;
|
||||||
copiedToClipboard: string;
|
copiedToClipboard: string;
|
||||||
linkExpiry: string;
|
linkExpiry: string;
|
||||||
noLinks: string;
|
noLinks: string;
|
||||||
generating: string;
|
generating: string;
|
||||||
|
generateLink: string;
|
||||||
usesRemaining: string;
|
usesRemaining: string;
|
||||||
unlimited: string;
|
unlimited: string;
|
||||||
|
never: string;
|
||||||
createSuccess: string;
|
createSuccess: string;
|
||||||
createError: string;
|
createError: string;
|
||||||
|
deactivate: string;
|
||||||
|
deactivated: string;
|
||||||
|
deactivatedMessage: string;
|
||||||
|
deactivateFailed: string;
|
||||||
|
deactivateFailedMessage: string;
|
||||||
|
deactivateNetworkError: string;
|
||||||
|
deactivateModalTitle: string;
|
||||||
|
deactivateModalDescription: string;
|
||||||
|
linkLabel: string;
|
||||||
|
accessCheckFailed: string;
|
||||||
|
userIdMissing: string;
|
||||||
|
accessDenied: string;
|
||||||
|
accessDeniedMessage: string;
|
||||||
|
permCheckFailed: string;
|
||||||
|
permCheckFailedMessage: string;
|
||||||
|
loadFailed: string;
|
||||||
|
loadStatsError: string;
|
||||||
|
loadLinksError: string;
|
||||||
|
copyFailed: string;
|
||||||
|
copyFailedMessage: string;
|
||||||
|
copiedMessage: string;
|
||||||
|
allLinks: string;
|
||||||
|
allLinksSubtitle: string;
|
||||||
|
colLink: string;
|
||||||
|
colCreated: string;
|
||||||
|
colExpires: string;
|
||||||
|
colUsage: string;
|
||||||
|
colStatus: string;
|
||||||
|
generateTitle: string;
|
||||||
|
maxUsesLabel: string;
|
||||||
|
expiresIn: string;
|
||||||
|
lockedByNeverExpires: string;
|
||||||
|
lockedByUnlimited: string;
|
||||||
|
statsActiveLinks: string;
|
||||||
|
statsLinksUsed: string;
|
||||||
|
statsPersonalUsers: string;
|
||||||
|
statsCompanyUsers: string;
|
||||||
|
statsTotalLinks: string;
|
||||||
|
levelStarter: string;
|
||||||
|
levelNovice: string;
|
||||||
|
levelHustler: string;
|
||||||
|
levelEntrepreneur: string;
|
||||||
|
levelPrestige: string;
|
||||||
|
levelMax: string;
|
||||||
|
levelLabel: string;
|
||||||
|
referrals: string;
|
||||||
|
of: string;
|
||||||
|
maxLevelReached: string;
|
||||||
|
nextMilestone: string;
|
||||||
|
registeredUsersTitle: string;
|
||||||
|
totalRefBadge: string;
|
||||||
|
registeredUsersSubtitle: string;
|
||||||
|
showingLatest5: string;
|
||||||
|
viewAll: string;
|
||||||
|
colUser: string;
|
||||||
|
colEmail: string;
|
||||||
|
colType: string;
|
||||||
|
colRegistered: string;
|
||||||
|
noRegisteredUsers: string;
|
||||||
|
typeCompany: string;
|
||||||
|
typePersonal: string;
|
||||||
|
allRegisteredUsersTitle: string;
|
||||||
|
allRegisteredUsersSubtitle: string;
|
||||||
|
exportCsv: string;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
filterAllTypes: string;
|
||||||
|
filterAllStatus: string;
|
||||||
|
filterActive: string;
|
||||||
|
filterInactive: string;
|
||||||
|
filterPending: string;
|
||||||
|
filterBlocked: string;
|
||||||
|
noUsersMatchFilters: string;
|
||||||
|
showing: string;
|
||||||
|
pagePrev: string;
|
||||||
|
pageNext: string;
|
||||||
|
pageOf: string;
|
||||||
|
expiry1Day: string;
|
||||||
|
expiry2Days: string;
|
||||||
|
expiry3Days: string;
|
||||||
|
expiry4Days: string;
|
||||||
|
expiry5Days: string;
|
||||||
|
expiry6Days: string;
|
||||||
|
expiry7Days: string;
|
||||||
|
expiryNever: string;
|
||||||
|
maxUses1: string;
|
||||||
|
maxUses5: string;
|
||||||
|
maxUses10: string;
|
||||||
|
maxUses50: string;
|
||||||
|
maxUsesUnlimited: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
quickactionDashboard: {
|
quickactionDashboard: {
|
||||||
@ -759,6 +884,7 @@ export interface Translations {
|
|||||||
codeDuplicate: string;
|
codeDuplicate: string;
|
||||||
codeRequired: string;
|
codeRequired: string;
|
||||||
nameRequired: string;
|
nameRequired: string;
|
||||||
|
wizardInputPlaceholder: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
contractManagement: {
|
contractManagement: {
|
||||||
@ -892,6 +1018,8 @@ export interface Translations {
|
|||||||
reasonNoActivePools: string;
|
reasonNoActivePools: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
autofix: Record<string, string>;
|
||||||
|
|
||||||
// ─── Notifications / Toasts ────────────────────────────
|
// ─── Notifications / Toasts ────────────────────────────
|
||||||
toasts: {
|
toasts: {
|
||||||
loginSuccess: string;
|
loginSuccess: string;
|
||||||
|
|||||||
@ -1,22 +1,47 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||||
import { Language, DEFAULT_LANGUAGE } from './config';
|
import { DEFAULT_LANGUAGE } from './config';
|
||||||
import { en } from './translations/en';
|
import { en } from './translations/en';
|
||||||
import { de } from './translations/de';
|
import { de } from './translations/de';
|
||||||
import { flattenObject, loadCustomI18n, type CustomI18nData } from './dynamicTranslations';
|
import { flattenObject } from './dynamicTranslations';
|
||||||
|
|
||||||
const builtInTranslations: Record<string, Record<string, any>> = { en, de };
|
type LanguageEntry = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TranslationFilesPayload = {
|
||||||
|
languages: LanguageEntry[];
|
||||||
|
translations: Record<string, Record<string, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const builtInTranslations: Record<string, Record<string, unknown>> = {
|
||||||
|
en: en as unknown as Record<string, unknown>,
|
||||||
|
de: de as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
|
||||||
// Flat map of English keys used as canonical key list and fallback
|
// Flat map of English keys used as canonical key list and fallback
|
||||||
const enFlat = flattenObject(en as Record<string, any>);
|
const enFlat = flattenObject(en as unknown as Record<string, unknown>);
|
||||||
|
|
||||||
|
function getNestedValue(root: Record<string, unknown>, key: string): string | null {
|
||||||
|
const segments = key.split('.');
|
||||||
|
let current: unknown = root;
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (!current || typeof current !== 'object') return null;
|
||||||
|
current = (current as Record<string, unknown>)[segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof current === 'string' ? current : null;
|
||||||
|
}
|
||||||
|
|
||||||
interface I18nContextType {
|
interface I18nContextType {
|
||||||
language: string;
|
language: string;
|
||||||
setLanguage: (lang: string) => void;
|
setLanguage: (lang: string) => void;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
customI18n: CustomI18nData;
|
languages: LanguageEntry[];
|
||||||
reloadCustomI18n: () => void;
|
reloadTranslations: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
||||||
@ -27,38 +52,84 @@ interface I18nProviderProps {
|
|||||||
|
|
||||||
export function I18nProvider({ children }: I18nProviderProps) {
|
export function I18nProvider({ children }: I18nProviderProps) {
|
||||||
const [language, setLanguage] = useState<string>(DEFAULT_LANGUAGE);
|
const [language, setLanguage] = useState<string>(DEFAULT_LANGUAGE);
|
||||||
const [customI18n, setCustomI18n] = useState<CustomI18nData>({ languages: [], translations: {} });
|
const [translationFiles, setTranslationFiles] = useState<TranslationFilesPayload>({
|
||||||
|
languages: [
|
||||||
|
{ code: 'en', name: 'English' },
|
||||||
|
{ code: 'de', name: 'Deutsch' },
|
||||||
|
],
|
||||||
|
translations: {
|
||||||
|
en: enFlat,
|
||||||
|
de: flattenObject(de as unknown as Record<string, unknown>),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const reloadCustomI18n = useCallback(() => {
|
const reloadTranslations = useCallback(async () => {
|
||||||
setCustomI18n(loadCustomI18n());
|
try {
|
||||||
|
const response = await fetch('/api/i18n/translations', { cache: 'no-store' });
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !result?.ok) return;
|
||||||
|
|
||||||
|
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>>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (languages.length > 0) {
|
||||||
|
setTranslationFiles({ languages, translations });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep built-in fallback translations if API is unavailable.
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reloadCustomI18n();
|
void reloadTranslations();
|
||||||
}, [reloadCustomI18n]);
|
}, [reloadTranslations]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (translationFiles.languages.length === 0) return;
|
||||||
|
if (translationFiles.languages.some((entry) => entry.code === language)) return;
|
||||||
|
|
||||||
|
const fallback = translationFiles.languages.find((entry) => entry.code === DEFAULT_LANGUAGE)?.code
|
||||||
|
?? translationFiles.languages[0]?.code
|
||||||
|
?? DEFAULT_LANGUAGE;
|
||||||
|
setLanguage(fallback);
|
||||||
|
}, [translationFiles.languages, language]);
|
||||||
|
|
||||||
const t = useCallback((key: string): string => {
|
const t = useCallback((key: string): string => {
|
||||||
// 1. Check custom translation overrides for this language
|
// 1. Check translation loaded from translation files API.
|
||||||
const customOverride = customI18n.translations[language]?.[key];
|
const fileValue = translationFiles.translations[language]?.[key];
|
||||||
if (customOverride !== undefined && customOverride !== '') return customOverride;
|
if (fileValue !== undefined && fileValue !== '') return fileValue;
|
||||||
|
|
||||||
// 2. Check built-in translations (nested lookup)
|
// 2. Check built-in static imports (works even before API load).
|
||||||
const builtIn = builtInTranslations[language];
|
const builtIn = builtInTranslations[language];
|
||||||
if (builtIn) {
|
if (builtIn) {
|
||||||
const keys = key.split('.');
|
const value = getNestedValue(builtIn, key);
|
||||||
let value: any = builtIn;
|
if (value !== null) return value;
|
||||||
for (const k of keys) {
|
|
||||||
value = value?.[k];
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fallback to English (flat map)
|
// 3. Fallback to English.
|
||||||
return enFlat[key] ?? key;
|
return enFlat[key] ?? key;
|
||||||
}, [language, customI18n]);
|
}, [language, translationFiles.translations]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nContext.Provider value={{ language, setLanguage, t, customI18n, reloadCustomI18n }}>
|
<I18nContext.Provider
|
||||||
|
value={{
|
||||||
|
language,
|
||||||
|
setLanguage,
|
||||||
|
t,
|
||||||
|
languages: translationFiles.languages,
|
||||||
|
reloadTranslations,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</I18nContext.Provider>
|
</I18nContext.Provider>
|
||||||
);
|
);
|
||||||
@ -81,10 +152,3 @@ export function getAllTranslationKeys(): string[] {
|
|||||||
export function getEnglishValue(key: string): string {
|
export function getEnglishValue(key: string): string {
|
||||||
return enFlat[key] ?? key;
|
return enFlat[key] ?? key;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns flat translations for a built-in language */
|
|
||||||
export function getBuiltInFlatTranslations(langCode: string): Record<string, string> {
|
|
||||||
const builtIn = builtInTranslations[langCode];
|
|
||||||
if (!builtIn) return {};
|
|
||||||
return flattenObject(builtIn);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
|
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
|
||||||
@ -9,6 +12,7 @@ import { useToast } from '../../components/toast/toastComponent'
|
|||||||
const GLASS_BG = 'rgba(255,255,255,0.55)'
|
const GLASS_BG = 'rgba(255,255,255,0.55)'
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
@ -158,12 +162,8 @@ export default function LoginForm() {
|
|||||||
>
|
>
|
||||||
{/* Title + Subtitle (restored) */}
|
{/* Title + Subtitle (restored) */}
|
||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold tracking-tight text-[#0F172A] drop-shadow-sm">
|
<h1 className="text-2xl md:text-3xl font-extrabold tracking-tight text-[#0F172A] drop-shadow-sm">{t('autofix.k3d01de91')}</h1>
|
||||||
PROFIT PLANET
|
<p className="mt-1 text-sm md:text-base text-slate-700/90">{t('autofix.k00394342')}</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm md:text-base text-slate-700/90">
|
|
||||||
Welcome back! Log in to continue.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@ -182,9 +182,7 @@ export default function LoginForm() {
|
|||||||
fontSize: isMobile ? '0.875rem' : isTablet ? '0.9rem' : undefined,
|
fontSize: isMobile ? '0.875rem' : isTablet ? '0.9rem' : undefined,
|
||||||
marginBottom: isMobile ? '0.25rem' : undefined,
|
marginBottom: isMobile ? '0.25rem' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>{t('autofix.keccee79f')}</label>
|
||||||
Email address
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
@ -197,7 +195,7 @@ export default function LoginForm() {
|
|||||||
fontSize: isMobile ? '0.95rem' : isTablet ? '1rem' : '1rem',
|
fontSize: isMobile ? '0.95rem' : isTablet ? '1rem' : '1rem',
|
||||||
padding: isMobile ? '0.5rem 0.75rem' : isTablet ? '0.6rem 0.875rem' : '0.7rem 1rem',
|
padding: isMobile ? '0.5rem 0.75rem' : isTablet ? '0.6rem 0.875rem' : '0.7rem 1rem',
|
||||||
}}
|
}}
|
||||||
placeholder="you@example.com"
|
placeholder={t('autofix.k8dda5201')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -231,7 +229,7 @@ export default function LoginForm() {
|
|||||||
? '0.6rem 2.75rem 0.6rem 0.875rem'
|
? '0.6rem 2.75rem 0.6rem 0.875rem'
|
||||||
: '0.7rem 3rem 0.7rem 1rem',
|
: '0.7rem 3rem 0.7rem 1rem',
|
||||||
}}
|
}}
|
||||||
placeholder="Your password"
|
placeholder={t('autofix.k6d85810b')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -272,9 +270,7 @@ export default function LoginForm() {
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>{t('autofix.k2786bc5f')}</div>
|
||||||
Signing in...
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
'Sign in'
|
'Sign in'
|
||||||
)}
|
)}
|
||||||
@ -287,9 +283,7 @@ export default function LoginForm() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="text-[#8D6B1D] hover:text-[#7A5E1A] hover:underline text-sm font-medium transition-colors"
|
className="text-[#8D6B1D] hover:text-[#7A5E1A] hover:underline text-sm font-medium transition-colors"
|
||||||
onClick={() => router.push("/password-reset")}
|
onClick={() => router.push("/password-reset")}
|
||||||
>
|
>{t('autofix.kc3d181e2')}</button>
|
||||||
Forgot password?
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
@ -7,6 +10,7 @@ import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
|
|||||||
import { useLogin } from '../hooks/useLogin'
|
import { useLogin } from '../hooks/useLogin'
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
@ -68,14 +72,10 @@ export default function LoginForm() {
|
|||||||
<div className="flex flex-1 flex-col justify-center px-4 py-2 sm:px-6 lg:flex-none lg:px-20 xl:px-24 bg-[#0F172A]">
|
<div className="flex flex-1 flex-col justify-center px-4 py-2 sm:px-6 lg:flex-none lg:px-20 xl:px-24 bg-[#0F172A]">
|
||||||
<div className="mx-auto w-full max-w-sm lg:w-96">
|
<div className="mx-auto w-full max-w-sm lg:w-96">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mt-8 text-2xl/9 font-bold tracking-tight text-white">
|
<h2 className="mt-8 text-2xl/9 font-bold tracking-tight text-white">{t('autofix.k47bbd37e')}</h2>
|
||||||
Sign in to Profit Planet
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-sm/6 text-gray-400">
|
<p className="mt-2 text-sm/6 text-gray-400">
|
||||||
Noch kein Mitglied?{' '}
|
Noch kein Mitglied?{' '}
|
||||||
<a href="/register" className="font-semibold text-[#8D6B1D] hover:text-[#A67C20]">
|
<a href="/register" className="font-semibold text-[#8D6B1D] hover:text-[#A67C20]">{t('autofix.k0fbaa1a9')}</a>
|
||||||
Jetzt registrieren
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -96,7 +96,7 @@ export default function LoginForm() {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
placeholder="deine@email.com"
|
placeholder={t('autofix.kcd7a1625')}
|
||||||
className="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-[#8D6B1D] sm:text-sm/6"
|
className="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-[#8D6B1D] sm:text-sm/6"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -116,7 +116,7 @@ export default function LoginForm() {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
placeholder="Dein Passwort"
|
placeholder={t('autofix.kce0ab46c')}
|
||||||
className="block w-full rounded-md bg-white/5 px-3 py-1.5 pr-10 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-[#8D6B1D] sm:text-sm/6"
|
className="block w-full rounded-md bg-white/5 px-3 py-1.5 pr-10 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-[#8D6B1D] sm:text-sm/6"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -161,9 +161,7 @@ export default function LoginForm() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label htmlFor="rememberMe" className="block text-sm/6 text-gray-300">
|
<label htmlFor="rememberMe" className="block text-sm/6 text-gray-300">{t('autofix.k61c2a732')}</label>
|
||||||
Angemeldet bleiben
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm/6">
|
<div className="text-sm/6">
|
||||||
@ -171,9 +169,7 @@ export default function LoginForm() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push("/password-reset")}
|
onClick={() => router.push("/password-reset")}
|
||||||
className="font-semibold text-[#8D6B1D] hover:text-[#A67C20]"
|
className="font-semibold text-[#8D6B1D] hover:text-[#A67C20]"
|
||||||
>
|
>{t('autofix.k88d8bb9d')}</button>
|
||||||
Passwort vergessen?
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -193,9 +189,7 @@ export default function LoginForm() {
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>{t('autofix.kf530c357')}</div>
|
||||||
Anmeldung läuft...
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
'Anmelden'
|
'Anmelden'
|
||||||
)}
|
)}
|
||||||
@ -211,7 +205,7 @@ export default function LoginForm() {
|
|||||||
<div className="w-full border-t border-gray-700" />
|
<div className="w-full border-t border-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-sm/6 font-medium">
|
<div className="relative flex justify-center text-sm/6 font-medium">
|
||||||
<span className="bg-[#0F172A] px-6 text-gray-300">Oder weiter mit</span>
|
<span className="bg-[#0F172A] px-6 text-gray-300">{t('autofix.ke9e71971')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -263,19 +257,15 @@ export default function LoginForm() {
|
|||||||
{/* Right Side Image */}
|
{/* Right Side Image */}
|
||||||
<div className="relative hidden w-0 flex-1 lg:block overflow-hidden bg-[#0F172A]">
|
<div className="relative hidden w-0 flex-1 lg:block overflow-hidden bg-[#0F172A]">
|
||||||
<img
|
<img
|
||||||
alt="Community Hands - Profit Planet"
|
alt={t('autofix.k17ba59ff')}
|
||||||
src="/images/misc/community_hands.jpg"
|
src="/images/misc/community_hands.jpg"
|
||||||
className="absolute inset-0 size-full object-cover rounded-l-3xl"
|
className="absolute inset-0 size-full object-cover rounded-l-3xl"
|
||||||
/>
|
/>
|
||||||
{/* Overlay with branding */}
|
{/* Overlay with branding */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-[#8D6B1D]/20 via-transparent to-[#8D6B1D]/10" />
|
<div className="absolute inset-0 bg-gradient-to-br from-[#8D6B1D]/20 via-transparent to-[#8D6B1D]/10" />
|
||||||
<div className="absolute bottom-8 left-8 right-8">
|
<div className="absolute bottom-8 left-8 right-8">
|
||||||
<h3 className="text-3xl font-bold text-white mb-4 drop-shadow-lg">
|
<h3 className="text-3xl font-bold text-white mb-4 drop-shadow-lg">{t('autofix.k6b76bd0e')}</h3>
|
||||||
Willkommen bei Profit Planet
|
<p className="text-lg text-white/90 drop-shadow-md">{t('autofix.k9f29dbfb')}</p>
|
||||||
</h3>
|
|
||||||
<p className="text-lg text-white/90 drop-shadow-md">
|
|
||||||
Entdecke nachhaltige Produkte und verdiene dabei. Deine Plattform für bewussten Konsum und finanzielle Vorteile.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import LoginForm from './components/LoginForm'
|
import LoginForm from './components/LoginForm'
|
||||||
@ -12,6 +15,7 @@ import BlueBlurryBackground from '../components/background/blueblurry' // NEW
|
|||||||
import CurvedLoop from '../components/curvedLoop'
|
import CurvedLoop from '../components/curvedLoop'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [hasHydrated, setHasHydrated] = useState(false)
|
const [hasHydrated, setHasHydrated] = useState(false)
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -50,7 +54,7 @@ export default function LoginPage() {
|
|||||||
<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-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||||
<p className="text-slate-700">You are already logged in. Redirecting...</p>
|
<p className="text-slate-700">{t('autofix.kbf4b7789')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { CheckIcon, XMarkIcon } from '@heroicons/react/20/solid'
|
import { CheckIcon, XMarkIcon } from '@heroicons/react/20/solid'
|
||||||
import PageLayout from '../components/PageLayout'
|
import PageLayout from '../components/PageLayout'
|
||||||
|
|
||||||
@ -76,21 +81,20 @@ function classNames(...classes: (string | undefined | null | false)[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MembershipsPage() {
|
export default function MembershipsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<form className="group/tiers isolate bg-gray-900 pb-24">
|
<form className="group/tiers isolate bg-gray-900 pb-24">
|
||||||
<div className="flow-root border-b border-b-white/5 bg-gray-800/25 pt-24 pb-16 sm:pt-32 lg:pb-0">
|
<div className="flow-root border-b border-b-white/5 bg-gray-800/25 pt-24 pb-16 sm:pt-32 lg:pb-0">
|
||||||
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<h2 className="mx-auto max-w-4xl text-center text-5xl font-semibold tracking-tight text-balance text-white sm:text-6xl">
|
<h2 className="mx-auto max-w-4xl text-center text-5xl font-semibold tracking-tight text-balance text-white sm:text-6xl">{t('autofix.kf5ac16fb')}</h2>
|
||||||
Pricing that grows with you
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto mt-6 max-w-2xl text-center text-lg font-medium text-pretty text-gray-400 sm:text-xl/8">
|
<p className="mx-auto mt-6 max-w-2xl text-center text-lg font-medium text-pretty text-gray-400 sm:text-xl/8">
|
||||||
Choose an affordable plan that’s packed with the best features for engaging your audience, creating
|
Choose an affordable plan that’s packed with the best features for engaging your audience, creating
|
||||||
customer loyalty, and driving sales.
|
customer loyalty, and driving sales.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-16 flex justify-center">
|
<div className="mt-16 flex justify-center">
|
||||||
<fieldset aria-label="Payment frequency">
|
<fieldset aria-label={t('autofix.ka86bdc9b')}>
|
||||||
<div className="grid grid-cols-2 gap-x-1 rounded-full bg-white/5 p-1 text-center text-xs/5 font-semibold text-white">
|
<div className="grid grid-cols-2 gap-x-1 rounded-full bg-white/5 p-1 text-center text-xs/5 font-semibold text-white">
|
||||||
<label className="group relative rounded-full px-2.5 py-1 has-checked:bg-indigo-500">
|
<label className="group relative rounded-full px-2.5 py-1 has-checked:bg-indigo-500">
|
||||||
<input
|
<input
|
||||||
@ -158,12 +162,8 @@ export default function MembershipsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<p className="text-white">USD</p>
|
<p className="text-white">USD</p>
|
||||||
<p className="text-gray-400 group-not-has-[[name=frequency][value=monthly]:checked]/tiers:hidden">
|
<p className="text-gray-400 group-not-has-[[name=frequency][value=monthly]:checked]/tiers:hidden">{t('autofix.k41f3daea')}</p>
|
||||||
Billed monthly
|
<p className="text-gray-400 group-not-has-[[name=frequency][value=annually]:checked]/tiers:hidden">{t('autofix.k86b03343')}</p>
|
||||||
</p>
|
|
||||||
<p className="text-gray-400 group-not-has-[[name=frequency][value=annually]:checked]/tiers:hidden">
|
|
||||||
Billed annually
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -172,9 +172,7 @@ export default function MembershipsPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
aria-describedby={`tier-${tier.id}`}
|
aria-describedby={`tier-${tier.id}`}
|
||||||
className="w-full rounded-md bg-white/10 px-3 py-2 text-center text-sm/6 font-semibold text-white not-group-data-featured:inset-ring not-group-data-featured:inset-ring-white/5 group-data-featured/tier:bg-indigo-500 hover:bg-white/20 group-data-featured/tier:hover:bg-indigo-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/75 group-data-featured/tier:focus-visible:outline-indigo-500"
|
className="w-full rounded-md bg-white/10 px-3 py-2 text-center text-sm/6 font-semibold text-white not-group-data-featured:inset-ring not-group-data-featured:inset-ring-white/5 group-data-featured/tier:bg-indigo-500 hover:bg-white/20 group-data-featured/tier:hover:bg-indigo-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/75 group-data-featured/tier:focus-visible:outline-indigo-500"
|
||||||
>
|
>{t('autofix.kf9f94d5e')}</button>
|
||||||
Buy this plan
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 flow-root sm:mt-10">
|
<div className="mt-8 flow-root sm:mt-10">
|
||||||
<ul
|
<ul
|
||||||
@ -202,9 +200,7 @@ export default function MembershipsPage() {
|
|||||||
<div className="mx-auto max-w-7xl px-6 py-24 sm:py-32 lg:px-8">
|
<div className="mx-auto max-w-7xl px-6 py-24 sm:py-32 lg:px-8">
|
||||||
{/* Feature comparison (up to lg) */}
|
{/* Feature comparison (up to lg) */}
|
||||||
<section aria-labelledby="mobile-comparison-heading" className="lg:hidden">
|
<section aria-labelledby="mobile-comparison-heading" className="lg:hidden">
|
||||||
<h2 id="mobile-comparison-heading" className="sr-only">
|
<h2 id="mobile-comparison-heading" className="sr-only">{t('autofix.k4bfb4f28')}</h2>
|
||||||
Feature comparison
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mx-auto max-w-2xl space-y-16">
|
<div className="mx-auto max-w-2xl space-y-16">
|
||||||
{tiers.map((tier) => (
|
{tiers.map((tier) => (
|
||||||
@ -293,9 +289,7 @@ export default function MembershipsPage() {
|
|||||||
|
|
||||||
{/* Feature comparison (lg+) */}
|
{/* Feature comparison (lg+) */}
|
||||||
<section aria-labelledby="comparison-heading" className="hidden lg:block">
|
<section aria-labelledby="comparison-heading" className="hidden lg:block">
|
||||||
<h2 id="comparison-heading" className="sr-only">
|
<h2 id="comparison-heading" className="sr-only">{t('autofix.k4bfb4f28')}</h2>
|
||||||
Feature comparison
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-x-8 border-t border-white/10 before:block">
|
<div className="grid grid-cols-4 gap-x-8 border-t border-white/10 before:block">
|
||||||
{tiers.map((tier) => (
|
{tiers.map((tier) => (
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
@ -25,6 +28,7 @@ function estimateReadingTime(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NewsDetailPage() {
|
export default function NewsDetailPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const slug = typeof params?.slug === 'string' ? params.slug : Array.isArray(params?.slug) ? params.slug[0] : ''
|
const slug = typeof params?.slug === 'string' ? params.slug : Array.isArray(params?.slug) ? params.slug[0] : ''
|
||||||
|
|
||||||
@ -90,9 +94,7 @@ export default function NewsDetailPage() {
|
|||||||
{error && !loading && (
|
{error && !loading && (
|
||||||
<div className="rounded-2xl bg-white shadow-lg border border-red-100 p-8">
|
<div className="rounded-2xl bg-white shadow-lg border border-red-100 p-8">
|
||||||
<div className="text-red-700 font-medium mb-2">{error}</div>
|
<div className="text-red-700 font-medium mb-2">{error}</div>
|
||||||
<Link href="/news" className="text-blue-900 hover:text-blue-700 font-semibold">
|
<Link href="/news" className="text-blue-900 hover:text-blue-700 font-semibold">{t('autofix.kd89474fa')}</Link>
|
||||||
Back to News
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Header from '../components/nav/Header'
|
import Header from '../components/nav/Header'
|
||||||
@ -18,6 +21,7 @@ type PublicNewsItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NewsPage() {
|
export default function NewsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [items, setItems] = React.useState<PublicNewsItem[]>([])
|
const [items, setItems] = React.useState<PublicNewsItem[]>([])
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
const [error, setError] = React.useState<string | null>(null)
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
@ -48,8 +52,8 @@ export default function NewsPage() {
|
|||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<div className="bg-blue-900 text-white py-16">
|
<div className="bg-blue-900 text-white py-16">
|
||||||
<div className="mx-auto max-w-7xl px-6">
|
<div className="mx-auto max-w-7xl px-6">
|
||||||
<h1 className="text-5xl font-bold mb-4">News & Updates</h1>
|
<h1 className="text-5xl font-bold mb-4">{t('autofix.k77a56aae')}</h1>
|
||||||
<p className="text-xl text-blue-100">Stay informed with our latest announcements and insights</p>
|
<p className="text-xl text-blue-100">{t('autofix.k27e93fd7')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -61,12 +65,12 @@ export default function NewsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="text-center py-12 text-gray-500">Loading news...</div>
|
<div className="text-center py-12 text-gray-500">{t('autofix.ke3889dc2')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && items.length === 0 && !error && (
|
{!loading && items.length === 0 && !error && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 text-lg">No news articles available yet.</p>
|
<p className="text-gray-500 text-lg">{t('autofix.k2f176a63')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -106,9 +110,7 @@ export default function NewsPage() {
|
|||||||
{item.summary && (
|
{item.summary && (
|
||||||
<p className="mt-3 text-gray-700 line-clamp-3">{item.summary}</p>
|
<p className="mt-3 text-gray-700 line-clamp-3">{item.summary}</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 inline-flex items-center text-blue-900 font-semibold group-hover:text-blue-700">
|
<div className="mt-4 inline-flex items-center text-blue-900 font-semibold group-hover:text-blue-700">{t('autofix.kb2dfe482')}<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
Read More
|
|
||||||
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,7 +15,10 @@ import {
|
|||||||
import type { ComponentType, SVGProps } from 'react';
|
import type { ComponentType, SVGProps } from 'react';
|
||||||
import type { DashboardPlatformIconName } from './utils/dashboardPlatforms';
|
import type { DashboardPlatformIconName } from './utils/dashboardPlatforms';
|
||||||
|
|
||||||
|
import { useTranslation } from './i18n/useTranslation';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isMobile, setIsMobile] = useState(() => {
|
const [isMobile, setIsMobile] = useState(() => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
@ -85,19 +88,15 @@ export default function HomePage() {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-5 sm:gap-6">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-5 sm:gap-6">
|
||||||
<img
|
<img
|
||||||
src="/images/logos/PP_Logo_BW_round.png"
|
src="/images/logos/PP_Logo_BW_round.png"
|
||||||
alt="Profit Planet"
|
alt={t('autofix.k788633d1')}
|
||||||
className="h-16 w-16 sm:h-20 sm:w-20 object-contain"
|
className="h-16 w-16 sm:h-20 sm:w-20 object-contain"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="inline-flex items-center rounded-full border border-gray-200 bg-white/60 px-3 py-1 text-xs font-semibold text-gray-700">
|
<div className="inline-flex items-center rounded-full border border-gray-200 bg-white/60 px-3 py-1 text-xs font-semibold text-gray-700">
|
||||||
Welcome
|
Welcome
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-3 text-5xl sm:text-6xl md:text-7xl font-black tracking-tight leading-none text-transparent bg-clip-text bg-gradient-to-r from-gray-900 via-gray-700 to-amber-700">
|
<h1 className="mt-3 text-5xl sm:text-6xl md:text-7xl font-black tracking-tight leading-none text-transparent bg-clip-text bg-gradient-to-r from-gray-900 via-gray-700 to-amber-700">{t('autofix.k788633d1')}</h1>
|
||||||
Profit Planet
|
<p className="mt-3 text-sm sm:text-base text-gray-700">{t('autofix.kde5c689e')}</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-3 text-sm sm:text-base text-gray-700">
|
|
||||||
Pick a platform to continue.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -106,15 +105,13 @@ export default function HomePage() {
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">Platforms</h2>
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">Platforms</h2>
|
||||||
<p className="mt-1 text-sm text-gray-700">Navigation shortcuts</p>
|
<p className="mt-1 text-sm text-gray-700">{t('autofix.kcc4adbcc')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 text-center text-sm text-gray-600">
|
<div className="rounded-2xl border border-gray-200 bg-white p-6 text-center text-sm text-gray-600">{t('autofix.k832387c5')}</div>
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@ -185,9 +182,7 @@ export default function HomePage() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{platforms.length === 0 && (
|
{platforms.length === 0 && (
|
||||||
<div className="sm:col-span-2 lg:col-span-3 rounded-2xl border border-gray-200 bg-white p-8 text-center text-sm text-gray-600">
|
<div className="sm:col-span-2 lg:col-span-3 rounded-2xl border border-gray-200 bg-white p-8 text-center text-sm text-gray-600">{t('autofix.k7fe72eff')}</div>
|
||||||
No platforms available.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { useState, useEffect, Suspense } from 'react' // CHANGED: add Suspense
|
import { useState, useEffect, 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'
|
||||||
@ -169,9 +172,7 @@ function PasswordResetPageInner() {
|
|||||||
<div className="absolute -top-24 -right-24 size-56 rounded-full bg-gradient-to-br from-indigo-500/10 to-fuchsia-500/10 blur-3xl pointer-events-none" />
|
<div className="absolute -top-24 -right-24 size-56 rounded-full bg-gradient-to-br from-indigo-500/10 to-fuchsia-500/10 blur-3xl pointer-events-none" />
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="mx-auto max-w-2xl text-center mb-8">
|
<div className="mx-auto max-w-2xl text-center mb-8">
|
||||||
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">
|
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">{t('autofix.k09f4290f')}</h1>
|
||||||
Reset password
|
|
||||||
</h1>
|
|
||||||
<p className="mt-3 text-slate-700 text-base sm:text-lg">
|
<p className="mt-3 text-slate-700 text-base sm:text-lg">
|
||||||
{!token
|
{!token
|
||||||
? 'Request a link to reset your password.'
|
? 'Request a link to reset your password.'
|
||||||
@ -181,16 +182,14 @@ function PasswordResetPageInner() {
|
|||||||
{!token && (
|
{!token && (
|
||||||
<form onSubmit={handleRequestSubmit} className="space-y-6">
|
<form onSubmit={handleRequestSubmit} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-[#0F172A] mb-2" htmlFor="email">
|
<label className="block text-sm font-semibold text-[#0F172A] mb-2" htmlFor="email">{t('autofix.keccee79f')}</label>
|
||||||
Email address
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => { setEmail(e.target.value); setRequestError(''); setRequestSuccess(false)}}
|
onChange={e => { setEmail(e.target.value); setRequestError(''); setRequestSuccess(false)}}
|
||||||
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
||||||
placeholder="your.email@example.com"
|
placeholder={t('autofix.k199db5f1')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -232,9 +231,7 @@ function PasswordResetPageInner() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push('/login')}
|
onClick={() => router.push('/login')}
|
||||||
className="text-[#8D6B1D] hover:text-[#7A5E1A] hover:underline font-medium"
|
className="text-[#8D6B1D] hover:text-[#7A5E1A] hover:underline font-medium"
|
||||||
>
|
>{t('autofix.ked60db76')}</button>
|
||||||
Back to login
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@ -243,9 +240,7 @@ function PasswordResetPageInner() {
|
|||||||
<form onSubmit={handleResetSubmit} className="space-y-6">
|
<form onSubmit={handleResetSubmit} className="space-y-6">
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-2" htmlFor="password">
|
<label className="block text-sm font-medium text-gray-900 mb-2" htmlFor="password">{t('autofix.k5aae8706')}</label>
|
||||||
New password
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
@ -253,7 +248,7 @@ function PasswordResetPageInner() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={e => { setPassword(e.target.value); setResetError(''); setResetSuccess(false)}}
|
onChange={e => { setPassword(e.target.value); setResetError(''); setResetSuccess(false)}}
|
||||||
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 pr-12 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 pr-12 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
||||||
placeholder="Your new password"
|
placeholder={t('autofix.k533db977')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -280,20 +275,18 @@ function PasswordResetPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-900 mb-2" htmlFor="confirm">
|
<label className="block text-sm font-medium text-gray-900 mb-2" htmlFor="confirm">{t('autofix.k051e8ac8')}</label>
|
||||||
Confirm password
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="confirm"
|
id="confirm"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={e => { setConfirmPassword(e.target.value); setResetError(''); setResetSuccess(false)}}
|
onChange={e => { setConfirmPassword(e.target.value); setResetError(''); setResetSuccess(false)}}
|
||||||
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
||||||
placeholder="Confirm password"
|
placeholder={t('autofix.k051e8ac8')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{confirmPassword && password !== confirmPassword && (
|
{confirmPassword && password !== confirmPassword && (
|
||||||
<p className="mt-2 text-xs text-red-500">Passwords do not match.</p>
|
<p className="mt-2 text-xs text-red-500">{t('autofix.ke0a3528a')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -304,9 +297,7 @@ function PasswordResetPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{resetSuccess && (
|
{resetSuccess && (
|
||||||
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">{t('autofix.k9d0c063d')}</div>
|
||||||
Password saved. Redirecting to login...
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -334,9 +325,7 @@ function PasswordResetPageInner() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push('/password-reset')}
|
onClick={() => router.push('/password-reset')}
|
||||||
className="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
|
className="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
|
||||||
>
|
>{t('autofix.k5122ab54')}</button>
|
||||||
Request again
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@ -350,6 +339,7 @@ function PasswordResetPageInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PasswordResetPage() {
|
export default function PasswordResetPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<Suspense
|
<Suspense
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@ -7,6 +10,7 @@ import { UsersIcon, CalendarDaysIcon, ArrowLeftIcon } from '@heroicons/react/24/
|
|||||||
import { usePersonalMatrixOverview, useMyMatrices } from './hooks/getStats'
|
import { usePersonalMatrixOverview, useMyMatrices } from './hooks/getStats'
|
||||||
|
|
||||||
export default function PersonalMatrixPage() {
|
export default function PersonalMatrixPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
|
|
||||||
@ -88,9 +92,7 @@ export default function PersonalMatrixPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loadingMatrices && matrices.length === 0 && (
|
{!loadingMatrices && matrices.length === 0 && (
|
||||||
<div className="rounded-md border border-yellow-200 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
|
<div className="rounded-md border border-yellow-200 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">{t('autofix.kaa656770')}</div>
|
||||||
You are not part of any matrix yet.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!loadingMatrices && matrices.length > 0 && (
|
{!loadingMatrices && matrices.length > 0 && (
|
||||||
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@ -116,15 +118,15 @@ export default function PersonalMatrixPage() {
|
|||||||
<div className="text-lg font-semibold text-blue-900">{m.name}</div>
|
<div className="text-lg font-semibold text-blue-900">{m.name}</div>
|
||||||
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-700">
|
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-700">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-800">Total members:</span>{' '}
|
<span className="font-medium text-gray-800">{t('autofix.kf0eef57e')}</span>{' '}
|
||||||
{totalUsersDisplay != null ? totalUsersDisplay : 'N/A'}
|
{totalUsersDisplay != null ? totalUsersDisplay : 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-800">Highest full level:</span>{' '}
|
<span className="font-medium text-gray-800">{t('autofix.k72428656')}</span>{' '}
|
||||||
{m.highestFullLevel != null ? m.highestFullLevel : 'N/A'}
|
{m.highestFullLevel != null ? m.highestFullLevel : 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium text-gray-800">Matrix fill:</span>
|
<span className="font-medium text-gray-800">{t('autofix.kdc47630b')}</span>
|
||||||
<div className="h-2 w-full rounded bg-gray-200">
|
<div className="h-2 w-full rounded bg-gray-200">
|
||||||
<div
|
<div
|
||||||
className="h-2 rounded bg-blue-600"
|
className="h-2 rounded bg-blue-600"
|
||||||
@ -137,7 +139,7 @@ export default function PersonalMatrixPage() {
|
|||||||
</div>
|
</div>
|
||||||
{m.createdAt && (
|
{m.createdAt && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-800">Created:</span>{' '}
|
<span className="font-medium text-gray-800">{t('autofix.kf971ea7f')}</span>{' '}
|
||||||
{new Date(m.createdAt).toLocaleDateString()}
|
{new Date(m.createdAt).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -151,9 +153,7 @@ export default function PersonalMatrixPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setSelectedId(m.id)}
|
onClick={() => setSelectedId(m.id)}
|
||||||
className="inline-flex items-center rounded-md bg-blue-800 px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-900"
|
className="inline-flex items-center rounded-md bg-blue-800 px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-900"
|
||||||
>
|
>{t('autofix.k84d5cfcb')}</button>
|
||||||
View overview
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@ -170,19 +170,19 @@ export default function PersonalMatrixPage() {
|
|||||||
{/* Stats cards */}
|
{/* Stats cards */}
|
||||||
<div className="mb-8 grid grid-cols-1 sm:grid-cols-3 gap-6">
|
<div className="mb-8 grid grid-cols-1 sm:grid-cols-3 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 under me</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.ka9d6e905')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">
|
<div className="text-xl font-semibold text-blue-900">
|
||||||
{data?.totalUsersUnderMe ?? (loading ? '…' : 0)}
|
{data?.totalUsersUnderMe ?? (loading ? '…' : 0)}
|
||||||
</div>
|
</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">Immediate children</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.kf78c9087')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">
|
<div className="text-xl font-semibold text-blue-900">
|
||||||
{data?.immediateChildrenCount ?? (loading ? '…' : 0)}
|
{data?.immediateChildrenCount ?? (loading ? '…' : 0)}
|
||||||
</div>
|
</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">Levels filled</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.kc4d7816e')}</div>
|
||||||
<div className="text-xl font-semibold text-blue-900">
|
<div className="text-xl font-semibold text-blue-900">
|
||||||
{data?.levelsFilled ?? (loading ? '…' : 0)}
|
{data?.levelsFilled ?? (loading ? '…' : 0)}
|
||||||
</div>
|
</div>
|
||||||
@ -198,9 +198,9 @@ export default function PersonalMatrixPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-8 py-6">
|
<div className="px-8 py-6">
|
||||||
{loading && <div className="text-xs text-gray-500">Loading…</div>}
|
{loading && <div className="text-xs text-gray-500">{t('autofix.k832387c5')}</div>}
|
||||||
{!loading && (data?.level1?.length ?? 0) === 0 && (
|
{!loading && (data?.level1?.length ?? 0) === 0 && (
|
||||||
<div className="text-xs text-gray-500 italic">No direct children.</div>
|
<div className="text-xs text-gray-500 italic">{t('autofix.k58344b74')}</div>
|
||||||
)}
|
)}
|
||||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{data?.level1.map((c) => (
|
{data?.level1.map((c) => (
|
||||||
@ -221,13 +221,13 @@ export default function PersonalMatrixPage() {
|
|||||||
{/* Level 2+ */}
|
{/* Level 2+ */}
|
||||||
<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">Level 2+</h2>
|
<h2 className="text-xl font-semibold text-blue-900">{t('autofix.k40f4552a')}</h2>
|
||||||
<p className="text-xs text-blue-700">Masked names for deeper descendants.</p>
|
<p className="text-xs text-blue-700">{t('autofix.k5fbf1824')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-8 py-6">
|
<div className="px-8 py-6">
|
||||||
{loading && <div className="text-xs text-gray-500">Loading…</div>}
|
{loading && <div className="text-xs text-gray-500">{t('autofix.k832387c5')}</div>}
|
||||||
{!loading && (data?.level2Plus?.length ?? 0) === 0 && (
|
{!loading && (data?.level2Plus?.length ?? 0) === 0 && (
|
||||||
<div className="text-xs text-gray-500 italic">No deeper descendants.</div>
|
<div className="text-xs text-gray-500 italic">{t('autofix.ka1d0b6ff')}</div>
|
||||||
)}
|
)}
|
||||||
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{data?.level2Plus.map((x) => (
|
{data?.level2Plus.map((x) => (
|
||||||
@ -244,7 +244,7 @@ export default function PersonalMatrixPage() {
|
|||||||
|
|
||||||
{/* Meta */}
|
{/* Meta */}
|
||||||
<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">Overview meta</div>
|
<div className="text-xs text-gray-500 mb-1">{t('autofix.k039e629b')}</div>
|
||||||
<div className="text-xs text-gray-700">
|
<div className="text-xs text-gray-700">
|
||||||
Matrix instance: {data?.matrixInstanceId ?? (loading ? '…' : '—')}{' '}
|
Matrix instance: {data?.matrixInstanceId ?? (loading ? '…' : '—')}{' '}
|
||||||
• Level1: {meta.countL1} • Level2+: {meta.countL2Plus}
|
• Level1: {meta.countL1} • Level2+: {meta.countL2Plus}
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export default function BankInformation({
|
export default function BankInformation({
|
||||||
@ -17,6 +22,7 @@ export default function BankInformation({
|
|||||||
setBankInfo: (v: { accountHolder: string, iban: string }) => void,
|
setBankInfo: (v: { accountHolder: string, iban: string }) => void,
|
||||||
onEdit?: () => void
|
onEdit?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// editing disabled for now; keep props to avoid refactors
|
// editing disabled for now; keep props to avoid refactors
|
||||||
const accountHolder = profileData.accountHolder || ''
|
const accountHolder = profileData.accountHolder || ''
|
||||||
const iban = profileData.iban || ''
|
const iban = profileData.iban || ''
|
||||||
@ -24,21 +30,21 @@ export default function BankInformation({
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm: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-gray-900">Bank Information</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k5d4f6b2f')}</h2>
|
||||||
<span className="text-xs text-gray-500">Editing disabled</span>
|
<span className="text-xs text-gray-500">{t('autofix.kfc6b6a29')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Account Holder</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.kada9d61c')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full p-2 border border-white/60 rounded-lg bg-white/50 text-gray-900"
|
className="w-full p-2 border border-white/60 rounded-lg bg-white/50 text-gray-900"
|
||||||
value={accountHolder}
|
value={accountHolder}
|
||||||
disabled
|
disabled
|
||||||
placeholder="Not provided"
|
placeholder={t('autofix.kf2147f07')}
|
||||||
/>
|
/>
|
||||||
{!accountHolder && <div className="mt-1 text-sm italic text-gray-400">Not provided</div>}
|
{!accountHolder && <div className="mt-1 text-sm italic text-gray-400">{t('autofix.kf2147f07')}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -48,9 +54,9 @@ export default function BankInformation({
|
|||||||
className="w-full p-2 border border-white/60 rounded-lg bg-white/50 text-gray-900"
|
className="w-full p-2 border border-white/60 rounded-lg bg-white/50 text-gray-900"
|
||||||
value={iban}
|
value={iban}
|
||||||
disabled
|
disabled
|
||||||
placeholder="Not provided"
|
placeholder={t('autofix.kf2147f07')}
|
||||||
/>
|
/>
|
||||||
{!iban && <div className="mt-1 text-sm italic text-gray-400">Not provided</div>}
|
{!iban && <div className="mt-1 text-sm italic text-gray-400">{t('autofix.kf2147f07')}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { UserCircleIcon, EnvelopeIcon, PhoneIcon, MapPinIcon, PencilIcon, CheckCircleIcon } from '@heroicons/react/24/outline'
|
import { UserCircleIcon, EnvelopeIcon, PhoneIcon, MapPinIcon, PencilIcon, CheckCircleIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
@ -6,10 +11,11 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
|||||||
HighlightIfMissing: React.FC<{ value: any, children: React.ReactNode }>
|
HighlightIfMissing: React.FC<{ value: any, children: React.ReactNode }>
|
||||||
onEdit?: () => void
|
onEdit?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k1d178b73')}</h2>
|
||||||
<button
|
<button
|
||||||
className="flex items-center text-[#8D6B1D] hover:text-[#7A5E1A] transition-colors"
|
className="flex items-center text-[#8D6B1D] hover:text-[#7A5E1A] transition-colors"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
@ -22,9 +28,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
|||||||
{profileData.userType === 'personal' && (
|
{profileData.userType === 'personal' && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.kfe8083f8')}</label>
|
||||||
First Name
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
||||||
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.firstName}>
|
<HighlightIfMissing value={profileData.firstName}>
|
||||||
@ -33,9 +37,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.k6a4108c8')}</label>
|
||||||
Last Name
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
||||||
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.lastName}>
|
<HighlightIfMissing value={profileData.lastName}>
|
||||||
@ -47,9 +49,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
|||||||
)}
|
)}
|
||||||
{profileData.userType === 'company' && (
|
{profileData.userType === 'company' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.k9dafde30')}</label>
|
||||||
Contact Person
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
||||||
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.contactPersonName}>
|
<HighlightIfMissing value={profileData.contactPersonName}>
|
||||||
@ -59,9 +59,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.kde6d477f')}</label>
|
||||||
Email Address
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
||||||
<EnvelopeIcon className="h-5 w-5 text-gray-400 mr-3" />
|
<EnvelopeIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.email}>
|
<HighlightIfMissing value={profileData.email}>
|
||||||
@ -71,9 +69,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.k2a2fe15a')}</label>
|
||||||
Phone Number
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
||||||
<PhoneIcon className="h-5 w-5 text-gray-400 mr-3" />
|
<PhoneIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.phone}>
|
<HighlightIfMissing value={profileData.phone}>
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { authFetch } from '../../utils/authFetch'
|
import { authFetch } from '../../utils/authFetch'
|
||||||
import { AboInvoice, useAboInvoices } from '../hooks/getAboInvoices'
|
import { AboInvoice, useAboInvoices } from '../hooks/getAboInvoices'
|
||||||
@ -78,6 +83,7 @@ function downloadBlob(content: Blob, fileName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FinanceInvoices({ abonementId }: Props) {
|
export default function FinanceInvoices({ abonementId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: invoices, loading, error } = useAboInvoices(abonementId)
|
const { data: invoices, loading, error } = useAboInvoices(abonementId)
|
||||||
const [busyId, setBusyId] = React.useState<string | number | null>(null)
|
const [busyId, setBusyId] = React.useState<string | number | null>(null)
|
||||||
const [actionError, setActionError] = React.useState<string | null>(null)
|
const [actionError, setActionError] = React.useState<string | null>(null)
|
||||||
@ -154,39 +160,31 @@ export default function FinanceInvoices({ abonementId }: Props) {
|
|||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Finance & Invoices</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k8953de89')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onExportAll}
|
onClick={onExportAll}
|
||||||
disabled={!invoices.length || loading}
|
disabled={!invoices.length || loading}
|
||||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
>
|
>{t('autofix.kfd632d02')}</button>
|
||||||
Export all invoices
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!abonementId ? (
|
{!abonementId ? (
|
||||||
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">{t('autofix.kc48b877b')}</div>
|
||||||
No subscription selected. Invoices will appear once you have an active subscription.
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">{t('autofix.ka5603827')}</div>
|
||||||
Loading invoices…
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-4 text-sm text-red-700">
|
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-4 text-sm text-red-700">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : invoices.length === 0 ? (
|
) : invoices.length === 0 ? (
|
||||||
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">{t('autofix.k2108b5a0')}</div>
|
||||||
No invoices found for this subscription.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto rounded-lg border border-white/60 bg-white/70 backdrop-blur-md shadow-lg">
|
<div className="overflow-x-auto rounded-lg border border-white/60 bg-white/70 backdrop-blur-md shadow-lg">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead className="bg-white/80">
|
<thead className="bg-white/80">
|
||||||
<tr className="text-left text-gray-700">
|
<tr className="text-left text-gray-700">
|
||||||
<th className="px-4 py-3 font-semibold">Date</th>
|
<th className="px-4 py-3 font-semibold">Date</th>
|
||||||
<th className="px-4 py-3 font-semibold">Invoice #</th>
|
<th className="px-4 py-3 font-semibold">{t('autofix.k947d8777')}</th>
|
||||||
<th className="px-4 py-3 font-semibold">Status</th>
|
<th className="px-4 py-3 font-semibold">Status</th>
|
||||||
<th className="px-4 py-3 font-semibold">Total</th>
|
<th className="px-4 py-3 font-semibold">Total</th>
|
||||||
<th className="px-4 py-3 font-semibold">Actions</th>
|
<th className="px-4 py-3 font-semibold">Actions</th>
|
||||||
|
|||||||
@ -1,10 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export default function MediaSection({ documents }: { documents: any[] }) {
|
export default function MediaSection({ documents }: { documents: any[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const hasDocuments = Array.isArray(documents) && documents.length > 0;
|
const hasDocuments = Array.isArray(documents) && documents.length > 0;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Media & Documents</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('autofix.ked7d533b')}</h2>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
{hasDocuments ? (
|
{hasDocuments ? (
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
@ -29,7 +35,7 @@ export default function MediaSection({ documents }: { documents: any[] }) {
|
|||||||
<a href={doc.signedUrl} target="_blank" rel="noopener noreferrer" className="px-3 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition">Preview</a>
|
<a href={doc.signedUrl} target="_blank" rel="noopener noreferrer" className="px-3 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition">Preview</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-gray-400 italic">No file</span>
|
<span className="text-xs text-gray-400 italic">{t('autofix.kb3243742')}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -37,7 +43,7 @@ export default function MediaSection({ documents }: { documents: any[] }) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-gray-500 italic py-6 text-center">No media or documents found.</div>
|
<div className="text-gray-500 italic py-6 text-center">{t('autofix.k60b1e339')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export default function ProfileCompletion({ profileComplete }: { profileComplete: number }) {
|
export default function ProfileCompletion({ profileComplete }: { profileComplete: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6 mb-8">
|
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6 mb-8">
|
||||||
<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-gray-900">Profile Completion</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.kd08b698a')}</h2>
|
||||||
<span className="text-sm font-medium text-[#8D6B1D]">
|
<span className="text-sm font-medium text-[#8D6B1D]">
|
||||||
{profileComplete}%
|
{profileComplete}%
|
||||||
</span>
|
</span>
|
||||||
@ -15,9 +21,7 @@ export default function ProfileCompletion({ profileComplete }: { profileComplete
|
|||||||
style={{ width: `${profileComplete}%` }}
|
style={{ width: `${profileComplete}%` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mt-2">
|
<p className="text-sm text-gray-600 mt-2">{t('autofix.k772cc77b')}</p>
|
||||||
Complete your profile to unlock all features
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useMyAboStatus } from '../hooks/getAbo'
|
import { useMyAboStatus } from '../hooks/getAbo'
|
||||||
|
|
||||||
@ -6,6 +11,7 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UserAbo({ onAboChange }: Props) {
|
export default function UserAbo({ onAboChange }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { hasAbo, abonement, loading, error } = useMyAboStatus()
|
const { hasAbo, abonement, loading, error } = useMyAboStatus()
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -16,10 +22,8 @@ export default function UserAbo({ onAboChange }: Props) {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k744fda01')}</h2>
|
||||||
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">{t('autofix.kcdfef775')}</div>
|
||||||
Loading subscriptions…
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -27,7 +31,7 @@ export default function UserAbo({ onAboChange }: Props) {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k744fda01')}</h2>
|
||||||
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-4 text-sm text-red-700">
|
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-4 text-sm text-red-700">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
@ -37,11 +41,9 @@ export default function UserAbo({ onAboChange }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">My Subscription</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k7fa55432')}</h2>
|
||||||
{(!hasAbo || !abonement) ? (
|
{(!hasAbo || !abonement) ? (
|
||||||
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">{t('autofix.k4f209a66')}</div>
|
||||||
You currently don’t have an active subscription.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:gap-4">
|
<div className="grid gap-3 sm:gap-4">
|
||||||
{(() => {
|
{(() => {
|
||||||
@ -92,9 +94,7 @@ export default function UserAbo({ onAboChange }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">{t('autofix.k49e51b5f')}</button>
|
||||||
Current plan
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
@ -21,9 +24,7 @@ import { authFetch } from '../utils/authFetch'
|
|||||||
function HighlightIfMissing({ value, children }: { value: any, children: React.ReactNode }) {
|
function HighlightIfMissing({ value, children }: { value: any, children: React.ReactNode }) {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return (
|
return (
|
||||||
<span className="italic text-gray-400">
|
<span className="italic text-gray-400">{t('autofix.kf2147f07')}</span>
|
||||||
Not provided
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
@ -69,6 +70,7 @@ const bankFields = [
|
|||||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(state => state.user)
|
const user = useAuthStore(state => state.user)
|
||||||
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
||||||
@ -328,17 +330,13 @@ export default function ProfilePage() {
|
|||||||
<div className="rounded-3xl bg-white/60 backdrop-blur-md border border-white/50 shadow-xl p-4 sm:p-6 lg:p-8">
|
<div className="rounded-3xl bg-white/60 backdrop-blur-md border border-white/50 shadow-xl p-4 sm:p-6 lg:p-8">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Profile Settings</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{t('autofix.k67cace8b')}</h1>
|
||||||
<p className="text-gray-600 mt-2">
|
<p className="text-gray-600 mt-2">{t('autofix.ka00fc5db')}</p>
|
||||||
Manage your account information and preferences
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pending admin verification notice (above progress) */}
|
{/* Pending admin verification notice (above progress) */}
|
||||||
{profileDataApi?.userStatus && profileDataApi.userStatus.is_admin_verified === 0 && (
|
{profileDataApi?.userStatus && profileDataApi.userStatus.is_admin_verified === 0 && (
|
||||||
<div className="rounded-md bg-yellow-50/80 backdrop-blur border border-yellow-200 p-3 text-sm text-yellow-800 mb-2">
|
<div className="rounded-md bg-yellow-50/80 backdrop-blur border border-yellow-200 p-3 text-sm text-yellow-800 mb-2">{t('autofix.k2af2916f')}</div>
|
||||||
Your account is fully submitted. Our team will verify your account shortly.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ProfileCompletion profileComplete={profileData.profileComplete} />
|
<ProfileCompletion profileComplete={profileData.profileComplete} />
|
||||||
@ -363,11 +361,11 @@ export default function ProfilePage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Account Status (make translucent) */}
|
{/* Account Status (make translucent) */}
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-4">Account Status</h3>
|
<h3 className="font-semibold text-gray-900 mb-4">{t('autofix.kb8cd2810')}</h3>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-600">Member Since</span>
|
<span className="text-sm text-gray-600">{t('autofix.k7bed84a7')}</span>
|
||||||
<span className="text-sm font-medium text-gray-900">{profileData.joinDate}</span>
|
<span className="text-sm font-medium text-gray-900">{profileData.joinDate}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -386,15 +384,13 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Quick Actions (make translucent) */}
|
{/* Quick Actions (make translucent) */}
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm: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
|
<button
|
||||||
onClick={() => router.push('/dashboard')}
|
onClick={() => router.push('/dashboard')}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
||||||
>
|
>{t('autofix.kd00443f2')}</button>
|
||||||
Go to Dashboard
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleDownloadAccountData}
|
onClick={handleDownloadAccountData}
|
||||||
disabled={downloadLoading}
|
disabled={downloadLoading}
|
||||||
@ -402,9 +398,7 @@ export default function ProfilePage() {
|
|||||||
>
|
>
|
||||||
{downloadLoading ? 'Preparing download...' : 'Download Account Data'}
|
{downloadLoading ? 'Preparing download...' : 'Download Account Data'}
|
||||||
</button>
|
</button>
|
||||||
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">{t('autofix.k41f7c81d')}</button>
|
||||||
Delete Account
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{downloadError && (
|
{downloadError && (
|
||||||
<p className="mt-2 text-xs text-red-600">{downloadError}</p>
|
<p className="mt-2 text-xs text-red-600">{downloadError}</p>
|
||||||
@ -418,26 +412,22 @@ export default function ProfilePage() {
|
|||||||
<section className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<section className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k744fda01')}</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">{t('autofix.kcada239b')}</p>
|
||||||
View your active subscriptions, included items and subscription details on a dedicated page.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/profile/subscriptions')}
|
onClick={() => router.push('/profile/subscriptions')}
|
||||||
className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>{t('autofix.k4b6c7681')}</button>
|
||||||
Open subscriptions
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 rounded-md border border-white/60 bg-white/70 p-3">
|
<div className="mt-4 rounded-md border border-white/60 bg-white/70 p-3">
|
||||||
{subscriptionsLoading ? (
|
{subscriptionsLoading ? (
|
||||||
<p className="text-sm text-gray-600">Loading subscriptions…</p>
|
<p className="text-sm text-gray-600">{t('autofix.kcdfef775')}</p>
|
||||||
) : subscriptionsError ? (
|
) : subscriptionsError ? (
|
||||||
<p className="text-sm text-red-700">{subscriptionsError}</p>
|
<p className="text-sm text-red-700">{subscriptionsError}</p>
|
||||||
) : subscriptions.length === 0 ? (
|
) : subscriptions.length === 0 ? (
|
||||||
<p className="text-sm text-gray-600">You don’t have any subscriptions yet.</p>
|
<p className="text-sm text-gray-600">{t('autofix.kc8652e34')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{subscriptions.map((subscription) => {
|
{subscriptions.map((subscription) => {
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import useAuthStore from '../../store/authStore'
|
import useAuthStore from '../../store/authStore'
|
||||||
@ -55,6 +58,7 @@ const formatMoney = (value?: string | number | null, currency?: string | null) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfileSubscriptionsPage() {
|
export default function ProfileSubscriptionsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(state => state.user)
|
const user = useAuthStore(state => state.user)
|
||||||
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
||||||
@ -230,34 +234,28 @@ export default function ProfileSubscriptionsPage() {
|
|||||||
<div className="rounded-3xl bg-white/60 backdrop-blur-md border border-white/50 shadow-xl p-4 sm:p-6 lg:p-8 space-y-6 sm:space-y-8">
|
<div className="rounded-3xl bg-white/60 backdrop-blur-md border border-white/50 shadow-xl p-4 sm:p-6 lg:p-8 space-y-6 sm:space-y-8">
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">My Subscriptions</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{t('autofix.k744fda01')}</h1>
|
||||||
<p className="text-gray-600 mt-2">Select any subscription to view details and included items.</p>
|
<p className="text-gray-600 mt-2">{t('autofix.kbf7bde57')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/profile')}
|
onClick={() => router.push('/profile')}
|
||||||
className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>{t('autofix.kf2a1257e')}</button>
|
||||||
Back to profile
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">{t('autofix.kcdfef775')}</div>
|
||||||
Loading subscriptions…
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-4 text-sm text-red-700">
|
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-4 text-sm text-red-700">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : subscriptions.length === 0 ? (
|
) : subscriptions.length === 0 ? (
|
||||||
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">{t('autofix.kc8652e34')}</div>
|
||||||
You don’t have any subscriptions yet.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap mb-3">
|
<div className="flex items-center justify-between gap-3 flex-wrap mb-3">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">All subscriptions</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k1df74994')}</h2>
|
||||||
<p className="text-xs text-gray-600">{subscriptions.length} total</p>
|
<p className="text-xs text-gray-600">{subscriptions.length} total</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -306,7 +304,7 @@ export default function ProfileSubscriptionsPage() {
|
|||||||
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
||||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Subscription details</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k3b8e0964')}</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">{selectedAbo.name || 'Coffee Subscription'}</p>
|
<p className="text-sm text-gray-600 mt-1">{selectedAbo.name || 'Coffee Subscription'}</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@ -345,7 +343,7 @@ export default function ProfileSubscriptionsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(status === 'finished' || status === 'cancelled') && (
|
{(status === 'finished' || status === 'cancelled') && (
|
||||||
<p className="text-xs text-gray-600">No further status changes are available for this subscription.</p>
|
<p className="text-xs text-gray-600">{t('autofix.k416bfe70')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{statusError && (
|
{statusError && (
|
||||||
@ -354,7 +352,7 @@ export default function ProfileSubscriptionsPage() {
|
|||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||||
<p className="text-gray-500">Subscription ID</p>
|
<p className="text-gray-500">{t('autofix.k354a026b')}</p>
|
||||||
<p className="font-medium text-gray-900">{selectedAbo.id}</p>
|
<p className="font-medium text-gray-900">{selectedAbo.id}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||||
@ -374,7 +372,7 @@ export default function ProfileSubscriptionsPage() {
|
|||||||
<p className="font-medium text-gray-900">{formatDate(selectedAbo.startedAt || selectedAbo.createdAt)}</p>
|
<p className="font-medium text-gray-900">{formatDate(selectedAbo.startedAt || selectedAbo.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
<div className="rounded-md bg-white/80 border border-gray-200 px-3 py-2">
|
||||||
<p className="text-gray-500">Next billing</p>
|
<p className="text-gray-500">{t('autofix.k85446b89')}</p>
|
||||||
<p className="font-medium text-gray-900">{formatDate(selectedAbo.nextBillingAt)}</p>
|
<p className="font-medium text-gray-900">{formatDate(selectedAbo.nextBillingAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -383,30 +381,24 @@ export default function ProfileSubscriptionsPage() {
|
|||||||
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
<section className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 sm:p-6 shadow-lg">
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Included in your subscription</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.kb383a3e8')}</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">{includedItems.length} item(s), {totalPacks} total pack(s)</p>
|
<p className="text-sm text-gray-600 mt-1">{includedItems.length} item(s), {totalPacks} total pack(s)</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">Changes apply from your next billing cycle.</p>
|
<p className="text-xs text-gray-500 mt-1">{t('autofix.k35f67931')}</p>
|
||||||
</div>
|
</div>
|
||||||
{!editingContent && canChangeContent && (
|
{!editingContent && canChangeContent && (
|
||||||
<button
|
<button
|
||||||
onClick={startEditingContent}
|
onClick={startEditingContent}
|
||||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50"
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>{t('autofix.k9bc83f50')}</button>
|
||||||
Change coffees for next month
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!canChangeContent && (
|
{!canChangeContent && (
|
||||||
<p className="mt-3 text-xs text-gray-600">
|
<p className="mt-3 text-xs text-gray-600">{t('autofix.kcc15636b')}</p>
|
||||||
Coffee content can only be changed while a subscription is issued, ongoing, or paused.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{includedItems.length === 0 ? (
|
{includedItems.length === 0 ? (
|
||||||
<div className="mt-4 rounded-md bg-white/80 border border-gray-200 p-3 text-sm text-gray-600">
|
<div className="mt-4 rounded-md bg-white/80 border border-gray-200 p-3 text-sm text-gray-600">{t('autofix.k9772afa4')}</div>
|
||||||
No included items were returned for this subscription.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{includedItems.map((item, index) => {
|
{includedItems.map((item, index) => {
|
||||||
@ -438,12 +430,12 @@ export default function ProfileSubscriptionsPage() {
|
|||||||
{editingContent && canChangeContent && (
|
{editingContent && canChangeContent && (
|
||||||
<div className="mt-4 rounded-md border border-gray-200 bg-white/90 p-4">
|
<div className="mt-4 rounded-md border border-gray-200 bg-white/90 p-4">
|
||||||
<div className="flex items-center justify-between gap-2 flex-wrap mb-3">
|
<div className="flex items-center justify-between gap-2 flex-wrap mb-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-900">Edit coffee content</h3>
|
<h3 className="text-sm font-semibold text-gray-900">{t('autofix.ke24abf9c')}</h3>
|
||||||
<p className="text-xs text-gray-600">Selected packs: {draftTotalPacks} (minimum 6)</p>
|
<p className="text-xs text-gray-600">Selected packs: {draftTotalPacks} (minimum 6)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{coffeesLoading ? (
|
{coffeesLoading ? (
|
||||||
<p className="text-sm text-gray-600">Loading available coffees…</p>
|
<p className="text-sm text-gray-600">{t('autofix.kc813a103')}</p>
|
||||||
) : coffeesError ? (
|
) : coffeesError ? (
|
||||||
<p className="text-sm text-red-600">{coffeesError}</p>
|
<p className="text-sm text-red-600">{coffeesError}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -62,7 +62,7 @@ const init: CompanyProfileData = {
|
|||||||
|
|
||||||
function ModernSelect({
|
function ModernSelect({
|
||||||
label,
|
label,
|
||||||
placeholder = 'Select…',
|
placeholder={t('autofix.ka5bf342b')},
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
@ -145,7 +145,7 @@ function ModernSelect({
|
|||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
placeholder="Search…"
|
placeholder={t('autofix.ka4ecb6cd')}
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm
|
||||||
focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -153,7 +153,7 @@ function ModernSelect({
|
|||||||
</div>
|
</div>
|
||||||
<div className="max-h-[42vh] overflow-auto p-1">
|
<div className="max-h-[42vh] overflow-auto p-1">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="px-3 py-2 text-sm text-gray-500">No results</div>
|
<div className="px-3 py-2 text-sm text-gray-500">{t('autofix.k955b1cbe')}</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map(o => {
|
filtered.map(o => {
|
||||||
const active = o.value === value
|
const active = o.value === value
|
||||||
@ -554,7 +554,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
value={form.companyPhone}
|
value={form.companyPhone}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onInput={handlePhoneInput}
|
onInput={handlePhoneInput}
|
||||||
placeholder="e.g. +43 1 234567"
|
placeholder={t('autofix.k8eaa7b3b')}
|
||||||
ref={companyPhoneRef}
|
ref={companyPhoneRef}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -580,7 +580,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
value={form.contactPersonPhone}
|
value={form.contactPersonPhone}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onInput={handlePhoneInput}
|
onInput={handlePhoneInput}
|
||||||
placeholder="e.g. +43 676 1234567"
|
placeholder={t('autofix.k9f56d4ac')}
|
||||||
ref={contactPhoneRef}
|
ref={contactPhoneRef}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -593,7 +593,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
name="registrationNumber"
|
name="registrationNumber"
|
||||||
value={form.registrationNumber}
|
value={form.registrationNumber}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="e.g. FN123456a"
|
placeholder={t('autofix.k85682289')}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -605,7 +605,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
name="uidNumber"
|
name="uidNumber"
|
||||||
value={form.uidNumber}
|
value={form.uidNumber}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="e.g. ATU12345678"
|
placeholder={t('autofix.kf4f44e2f')}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -673,7 +673,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
name="accountHolder"
|
name="accountHolder"
|
||||||
value={form.accountHolder}
|
value={form.accountHolder}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Company / Holder name"
|
placeholder={t('autofix.k07fe11b2')}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -686,7 +686,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
name="iban"
|
name="iban"
|
||||||
value={form.iban}
|
value={form.iban}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="DE89 3704 0044 0532 0130 00"
|
placeholder={t('autofix.k1f269263')}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tracking-wide uppercase focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tracking-wide uppercase focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -735,7 +735,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
name="emergencyName"
|
name="emergencyName"
|
||||||
value={form.emergencyName}
|
value={form.emergencyName}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Contact name"
|
placeholder={t('autofix.k67dd8a82')}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -80,7 +80,7 @@ type SelectOption = { value: string; label: string }
|
|||||||
|
|
||||||
function ModernSelect({
|
function ModernSelect({
|
||||||
label,
|
label,
|
||||||
placeholder = 'Select…',
|
placeholder={t('autofix.ka5bf342b')},
|
||||||
searchPlaceholder = 'Search…',
|
searchPlaceholder = 'Search…',
|
||||||
noResults = 'No results',
|
noResults = 'No results',
|
||||||
value,
|
value,
|
||||||
@ -651,7 +651,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
value={form.phone}
|
value={form.phone}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onInput={handlePhoneInput}
|
onInput={handlePhoneInput}
|
||||||
placeholder="e.g. +43 676 1234567"
|
placeholder={t('autofix.k9f56d4ac')}
|
||||||
ref={phoneRef}
|
ref={phoneRef}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -690,7 +690,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
name="street"
|
name="street"
|
||||||
value={form.street}
|
value={form.street}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Street & House Number"
|
placeholder={t('autofix.k96dbbe05')}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -703,7 +703,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
name="postalCode"
|
name="postalCode"
|
||||||
value={form.postalCode}
|
value={form.postalCode}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="e.g. 12345"
|
placeholder={t('autofix.kf70b9896')}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -716,7 +716,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
name="city"
|
name="city"
|
||||||
value={form.city}
|
value={form.city}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="e.g. Berlin"
|
placeholder={t('autofix.k62bc3c59')}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -751,7 +751,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
name="accountHolder"
|
name="accountHolder"
|
||||||
value={form.accountHolder}
|
value={form.accountHolder}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Full name"
|
placeholder={t('autofix.k28f1a9b1')}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -764,7 +764,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
name="iban"
|
name="iban"
|
||||||
value={form.iban}
|
value={form.iban}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="e.g. DE89 3704 0044 0532 0130 00"
|
placeholder={t('autofix.k1a1ca621')}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tracking-wide uppercase focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tracking-wide uppercase focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -801,7 +801,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
name="emergencyName"
|
name="emergencyName"
|
||||||
value={form.emergencyName}
|
value={form.emergencyName}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Contact name"
|
placeholder={t('autofix.k67dd8a82')}
|
||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -159,7 +159,7 @@ export default function PersonalIdUploadPage() {
|
|||||||
type="date"
|
type="date"
|
||||||
value={expiry}
|
value={expiry}
|
||||||
onChange={e => setExpiry(e.target.value)}
|
onChange={e => setExpiry(e.target.value)}
|
||||||
placeholder="tt.mm jjjj"
|
placeholder={t('autofix.k290e3aab')}
|
||||||
className={`${inputBase} ${expiry ? 'text-gray-900' : 'text-gray-700'} appearance-none [&::-webkit-calendar-picker-indicator]:opacity-80 focus:ring-[#8D6B1D] focus:border-transparent`}
|
className={`${inputBase} ${expiry ? 'text-gray-900' : 'text-gray-700'} appearance-none [&::-webkit-calendar-picker-indicator]:opacity-80 focus:ring-[#8D6B1D] focus:border-transparent`}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
interface DeactivateReferralLinkModalProps {
|
interface DeactivateReferralLinkModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -19,20 +20,21 @@ export default function DeactivateReferralLinkModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: DeactivateReferralLinkModalProps) {
|
}: DeactivateReferralLinkModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<ConfirmActionModal
|
<ConfirmActionModal
|
||||||
open={open}
|
open={open}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
intent="danger"
|
intent="danger"
|
||||||
title="Deactivate referral link?"
|
title={t('referralManagement.deactivateModalTitle')}
|
||||||
description="This will immediately deactivate the selected referral link so it can no longer be used."
|
description={t('referralManagement.deactivateModalDescription')}
|
||||||
confirmText="Deactivate"
|
confirmText={t('referralManagement.deactivate')}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
extraContent={
|
extraContent={
|
||||||
linkPreview ? (
|
linkPreview ? (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs uppercase text-gray-500">Link</span>
|
<span className="text-xs uppercase text-gray-500">{t('referralManagement.linkLabel')}</span>
|
||||||
<div title={fullUrl} className="mt-1 inline-flex items-center rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800">
|
<div title={fullUrl} className="mt-1 inline-flex items-center rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800">
|
||||||
{linkPreview}
|
{linkPreview}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,12 +3,14 @@
|
|||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import { ClipboardDocumentIcon } from '@heroicons/react/24/outline'
|
import { ClipboardDocumentIcon } from '@heroicons/react/24/outline'
|
||||||
import { createReferralLink } from '../hooks/generateReferralLink'
|
import { createReferralLink } from '../hooks/generateReferralLink'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onCreated?: () => void | Promise<void>
|
onCreated?: () => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
// Defaults: Unlimited + Never expires
|
// Defaults: Unlimited + Never expires
|
||||||
const [maxUses, setMaxUses] = useState<string>('-1')
|
const [maxUses, setMaxUses] = useState<string>('-1')
|
||||||
const [expiresInDays, setExpiresInDays] = useState<string>('-1')
|
const [expiresInDays, setExpiresInDays] = useState<string>('-1')
|
||||||
@ -21,27 +23,27 @@ export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
|||||||
|
|
||||||
const expiryOptions = useMemo(
|
const expiryOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ value: '1', label: '1 day' },
|
{ value: '1', label: t('referralManagement.expiry1Day') },
|
||||||
{ value: '2', label: '2 days' },
|
{ value: '2', label: t('referralManagement.expiry2Days') },
|
||||||
{ value: '3', label: '3 days' },
|
{ value: '3', label: t('referralManagement.expiry3Days') },
|
||||||
{ value: '4', label: '4 days' },
|
{ value: '4', label: t('referralManagement.expiry4Days') },
|
||||||
{ value: '5', label: '5 days' },
|
{ value: '5', label: t('referralManagement.expiry5Days') },
|
||||||
{ value: '6', label: '6 days' },
|
{ value: '6', label: t('referralManagement.expiry6Days') },
|
||||||
{ value: '7', label: '7 days' },
|
{ value: '7', label: t('referralManagement.expiry7Days') },
|
||||||
{ value: '-1', label: 'Never expires' },
|
{ value: '-1', label: t('referralManagement.expiryNever') },
|
||||||
],
|
],
|
||||||
[]
|
[t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxUsesOptions = useMemo(
|
const maxUsesOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ value: '1', label: '1 use' },
|
{ value: '1', label: t('referralManagement.maxUses1') },
|
||||||
{ value: '5', label: '5 uses' },
|
{ value: '5', label: t('referralManagement.maxUses5') },
|
||||||
{ value: '10', label: '10 uses' },
|
{ value: '10', label: t('referralManagement.maxUses10') },
|
||||||
{ value: '50', label: '50 uses' },
|
{ value: '50', label: t('referralManagement.maxUses50') },
|
||||||
{ value: '-1', label: 'Unlimited' },
|
{ value: '-1', label: t('referralManagement.maxUsesUnlimited') },
|
||||||
],
|
],
|
||||||
[]
|
[t]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handlers that enforce coupling
|
// Handlers that enforce coupling
|
||||||
@ -117,10 +119,10 @@ export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Generate Referral Link</h2>
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('referralManagement.generateTitle')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max Uses</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('referralManagement.maxUsesLabel')}</label>
|
||||||
<select
|
<select
|
||||||
value={maxUses}
|
value={maxUses}
|
||||||
onChange={(e) => onChangeMaxUses(e.target.value)}
|
onChange={(e) => onChangeMaxUses(e.target.value)}
|
||||||
@ -131,11 +133,11 @@ export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
|||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{disableMaxUses && <p className="mt-1 text-xs text-gray-500">Locked by “Never expires”.</p>}
|
{disableMaxUses && <p className="mt-1 text-xs text-gray-500">{t('referralManagement.lockedByNeverExpires')}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Expires In</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('referralManagement.expiresIn')}</label>
|
||||||
<select
|
<select
|
||||||
value={expiresInDays}
|
value={expiresInDays}
|
||||||
onChange={(e) => onChangeExpires(e.target.value)}
|
onChange={(e) => onChangeExpires(e.target.value)}
|
||||||
@ -146,7 +148,7 @@ export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
|||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{disableExpires && <p className="mt-1 text-xs text-gray-500">Locked by “Unlimited uses”.</p>}
|
{disableExpires && <p className="mt-1 text-xs text-gray-500">{t('referralManagement.lockedByUnlimited')}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -156,7 +158,7 @@ export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
|||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-[#8D6B1D] px-4 py-2 text-white hover:bg-[#7A5E1A] disabled:opacity-60"
|
className="inline-flex items-center gap-2 rounded-md bg-[#8D6B1D] px-4 py-2 text-white hover:bg-[#7A5E1A] disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isGenerating ? 'Generating...' : 'Generate Link'}
|
{isGenerating ? t('referralManagement.generating') : t('referralManagement.generateLink')}
|
||||||
</button>
|
</button>
|
||||||
{generatedLink && (
|
{generatedLink && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -166,7 +168,7 @@ export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
|||||||
className="inline-flex items-center gap-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 hover:bg-gray-50"
|
className="inline-flex items-center gap-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<ClipboardDocumentIcon className="h-4 w-4" />
|
<ClipboardDocumentIcon className="h-4 w-4" />
|
||||||
{isCopying ? 'Copied' : 'Copy'}
|
{isCopying ? t('referralManagement.copied') : t('referralManagement.copy')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// total points = total registered users via your referral
|
// total points = total registered users via your referral
|
||||||
@ -11,11 +12,11 @@ interface Props {
|
|||||||
// NEW: thresholds with names (Level 1+)
|
// NEW: thresholds with names (Level 1+)
|
||||||
// Level 0 (<5) will be handled separately as "Starter"
|
// Level 0 (<5) will be handled separately as "Starter"
|
||||||
const LEVELS = [
|
const LEVELS = [
|
||||||
{ threshold: 5, name: 'Novice' }, // Level 1
|
{ threshold: 5, nameKey: 'levelNovice' },
|
||||||
{ threshold: 25, name: 'Hustler' }, // Level 2
|
{ threshold: 25, nameKey: 'levelHustler' },
|
||||||
{ threshold: 125, name: 'Entrepreneur' },// Level 3
|
{ threshold: 125, nameKey: 'levelEntrepreneur' },
|
||||||
{ threshold: 625, name: 'Prestige' }, // Level 4
|
{ threshold: 625, nameKey: 'levelPrestige' },
|
||||||
{ threshold: 3125, name: 'MAX' }, // Level 5+
|
{ threshold: 3125, nameKey: 'levelMax' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ...existing calc helpers...
|
// ...existing calc helpers...
|
||||||
@ -38,6 +39,7 @@ function nextThreshold(points: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LevelTrackerWidget({ points = 3, className = '' }: Props) {
|
export default function LevelTrackerWidget({ points = 3, className = '' }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const safePoints = Math.max(0, Math.floor(points))
|
const safePoints = Math.max(0, Math.floor(points))
|
||||||
|
|
||||||
// NEW: derive level index and name
|
// NEW: derive level index and name
|
||||||
@ -54,8 +56,8 @@ export default function LevelTrackerWidget({ points = 3, className = '' }: Props
|
|||||||
const level = useMemo(() => calcLevel(safePoints), [safePoints])
|
const level = useMemo(() => calcLevel(safePoints), [safePoints])
|
||||||
const displayLevel = Math.min(level, LEVELS.length) // cap at 5 for display
|
const displayLevel = Math.min(level, LEVELS.length) // cap at 5 for display
|
||||||
const levelName = level === 0
|
const levelName = level === 0
|
||||||
? 'Starter'
|
? t('referralManagement.levelStarter')
|
||||||
: LEVELS[Math.min(level - 1, LEVELS.length - 1)].name
|
: t(`referralManagement.${LEVELS[Math.min(level - 1, LEVELS.length - 1)].nameKey}` as any)
|
||||||
|
|
||||||
// For progress to next target: keep growing in powers of 5, even after MAX (name stays MAX)
|
// For progress to next target: keep growing in powers of 5, even after MAX (name stays MAX)
|
||||||
const target = useMemo(() => nextThreshold(safePoints), [safePoints])
|
const target = useMemo(() => nextThreshold(safePoints), [safePoints])
|
||||||
@ -77,7 +79,7 @@ export default function LevelTrackerWidget({ points = 3, className = '' }: Props
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-sm font-semibold text-gray-900">Level</span>
|
<span className="text-sm font-semibold text-gray-900">{t('referralManagement.levelLabel')}</span>
|
||||||
<span className="text-lg font-bold text-indigo-700">#{displayLevel}</span>
|
<span className="text-lg font-bold text-indigo-700">#{displayLevel}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* NEW: level name badge */}
|
{/* NEW: level name badge */}
|
||||||
@ -86,7 +88,7 @@ export default function LevelTrackerWidget({ points = 3, className = '' }: Props
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600">
|
<div className="text-xs text-gray-600">
|
||||||
{targetProgress} of {target} referrals
|
{targetProgress} {t('referralManagement.of')} {target} {t('referralManagement.referrals')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -101,8 +103,8 @@ export default function LevelTrackerWidget({ points = 3, className = '' }: Props
|
|||||||
<div className="mt-2 flex items-center justify-between">
|
<div className="mt-2 flex items-center justify-between">
|
||||||
<span className="text-[11px] font-medium text-gray-600">
|
<span className="text-[11px] font-medium text-gray-600">
|
||||||
{displayLevel >= LEVELS.length
|
{displayLevel >= LEVELS.length
|
||||||
? 'Max level reached'
|
? t('referralManagement.maxLevelReached')
|
||||||
: `Next milestone: ${target} total referrals`}
|
: `${t('referralManagement.nextMilestone')}: ${target} ${t('referralManagement.referrals')}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] font-semibold text-indigo-700">{animPercent}%</span>
|
<span className="text-[11px] font-semibold text-indigo-700">{animPercent}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ClipboardDocumentIcon } from '@heroicons/react/24/outline'
|
import { ClipboardDocumentIcon } from '@heroicons/react/24/outline'
|
||||||
import { useToast } from '../../components/toast/toastComponent'
|
import { useToast } from '../../components/toast/toastComponent'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
interface ReferralLink {
|
interface ReferralLink {
|
||||||
id?: string | number
|
id?: string | number
|
||||||
@ -41,6 +42,7 @@ function shortLink(href?: string) {
|
|||||||
|
|
||||||
export default function ReferralLinksListWidget({ links, onDeactivate }: Props) {
|
export default function ReferralLinksListWidget({ links, onDeactivate }: Props) {
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
// Local floating tooltip (fixed) so table doesn't scroll to show it
|
// Local floating tooltip (fixed) so table doesn't scroll to show it
|
||||||
const [tooltip, setTooltip] = useState<{ visible: boolean; text: string; x: number; y: number }>({
|
const [tooltip, setTooltip] = useState<{ visible: boolean; text: string; x: number; y: number }>({
|
||||||
visible: false,
|
visible: false,
|
||||||
@ -61,15 +63,15 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Copied',
|
title: t('referralManagement.copied'),
|
||||||
message: 'Link copied to Zwischenablage.',
|
message: t('referralManagement.copiedMessage'),
|
||||||
duration: 2500,
|
duration: 2500,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Copy failed',
|
title: t('referralManagement.copyFailed'),
|
||||||
message: 'Could not copy link to clipboard.',
|
message: t('referralManagement.copyFailedMessage'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,19 +80,19 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
<>
|
<>
|
||||||
<div className="mt-8 bg-white rounded-lg shadow-sm border border-gray-200">
|
<div className="mt-8 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
<div className="p-6 border-b border-gray-100">
|
<div className="p-6 border-b border-gray-100">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">All Referral Links</h2>
|
<h2 className="text-xl font-semibold text-gray-900">{t('referralManagement.allLinks')}</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">Manage your links and see their status.</p>
|
<p className="text-sm text-gray-600 mt-1">{t('referralManagement.allLinksSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Link</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colLink')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colCreated')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expires</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colExpires')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Usage</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colUsage')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colStatus')}</th>
|
||||||
<th className="px-6 py-3" />
|
<th className="px-6 py-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -98,7 +100,7 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
{links.length === 0 ? (
|
{links.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-6 py-6 text-sm text-gray-500">
|
<td colSpan={6} className="px-6 py-6 text-sm text-gray-500">
|
||||||
No referral links found.
|
{t('referralManagement.noLinks')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@ -106,7 +108,7 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
const createdDate = l.createdAt ? new Date(l.createdAt) : null
|
const createdDate = l.createdAt ? new Date(l.createdAt) : null
|
||||||
const created = createdDate ? createdDate.toLocaleString() : '—'
|
const created = createdDate ? createdDate.toLocaleString() : '—'
|
||||||
const expiresDate = l.expiresAt ? new Date(l.expiresAt) : null
|
const expiresDate = l.expiresAt ? new Date(l.expiresAt) : null
|
||||||
const expires = expiresDate ? expiresDate.toLocaleString() : 'Never'
|
const expires = expiresDate ? expiresDate.toLocaleString() : t('referralManagement.never')
|
||||||
const unlimited = !!(l.isUnlimited || l.maxUses === -1)
|
const unlimited = !!(l.isUnlimited || l.maxUses === -1)
|
||||||
|
|
||||||
// Usage text and badge color
|
// Usage text and badge color
|
||||||
@ -115,7 +117,7 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
: 0
|
: 0
|
||||||
const usage =
|
const usage =
|
||||||
unlimited
|
unlimited
|
||||||
? 'Unlimited'
|
? t('referralManagement.unlimited')
|
||||||
: (typeof l.uses === 'number' && typeof l.maxUses === 'number' && l.maxUses > 0
|
: (typeof l.uses === 'number' && typeof l.maxUses === 'number' && l.maxUses > 0
|
||||||
? `${l.uses} / ${l.maxUses}`
|
? `${l.uses} / ${l.maxUses}`
|
||||||
: (typeof l.uses === 'number' ? String(l.uses) : '—'))
|
: (typeof l.uses === 'number' ? String(l.uses) : '—'))
|
||||||
@ -172,16 +174,16 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
transition-all duration-150 hover:bg-gray-50 hover:shadow-sm hover:-translate-y-0.5 active:translate-y-0"
|
transition-all duration-150 hover:bg-gray-50 hover:shadow-sm hover:-translate-y-0.5 active:translate-y-0"
|
||||||
>
|
>
|
||||||
<ClipboardDocumentIcon className="h-4 w-4" />
|
<ClipboardDocumentIcon className="h-4 w-4" />
|
||||||
Copy
|
{t('referralManagement.copy')}
|
||||||
</button>
|
</button>
|
||||||
{/* Mobile: only copy button */}
|
{/* Mobile: only copy button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => copyToClipboard(l.url || String(l.code || ''))}
|
onClick={() => copyToClipboard(l.url || String(l.code || ''))}
|
||||||
className="inline-flex md:hidden items-center gap-2 rounded border border-gray-300 px-3 py-2 text-xs text-gray-700 hover:bg-gray-50"
|
className="inline-flex md:hidden items-center gap-2 rounded border border-gray-300 px-3 py-2 text-xs text-gray-700 hover:bg-gray-50"
|
||||||
aria-label="Copy referral link"
|
aria-label={t('autofix.k77d5ecd9')}
|
||||||
>
|
>
|
||||||
<ClipboardDocumentIcon className="h-4 w-4" />
|
<ClipboardDocumentIcon className="h-4 w-4" />
|
||||||
Copy link
|
{t('referralManagement.copyMobile')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -226,7 +228,7 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:shadow-none
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:shadow-none
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Deactivate
|
{t('referralManagement.deactivate')}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
BuildingOffice2Icon,
|
BuildingOffice2Icon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import LevelTrackerWidget from './levelTrackerWidget' // NEW
|
import LevelTrackerWidget from './levelTrackerWidget' // NEW
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
type Stats = {
|
type Stats = {
|
||||||
activeLinks: number
|
activeLinks: number
|
||||||
@ -39,14 +40,15 @@ const renderStatCard = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
export default function ReferralStatisticWidget({ stats, totalReferredFromBackend }: Props) {
|
export default function ReferralStatisticWidget({ stats, totalReferredFromBackend }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const topStats = [
|
const topStats = [
|
||||||
{ label: 'Active Links', value: stats.activeLinks, icon: CheckCircleIcon, color: 'bg-green-500' },
|
{ label: t('referralManagement.statsActiveLinks'), value: stats.activeLinks, icon: CheckCircleIcon, color: 'bg-green-500' },
|
||||||
{ label: 'Links Used', value: stats.linksUsed, icon: ChartBarIcon, color: 'bg-indigo-500' },
|
{ label: t('referralManagement.statsLinksUsed'), value: stats.linksUsed, icon: ChartBarIcon, color: 'bg-indigo-500' },
|
||||||
]
|
]
|
||||||
const bottomStats = [
|
const bottomStats = [
|
||||||
{ label: 'Personal Users', value: stats.personalUsersReferred, icon: UsersIcon, color: 'bg-blue-500' },
|
{ label: t('referralManagement.statsPersonalUsers'), value: stats.personalUsersReferred, icon: UsersIcon, color: 'bg-blue-500' },
|
||||||
{ label: 'Company Users', value: stats.companyUsersReferred, icon: BuildingOffice2Icon, color: 'bg-amber-600' },
|
{ label: t('referralManagement.statsCompanyUsers'), value: stats.companyUsersReferred, icon: BuildingOffice2Icon, color: 'bg-amber-600' },
|
||||||
{ label: 'Total Links', value: stats.totalLinks, icon: LinkIcon, color: 'bg-yellow-500' },
|
{ label: t('referralManagement.statsTotalLinks'), value: stats.totalLinks, icon: LinkIcon, color: 'bg-yellow-500' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// NEW: prefer backend total_referred_users if provided
|
// NEW: prefer backend total_referred_users if provided
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import { UsersIcon } from '@heroicons/react/24/outline'
|
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
type UserType = 'personal' | 'company'
|
type UserType = 'personal' | 'company'
|
||||||
type UserStatus = 'active' | 'inactive' | 'pending' | 'blocked'
|
type UserStatus = 'active' | 'inactive' | 'pending' | 'blocked'
|
||||||
@ -59,6 +60,7 @@ function exportCsv(rows: RegisteredUser[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RegisteredUserList({ users, loading }: Props) {
|
export default function RegisteredUserList({ users, loading }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
// Normalize backend shape to local RegisteredUser shape
|
// Normalize backend shape to local RegisteredUser shape
|
||||||
const normalizedUsers = useMemo<RegisteredUser[]>(() => {
|
const normalizedUsers = useMemo<RegisteredUser[]>(() => {
|
||||||
if (!users || users.length === 0) return []
|
if (!users || users.length === 0) return []
|
||||||
@ -129,28 +131,28 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
<div className="mt-8 mb-8 bg-white rounded-lg shadow-sm border border-gray-200">
|
<div className="mt-8 mb-8 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
<div className="p-6 border-b border-gray-100 flex items-start justify-between gap-4">
|
<div className="p-6 border-b border-gray-100 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Registered Users via Your Referral</h2>
|
<h2 className="text-xl font-semibold text-gray-900">{t('referralManagement.registeredUsersTitle')}</h2>
|
||||||
<div className="mt-2 inline-flex items-center gap-2">
|
<div className="mt-2 inline-flex items-center gap-2">
|
||||||
<span className="inline-flex items-center gap-2 rounded-full bg-violet-100 text-violet-800 px-3 py-1 text-[11px] font-semibold tracking-wide">
|
<span className="inline-flex items-center gap-2 rounded-full bg-violet-100 text-violet-800 px-3 py-1 text-[11px] font-semibold tracking-wide">
|
||||||
<UsersIcon className="h-4 w-4" />
|
<UsersIcon className="h-4 w-4" />
|
||||||
TOTAL REGISTERED USER WITH YOUR REF LINK
|
{t('referralManagement.totalRefBadge')}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center rounded-full bg-violet-600 text-white px-2 py-1 text-[11px] font-bold">
|
<span className="inline-flex items-center rounded-full bg-violet-600 text-white px-2 py-1 text-[11px] font-bold">
|
||||||
{totalRegistered}
|
{totalRegistered}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mt-2">
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
Users who signed up using one of your referral links.
|
{t('referralManagement.registeredUsersSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
Showing the latest 5 users. Use “View all” to see the complete list.
|
{t('referralManagement.showingLatest5')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={resetAndOpen}
|
onClick={resetAndOpen}
|
||||||
className="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-500"
|
className="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-500"
|
||||||
>
|
>
|
||||||
View all
|
{t('referralManagement.viewAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -158,11 +160,11 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colUser')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colEmail')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colType')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Registered</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colRegistered')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colStatus')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 bg-white">
|
<tbody className="divide-y divide-gray-100 bg-white">
|
||||||
@ -175,7 +177,7 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-6 py-6 text-sm text-gray-500" colSpan={5}>
|
<td className="px-6 py-6 text-sm text-gray-500" colSpan={5}>
|
||||||
No registered users found.
|
{t('referralManagement.noRegisteredUsers')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@ -187,7 +189,7 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
<td className="px-6 py-4 text-sm text-gray-700">{u.email}</td>
|
<td className="px-6 py-4 text-sm text-gray-700">{u.email}</td>
|
||||||
<td className="px-6 py-4 text-sm">
|
<td className="px-6 py-4 text-sm">
|
||||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
||||||
{u.userType === 'company' ? 'Company' : 'Personal'}
|
{u.userType === 'company' ? t('referralManagement.typeCompany') : t('referralManagement.typePersonal')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-700">{date}</td>
|
<td className="px-6 py-4 text-sm text-gray-700">{date}</td>
|
||||||
@ -213,21 +215,21 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
<div className="w-full max-w-6xl bg-white rounded-xl shadow-2xl ring-1 ring-black/10 overflow-hidden">
|
<div className="w-full max-w-6xl bg-white rounded-xl shadow-2xl ring-1 ring-black/10 overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between gap-3">
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">All Registered Users via Your Referral</h3>
|
<h3 className="text-lg font-semibold text-gray-900">{t('referralManagement.allRegisteredUsersTitle')}</h3>
|
||||||
<p className="text-xs text-gray-600">Search, filter, paginate, or export the full list.</p>
|
<p className="text-xs text-gray-600">{t('referralManagement.allRegisteredUsersSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => exportCsv(filtered)}
|
onClick={() => exportCsv(filtered)}
|
||||||
className="inline-flex items-center rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
className="inline-flex items-center rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Export CSV
|
{t('referralManagement.exportCsv')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="inline-flex items-center rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-800"
|
className="inline-flex items-center rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
Close
|
{t('common.close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -236,7 +238,7 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => { setQuery(e.target.value); setPage(1) }}
|
onChange={e => { setQuery(e.target.value); setPage(1) }}
|
||||||
placeholder="Search name or email…"
|
placeholder={t('referralManagement.searchPlaceholder')}
|
||||||
className="md:col-span-2 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder:text-gray-700 placeholder:opacity-100"
|
className="md:col-span-2 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder:text-gray-700 placeholder:opacity-100"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
@ -244,20 +246,20 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
onChange={e => { setTypeFilter(e.target.value as any); setPage(1) }}
|
onChange={e => { setTypeFilter(e.target.value as any); setPage(1) }}
|
||||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900"
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900"
|
||||||
>
|
>
|
||||||
<option value="all">All Types</option>
|
<option value="all">{t('referralManagement.filterAllTypes')}</option>
|
||||||
<option value="personal">Personal</option>
|
<option value="personal">{t('referralManagement.typePersonal')}</option>
|
||||||
<option value="company">Company</option>
|
<option value="company">{t('referralManagement.typeCompany')}</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={e => { setStatusFilter(e.target.value as any); setPage(1) }}
|
onChange={e => { setStatusFilter(e.target.value as any); setPage(1) }}
|
||||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900"
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900"
|
||||||
>
|
>
|
||||||
<option value="all">All Status</option>
|
<option value="all">{t('referralManagement.filterAllStatus')}</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">{t('referralManagement.filterActive')}</option>
|
||||||
<option value="inactive">Inactive</option>
|
<option value="inactive">{t('referralManagement.filterInactive')}</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">{t('referralManagement.filterPending')}</option>
|
||||||
<option value="blocked">Blocked</option>
|
<option value="blocked">{t('referralManagement.filterBlocked')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -265,18 +267,18 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colUser')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colEmail')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colType')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Registered</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colRegistered')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colStatus')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 bg-white">
|
<tbody className="divide-y divide-gray-100 bg-white">
|
||||||
{pageRows.length === 0 ? (
|
{pageRows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-6 py-6 text-sm text-gray-500" colSpan={5}>
|
<td className="px-6 py-6 text-sm text-gray-500" colSpan={5}>
|
||||||
No users match your filters.
|
{t('referralManagement.noUsersMatchFilters')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@ -288,7 +290,7 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
<td className="px-6 py-3 text-sm text-gray-700">{u.email}</td>
|
<td className="px-6 py-3 text-sm text-gray-700">{u.email}</td>
|
||||||
<td className="px-6 py-3 text-sm">
|
<td className="px-6 py-3 text-sm">
|
||||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
||||||
{u.userType === 'company' ? 'Company' : 'Personal'}
|
{u.userType === 'company' ? t('referralManagement.typeCompany') : t('referralManagement.typePersonal')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-sm text-gray-700">{date}</td>
|
<td className="px-6 py-3 text-sm text-gray-700">{date}</td>
|
||||||
@ -307,7 +309,7 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
|
|
||||||
<div className="px-6 py-4 flex items-center justify-between gap-3">
|
<div className="px-6 py-4 flex items-center justify-between gap-3">
|
||||||
<span className="text-xs text-gray-600">
|
<span className="text-xs text-gray-600">
|
||||||
Showing {pageRows.length} of {filtered.length} users
|
{t('referralManagement.showing')} {pageRows.length} {t('referralManagement.of')} {filtered.length} {t('referralManagement.colUser').toLowerCase()}s
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@ -315,17 +317,17 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 disabled:opacity-50 hover:bg-gray-50"
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 disabled:opacity-50 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Previous
|
{t('referralManagement.pagePrev')}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700">
|
||||||
Page {page} of {totalPages}
|
{t('referralManagement.pageOf').replace('{page}', String(page)).replace('{total}', String(totalPages))}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 disabled:opacity-50 hover:bg-gray-50"
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 disabled:opacity-50 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Next
|
{t('referralManagement.pageNext')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,9 +13,11 @@ import { useRouter } from 'next/navigation'
|
|||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import { useRegisteredUsers } from './hooks/registeredUsers'
|
import { useRegisteredUsers } from './hooks/registeredUsers'
|
||||||
import { ToastProvider, useToast } from '../components/toast/toastComponent' // NEW
|
import { ToastProvider, useToast } from '../components/toast/toastComponent' // NEW
|
||||||
|
import { useTranslation } from '../i18n/useTranslation'
|
||||||
|
|
||||||
function ReferralManagementPageInner() {
|
function ReferralManagementPageInner() {
|
||||||
const { showToast } = useToast() // NEW
|
const { showToast } = useToast() // NEW
|
||||||
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const isAuthReady = useAuthStore(s => s.isAuthReady)
|
const isAuthReady = useAuthStore(s => s.isAuthReady)
|
||||||
@ -56,14 +58,14 @@ function ReferralManagementPageInner() {
|
|||||||
if (res?.ok) {
|
if (res?.ok) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Link deactivated',
|
title: t('referralManagement.deactivated'),
|
||||||
message: 'The referral link has been deactivated successfully.',
|
message: t('referralManagement.deactivatedMessage'),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Deactivate failed',
|
title: t('referralManagement.deactivateFailed'),
|
||||||
message: (res as any)?.body?.message || 'Could not deactivate the referral link.',
|
message: (res as any)?.body?.message || t('referralManagement.deactivateFailedMessage'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,8 +73,8 @@ function ReferralManagementPageInner() {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Deactivate failed',
|
title: t('referralManagement.deactivateFailed'),
|
||||||
message: 'Network error while deactivating the referral link.',
|
message: t('referralManagement.deactivateNetworkError'),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setDeactivatePending(false)
|
setDeactivatePending(false)
|
||||||
@ -105,8 +107,8 @@ function ReferralManagementPageInner() {
|
|||||||
}
|
}
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Access check failed',
|
title: t('referralManagement.accessCheckFailed'),
|
||||||
message: 'User id is missing. Redirecting…',
|
message: t('referralManagement.userIdMissing'),
|
||||||
})
|
})
|
||||||
router.replace('/dashboard')
|
router.replace('/dashboard')
|
||||||
return
|
return
|
||||||
@ -158,8 +160,8 @@ function ReferralManagementPageInner() {
|
|||||||
if (!can) {
|
if (!can) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
title: 'Access denied',
|
title: t('referralManagement.accessDenied'),
|
||||||
message: 'You do not have permission to access Referral Management.',
|
message: t('referralManagement.accessDeniedMessage'),
|
||||||
})
|
})
|
||||||
router.replace('/dashboard')
|
router.replace('/dashboard')
|
||||||
}
|
}
|
||||||
@ -170,8 +172,8 @@ function ReferralManagementPageInner() {
|
|||||||
}
|
}
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Permission check failed',
|
title: t('referralManagement.permCheckFailed'),
|
||||||
message: 'Could not verify permissions. Redirecting…',
|
message: t('referralManagement.permCheckFailedMessage'),
|
||||||
})
|
})
|
||||||
router.replace('/dashboard')
|
router.replace('/dashboard')
|
||||||
}
|
}
|
||||||
@ -222,15 +224,15 @@ function ReferralManagementPageInner() {
|
|||||||
if (!statsRes.ok) {
|
if (!statsRes.ok) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Load failed',
|
title: t('referralManagement.loadFailed'),
|
||||||
message: 'Could not load referral statistics.',
|
message: t('referralManagement.loadStatsError'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!listRes.ok) {
|
if (!listRes.ok) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Load failed',
|
title: t('referralManagement.loadFailed'),
|
||||||
message: 'Could not load referral links.',
|
message: t('referralManagement.loadLinksError'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,7 +278,7 @@ function ReferralManagementPageInner() {
|
|||||||
<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-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||||
<p className="text-slate-700">Loading...</p>
|
<p className="text-slate-700">{t('common.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@ -289,9 +291,9 @@ function ReferralManagementPageInner() {
|
|||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Referral Management</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{t('referralManagement.title')}</h1>
|
||||||
<p className="text-gray-600 mt-2">
|
<p className="text-gray-600 mt-2">
|
||||||
Create and manage your referral links. Track performance at a glance.
|
{t('referralManagement.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
|
|||||||
import { useRegister } from '../hooks/useRegister'
|
import { useRegister } from '../hooks/useRegister'
|
||||||
import { useToast } from '../../components/toast/toastComponent'
|
import { useToast } from '../../components/toast/toastComponent'
|
||||||
import TelephoneInput, { TelephoneInputHandle } from '../../components/phone/telephoneInput'
|
import TelephoneInput, { TelephoneInputHandle } from '../../components/phone/telephoneInput'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
interface RegisterFormProps {
|
interface RegisterFormProps {
|
||||||
mode: 'personal' | 'company' | 'guest'
|
mode: 'personal' | 'company' | 'guest'
|
||||||
@ -80,6 +81,7 @@ export default function RegisterForm({
|
|||||||
// Hook for backend calls
|
// Hook for backend calls
|
||||||
const { registerPersonalReferral, registerCompanyReferral, registerGuest, error: regError } = useRegister()
|
const { registerPersonalReferral, registerCompanyReferral, registerGuest, error: regError } = useRegister()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
// Guest form state
|
// Guest form state
|
||||||
const [guestForm, setGuestForm] = useState({
|
const [guestForm, setGuestForm] = useState({
|
||||||
@ -132,22 +134,22 @@ export default function RegisterForm({
|
|||||||
!personalForm.email.trim() || !personalForm.confirmEmail.trim() ||
|
!personalForm.email.trim() || !personalForm.confirmEmail.trim() ||
|
||||||
!personalForm.password.trim() || !personalForm.confirmPassword.trim()
|
!personalForm.password.trim() || !personalForm.confirmPassword.trim()
|
||||||
) {
|
) {
|
||||||
setError('All fields are required')
|
setError(t('register.errorAllRequired'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (personalForm.email !== personalForm.confirmEmail) {
|
if (personalForm.email !== personalForm.confirmEmail) {
|
||||||
setError('Email addresses do not match')
|
setError(t('register.errorEmailMismatch'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (personalForm.password !== personalForm.confirmPassword) {
|
if (personalForm.password !== personalForm.confirmPassword) {
|
||||||
setError('Passwords do not match')
|
setError(t('register.errorPasswordMismatch'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(personalForm.password)) {
|
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(personalForm.password)) {
|
||||||
setError('Password must be at least 8 characters long and contain uppercase and lowercase letters, numbers and special characters')
|
setError(t('register.errorPasswordWeak'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,15 +159,15 @@ export default function RegisterForm({
|
|||||||
const valid = phoneApi?.isValid() ?? false
|
const valid = phoneApi?.isValid() ?? false
|
||||||
|
|
||||||
if (!dialCode) {
|
if (!dialCode) {
|
||||||
setError('Please select a country code from the dropdown before continuing.')
|
setError(t('register.errorSelectCountryCode'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!intlNumber) {
|
if (!intlNumber) {
|
||||||
setError('Please enter your phone number.')
|
setError(t('register.errorPhoneRequired'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setError('Please enter a valid mobile phone number.')
|
setError(t('register.errorPhoneInvalid'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,22 +180,22 @@ export default function RegisterForm({
|
|||||||
!companyForm.confirmCompanyEmail.trim() || !companyForm.contactPersonName.trim() ||
|
!companyForm.confirmCompanyEmail.trim() || !companyForm.contactPersonName.trim() ||
|
||||||
!companyForm.password.trim() || !companyForm.confirmPassword.trim()
|
!companyForm.password.trim() || !companyForm.confirmPassword.trim()
|
||||||
) {
|
) {
|
||||||
setError('All fields are required')
|
setError(t('register.errorAllRequired'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (companyForm.companyEmail !== companyForm.confirmCompanyEmail) {
|
if (companyForm.companyEmail !== companyForm.confirmCompanyEmail) {
|
||||||
setError('Email addresses do not match')
|
setError(t('register.errorEmailMismatch'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (companyForm.password !== companyForm.confirmPassword) {
|
if (companyForm.password !== companyForm.confirmPassword) {
|
||||||
setError('Passwords do not match')
|
setError(t('register.errorPasswordMismatch'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(companyForm.password)) {
|
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(companyForm.password)) {
|
||||||
setError('Password must be at least 8 characters long and contain uppercase and lowercase letters, numbers and special characters')
|
setError(t('register.errorPasswordWeak'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,15 +211,15 @@ export default function RegisterForm({
|
|||||||
const contactValid = contactApi?.isValid() ?? false
|
const contactValid = contactApi?.isValid() ?? false
|
||||||
|
|
||||||
if (!companyDialCode || !contactDialCode) {
|
if (!companyDialCode || !contactDialCode) {
|
||||||
setError('Please select country codes (dropdown) for both company and contact phone numbers.')
|
setError(t('register.errorBothCountryCodes'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!companyNumber || !contactNumber) {
|
if (!companyNumber || !contactNumber) {
|
||||||
setError('Please enter both company and contact phone numbers.')
|
setError(t('register.errorBothPhonesRequired'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!companyValid || !contactValid) {
|
if (!companyValid || !contactValid) {
|
||||||
setError('Please enter valid phone numbers for company and contact person.')
|
setError(t('register.errorBothPhonesInvalid'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,25 +256,25 @@ export default function RegisterForm({
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Registration successful',
|
title: t('register.successTitle'),
|
||||||
message: 'You can now log in with your new account.'
|
message: t('register.successMessage')
|
||||||
})
|
})
|
||||||
onRegistered()
|
onRegistered()
|
||||||
} else {
|
} else {
|
||||||
const msg = res.message || 'Registration failed. Please try again.'
|
const msg = res.message || t('register.failedMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Registration failed',
|
title: t('register.failedTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = 'Registration failed. Please try again.'
|
const msg = t('register.failedMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Registration failed',
|
title: t('register.failedTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@ -312,25 +314,25 @@ export default function RegisterForm({
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Registration successful',
|
title: t('register.successTitle'),
|
||||||
message: 'You can now log in with your new company account.'
|
message: t('register.successCompanyMessage')
|
||||||
})
|
})
|
||||||
onRegistered()
|
onRegistered()
|
||||||
} else {
|
} else {
|
||||||
const msg = res.message || 'Registration failed. Please try again.'
|
const msg = res.message || t('register.failedMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Registration failed',
|
title: t('register.failedTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = 'Registration failed. Please try again.'
|
const msg = t('register.failedMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Registration failed',
|
title: t('register.failedTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@ -344,7 +346,7 @@ export default function RegisterForm({
|
|||||||
setError(regError)
|
setError(regError)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Registration failed',
|
title: t('register.failedTitle'),
|
||||||
message: regError
|
message: regError
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -371,19 +373,19 @@ export default function RegisterForm({
|
|||||||
!guestForm.email.trim() || !guestForm.confirmEmail.trim() ||
|
!guestForm.email.trim() || !guestForm.confirmEmail.trim() ||
|
||||||
!guestForm.password.trim() || !guestForm.confirmPassword.trim()
|
!guestForm.password.trim() || !guestForm.confirmPassword.trim()
|
||||||
) {
|
) {
|
||||||
setError('All fields are required')
|
setError(t('register.errorAllRequired'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (guestForm.email !== guestForm.confirmEmail) {
|
if (guestForm.email !== guestForm.confirmEmail) {
|
||||||
setError('Email addresses do not match')
|
setError(t('register.errorEmailMismatch'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (guestForm.password !== guestForm.confirmPassword) {
|
if (guestForm.password !== guestForm.confirmPassword) {
|
||||||
setError('Passwords do not match')
|
setError(t('register.errorPasswordMismatch'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(guestForm.password)) {
|
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(guestForm.password)) {
|
||||||
setError('Password must be at least 8 characters long and contain uppercase and lowercase letters, numbers and special characters')
|
setError(t('register.errorPasswordWeak'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
@ -408,19 +410,19 @@ export default function RegisterForm({
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Registration successful',
|
title: t('register.successTitle'),
|
||||||
message: 'You can now log in to view your coffee abonnement.'
|
message: t('register.successGuestMessage')
|
||||||
})
|
})
|
||||||
onRegistered()
|
onRegistered()
|
||||||
} else {
|
} else {
|
||||||
const msg = res.message || 'Registration failed. Please try again.'
|
const msg = res.message || t('register.failedMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({ variant: 'error', title: 'Registration failed', message: msg })
|
showToast({ variant: 'error', title: t('register.failedTitle'), message: msg })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const msg = 'Registration failed. Please try again.'
|
const msg = t('register.failedMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({ variant: 'error', title: 'Registration failed', message: msg })
|
showToast({ variant: 'error', title: t('register.failedTitle'), message: msg })
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -440,16 +442,16 @@ export default function RegisterForm({
|
|||||||
const renderPasswordStrength = (password: string) => {
|
const renderPasswordStrength = (password: string) => {
|
||||||
const strength = getPasswordStrength(password)
|
const strength = getPasswordStrength(password)
|
||||||
const rules = [
|
const rules = [
|
||||||
{ test: password.length >= 8, text: 'At least 8 characters' },
|
{ test: password.length >= 8, text: t('register.pwdMinLength') },
|
||||||
{ test: /[a-z]/.test(password), text: 'Lowercase letters (a-z)' },
|
{ test: /[a-z]/.test(password), text: t('register.pwdLowercase') },
|
||||||
{ test: /[A-Z]/.test(password), text: 'Uppercase letters (A-Z)' },
|
{ test: /[A-Z]/.test(password), text: t('register.pwdUppercase') },
|
||||||
{ test: /\d/.test(password), text: 'Digits (0-9)' },
|
{ test: /\d/.test(password), text: t('register.pwdDigits') },
|
||||||
{ test: /[\W_]/.test(password), text: 'Special characters (!@#$...)' }
|
{ test: /[\W_]/.test(password), text: t('register.pwdSpecial') }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="text-sm text-slate-700 mb-2">Password requirements:</div>
|
<div className="text-sm text-slate-700 mb-2">{t('register.passwordRequirements')}</div>
|
||||||
<ul className="text-sm space-y-1">
|
<ul className="text-sm space-y-1">
|
||||||
{rules.map((rule, index) => (
|
{rules.map((rule, index) => (
|
||||||
<li
|
<li
|
||||||
@ -471,11 +473,11 @@ export default function RegisterForm({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-[#0F172A] mb-2">
|
<h2 className="text-2xl sm:text-3xl font-extrabold text-[#0F172A] mb-2">
|
||||||
Registration for Profit Planet
|
{t('register.formTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
{referrerEmail && (
|
{referrerEmail && (
|
||||||
<p className="text-base sm:text-sm text-[#8D6B1D] font-medium">
|
<p className="text-base sm:text-sm text-[#8D6B1D] font-medium">
|
||||||
You were invited by <span className="font-semibold">{referrerEmail}</span>!
|
{t('register.invitedBy')} <span className="font-semibold">{referrerEmail}</span>!
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -488,7 +490,7 @@ export default function RegisterForm({
|
|||||||
className="px-6 py-2 rounded-md font-semibold text-sm bg-[#8D6B1D] text-white shadow-sm cursor-default"
|
className="px-6 py-2 rounded-md font-semibold text-sm bg-[#8D6B1D] text-white shadow-sm cursor-default"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Guest
|
{t('register.tabGuest')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -501,7 +503,7 @@ export default function RegisterForm({
|
|||||||
onClick={() => setMode('personal')}
|
onClick={() => setMode('personal')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Individual
|
{t('register.tabIndividual')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
||||||
@ -512,7 +514,7 @@ export default function RegisterForm({
|
|||||||
onClick={() => setMode('company')}
|
onClick={() => setMode('company')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Company
|
{t('register.tabCompany')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -533,7 +535,7 @@ export default function RegisterForm({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="firstName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="firstName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
First name *
|
{t('register.firstName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -548,7 +550,7 @@ export default function RegisterForm({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="lastName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="lastName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Last name *
|
{t('register.lastName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -565,7 +567,7 @@ export default function RegisterForm({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="email" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Email address *
|
{t('register.email')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@ -580,7 +582,7 @@ export default function RegisterForm({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="confirmEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="confirmEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Confirm email *
|
{t('register.confirmEmail')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@ -595,15 +597,13 @@ export default function RegisterForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="phoneNumber" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="phoneNumber" className="block text-sm font-medium text-[#0F172A] mb-2">{t('autofix.kfb1676b0')}</label>
|
||||||
Phone number *
|
|
||||||
</label>
|
|
||||||
<TelephoneInput
|
<TelephoneInput
|
||||||
id="phoneNumber"
|
id="phoneNumber"
|
||||||
name="phoneNumber"
|
name="phoneNumber"
|
||||||
ref={personalPhoneRef}
|
ref={personalPhoneRef}
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
placeholder="e.g. +43 676 1234567"
|
placeholder={t('autofix.k9f56d4ac')}
|
||||||
required
|
required
|
||||||
onChange={e =>
|
onChange={e =>
|
||||||
setPersonalForm(prev => ({ ...prev, phoneNumber: (e.target as HTMLInputElement).value }))
|
setPersonalForm(prev => ({ ...prev, phoneNumber: (e.target as HTMLInputElement).value }))
|
||||||
@ -614,7 +614,7 @@ export default function RegisterForm({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="password" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Password *
|
{t('register.password')} *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@ -644,7 +644,7 @@ export default function RegisterForm({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Confirm password *
|
{t('register.confirmPassword')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type={showPersonalPassword ? 'text' : 'password'}
|
type={showPersonalPassword ? 'text' : 'password'}
|
||||||
@ -671,10 +671,10 @@ export default function RegisterForm({
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
Registration in progress...
|
{t('register.submitting')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Register now'
|
t('register.registerNow')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -683,7 +683,7 @@ export default function RegisterForm({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="companyName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="companyName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Company name *
|
{t('register.companyName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -698,7 +698,7 @@ export default function RegisterForm({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="contactPersonName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="contactPersonName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Contact person *
|
{t('register.contactPersonName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -715,7 +715,7 @@ export default function RegisterForm({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="companyEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="companyEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Company email *
|
{t('register.companyEmail')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@ -730,7 +730,7 @@ export default function RegisterForm({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="confirmCompanyEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="confirmCompanyEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Confirm email *
|
{t('register.confirmEmail')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@ -747,14 +747,14 @@ export default function RegisterForm({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="companyPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="companyPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Company phone *
|
{t('register.companyPhone')} *
|
||||||
</label>
|
</label>
|
||||||
<TelephoneInput
|
<TelephoneInput
|
||||||
id="companyPhone"
|
id="companyPhone"
|
||||||
name="companyPhone"
|
name="companyPhone"
|
||||||
ref={companyPhoneRef}
|
ref={companyPhoneRef}
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
placeholder="e.g. +43 1 234567"
|
placeholder={t('autofix.k8eaa7b3b')}
|
||||||
required
|
required
|
||||||
onChange={e =>
|
onChange={e =>
|
||||||
setCompanyForm(prev => ({ ...prev, companyPhone: (e.target as HTMLInputElement).value }))
|
setCompanyForm(prev => ({ ...prev, companyPhone: (e.target as HTMLInputElement).value }))
|
||||||
@ -764,14 +764,14 @@ export default function RegisterForm({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="contactPersonPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="contactPersonPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Contact person phone *
|
{t('register.contactPersonPhone')} *
|
||||||
</label>
|
</label>
|
||||||
<TelephoneInput
|
<TelephoneInput
|
||||||
id="contactPersonPhone"
|
id="contactPersonPhone"
|
||||||
name="contactPersonPhone"
|
name="contactPersonPhone"
|
||||||
ref={contactPhoneRef}
|
ref={contactPhoneRef}
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
placeholder="e.g. +43 676 1234567"
|
placeholder={t('autofix.k9f56d4ac')}
|
||||||
required
|
required
|
||||||
onChange={e =>
|
onChange={e =>
|
||||||
setCompanyForm(prev => ({
|
setCompanyForm(prev => ({
|
||||||
@ -786,7 +786,7 @@ export default function RegisterForm({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="password" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Password *
|
{t('register.password')} *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@ -816,7 +816,7 @@ export default function RegisterForm({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Confirm password *
|
{t('register.confirmPassword')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type={showCompanyPassword ? 'text' : 'password'}
|
type={showCompanyPassword ? 'text' : 'password'}
|
||||||
@ -843,10 +843,10 @@ export default function RegisterForm({
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
Registration in progress...
|
{t('register.submitting')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Register company'
|
t('register.submitCompany')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -854,14 +854,14 @@ export default function RegisterForm({
|
|||||||
<form onSubmit={handleGuestSubmit} className="space-y-6">
|
<form onSubmit={handleGuestSubmit} className="space-y-6">
|
||||||
<div className="p-4 bg-amber-50/70 backdrop-blur-[18px] border border-amber-200/70 rounded-lg mb-2">
|
<div className="p-4 bg-amber-50/70 backdrop-blur-[18px] border border-amber-200/70 rounded-lg mb-2">
|
||||||
<p className="text-amber-800 text-sm font-medium">
|
<p className="text-amber-800 text-sm font-medium">
|
||||||
You are registering as a guest. You will have access to your coffee abonnements only.
|
{t('register.guestNote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="guestFirstName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="guestFirstName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
First name *
|
{t('register.firstName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -875,7 +875,7 @@ export default function RegisterForm({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="guestLastName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="guestLastName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Last name *
|
{t('register.lastName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -892,7 +892,7 @@ export default function RegisterForm({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="guestEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="guestEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Email *
|
{t('register.email')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@ -906,7 +906,7 @@ export default function RegisterForm({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="guestConfirmEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="guestConfirmEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Confirm email *
|
{t('register.confirmEmail')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@ -923,7 +923,7 @@ export default function RegisterForm({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="guestPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="guestPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Password *
|
{t('register.password')} *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@ -951,7 +951,7 @@ export default function RegisterForm({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="guestConfirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
<label htmlFor="guestConfirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
Confirm password *
|
{t('register.confirmPassword')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@ -977,10 +977,10 @@ export default function RegisterForm({
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
Registration in progress...
|
{t('register.submitting')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Register as Guest'
|
t('register.submitGuest')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -990,12 +990,12 @@ export default function RegisterForm({
|
|||||||
{/* Login Link */}
|
{/* Login Link */}
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<p className="text-slate-700">
|
<p className="text-slate-700">
|
||||||
Already registered?{' '}
|
{t('register.alreadyHaveAccount')}{' '}
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href="/login"
|
||||||
className="text-[#8D6B1D] hover:text-[#7A5E1A] font-medium transition-colors"
|
className="text-[#8D6B1D] hover:text-[#7A5E1A] font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Login here
|
{t('register.loginHere')}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
import { Dialog, Transition } from '@headlessui/react'
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
interface SessionDetectedModalProps {
|
interface SessionDetectedModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -16,8 +17,7 @@ export default function SessionDetectedModal({
|
|||||||
onLogout,
|
onLogout,
|
||||||
onCancel,
|
onCancel,
|
||||||
inline = false
|
inline = false
|
||||||
}: SessionDetectedModalProps) {
|
}: SessionDetectedModalProps) { const { t } = useTranslation() // Make inline + non-inline consistent
|
||||||
// Make inline + non-inline consistent
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
if (inline) {
|
if (inline) {
|
||||||
@ -30,10 +30,10 @@ export default function SessionDetectedModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold leading-6 text-[#0F172A]">
|
<h3 className="text-base font-semibold leading-6 text-[#0F172A]">
|
||||||
Active session detected
|
{t('register.sessionDetectedTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm text-[#4A4A4A]">
|
<p className="mt-2 text-sm text-[#4A4A4A]">
|
||||||
You are already logged in. To register, you must first log out or you can go to the dashboard.
|
{t('register.sessionDescription')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-5 flex flex-col sm:flex-row gap-3 sm:justify-end">
|
<div className="mt-5 flex flex-col sm:flex-row gap-3 sm:justify-end">
|
||||||
<button
|
<button
|
||||||
@ -41,14 +41,14 @@ export default function SessionDetectedModal({
|
|||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="inline-flex justify-center rounded-md bg-white/80 px-4 py-2 text-sm font-medium text-[#4A4A4A] shadow-sm ring-1 ring-gray-300/70 hover:bg-gray-50 transition-colors"
|
className="inline-flex justify-center rounded-md bg-white/80 px-4 py-2 text-sm font-medium text-[#4A4A4A] shadow-sm ring-1 ring-gray-300/70 hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
Go to dashboard
|
{t('register.goToDashboard')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
className="inline-flex justify-center rounded-md bg-[#8D6B1D] px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] transition-colors"
|
className="inline-flex justify-center rounded-md bg-[#8D6B1D] px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] transition-colors"
|
||||||
>
|
>
|
||||||
Log out and register
|
{t('register.sessionLogout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -98,11 +98,11 @@ export default function SessionDetectedModal({
|
|||||||
as="h3"
|
as="h3"
|
||||||
className="text-base font-semibold leading-6 text-[#0F172A]"
|
className="text-base font-semibold leading-6 text-[#0F172A]"
|
||||||
>
|
>
|
||||||
Active session detected
|
{t('register.sessionDetectedTitle')}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm text-[#4A4A4A]">
|
<p className="text-sm text-[#4A4A4A]">
|
||||||
You are already logged in. To register, you must first log out or you can go to the dashboard.
|
{t('register.sessionDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -113,14 +113,14 @@ export default function SessionDetectedModal({
|
|||||||
className="inline-flex w-full justify-center rounded-md bg-[#8D6B1D] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] sm:ml-3 sm:w-auto transition-colors"
|
className="inline-flex w-full justify-center rounded-md bg-[#8D6B1D] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] sm:ml-3 sm:w-auto transition-colors"
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
>
|
>
|
||||||
Log out and register
|
{t('register.sessionLogout')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-[#4A4A4A] shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto transition-colors"
|
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-[#4A4A4A] shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto transition-colors"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
Go to dashboard
|
{t('register.goToDashboard')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
interface InvalidRefLinkModalProps {
|
interface InvalidRefLinkModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -18,6 +19,7 @@ export default function InvalidRefLinkModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onGoHome
|
onGoHome
|
||||||
}: InvalidRefLinkModalProps) {
|
}: InvalidRefLinkModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
const Content = (
|
const Content = (
|
||||||
@ -27,13 +29,13 @@ export default function InvalidRefLinkModal({
|
|||||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-[#0F172A]">Invalid invitation link</h3>
|
<h3 className="text-lg font-semibold text-[#0F172A]">{t('register.invalidLinkTitle')}</h3>
|
||||||
<p className="mt-1 text-sm text-slate-700">
|
<p className="mt-1 text-sm text-slate-700">
|
||||||
This registration link is invalid or no longer active. Please request a new link.
|
{t('register.invalidLinkMessage')}
|
||||||
</p>
|
</p>
|
||||||
{token && (
|
{token && (
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
Token: <span className="font-mono break-all">{token}</span>
|
{t('register.tokenLabel')}: <span className="font-mono break-all">{token}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
@ -41,14 +43,14 @@ export default function InvalidRefLinkModal({
|
|||||||
onClick={onGoHome}
|
onClick={onGoHome}
|
||||||
className="inline-flex items-center rounded-md bg-[#8D6B1D] px-3.5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] transition-colors"
|
className="inline-flex items-center rounded-md bg-[#8D6B1D] px-3.5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] transition-colors"
|
||||||
>
|
>
|
||||||
Go to homepage
|
{t('register.goToHomepage')}
|
||||||
</button>
|
</button>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="inline-flex items-center rounded-md border border-slate-300/80 bg-white/70 px-3.5 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-50/90 transition-colors"
|
className="inline-flex items-center rounded-md border border-slate-300/80 bg-white/70 px-3.5 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-50/90 transition-colors"
|
||||||
>
|
>
|
||||||
Close
|
{t('common.close')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,9 +10,11 @@ import InvalidRefLinkModal from './components/invalidRefLinkModal'
|
|||||||
import { ToastProvider, useToast } from '../components/toast/toastComponent'
|
import { ToastProvider, useToast } from '../components/toast/toastComponent'
|
||||||
import Waves from '../components/background/waves'
|
import Waves from '../components/background/waves'
|
||||||
import BlueBlurryBackground from '../components/background/blueblurry' // NEW
|
import BlueBlurryBackground from '../components/background/blueblurry' // NEW
|
||||||
|
import { useTranslation } from '../i18n/useTranslation'
|
||||||
|
|
||||||
// NEW: inner component that actually uses useToast and all the logic
|
// NEW: inner component that actually uses useToast and all the logic
|
||||||
function RegisterPageInner() {
|
function RegisterPageInner() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const refToken = searchParams.get('ref')
|
const refToken = searchParams.get('ref')
|
||||||
const isGuestInvite = searchParams.get('guest') === 'true'
|
const isGuestInvite = searchParams.get('guest') === 'true'
|
||||||
@ -59,8 +61,8 @@ function RegisterPageInner() {
|
|||||||
}
|
}
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invitation error',
|
title: t('register.invalidInvitationTitle'),
|
||||||
message: 'No invitation token found in the link.'
|
message: t('register.noInvitationToken')
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -91,16 +93,16 @@ function RegisterPageInner() {
|
|||||||
setInvalidRef(false)
|
setInvalidRef(false)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Invitation verified',
|
title: t('register.invitationVerifiedTitle'),
|
||||||
message: 'Your invitation link is valid. You can register now.'
|
message: t('register.invitationVerifiedMessage')
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const reason = body?.reason || `HTTP ${res.status}`
|
const reason = body?.reason || `HTTP ${res.status}`
|
||||||
setInvalidRef(true)
|
setInvalidRef(true)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invalid invitation',
|
title: t('register.invalidInvitationTitle'),
|
||||||
message: `Reason: ${reason}. This invitation link is invalid or no longer active.`
|
message: `${reason ? `${reason}. ` : ''}${t('register.invalidInvitationMessage')}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setIsRefChecked(true)
|
setIsRefChecked(true)
|
||||||
@ -113,8 +115,8 @@ function RegisterPageInner() {
|
|||||||
}
|
}
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Network error',
|
title: t('common.error'),
|
||||||
message: 'Could not reach the server. Is the backend running?'
|
message: t('register.networkError')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -214,7 +216,7 @@ function RegisterPageInner() {
|
|||||||
<div className="absolute -top-24 -right-24 size-56 rounded-full bg-gradient-to-br from-indigo-500/10 to-fuchsia-500/10 blur-3xl pointer-events-none" />
|
<div className="absolute -top-24 -right-24 size-56 rounded-full bg-gradient-to-br from-indigo-500/10 to-fuchsia-500/10 blur-3xl pointer-events-none" />
|
||||||
<div className="relative text-center">
|
<div className="relative text-center">
|
||||||
<div className="animate-spin rounded-full h-10 w-10 border-2 border-b-transparent border-[#8D6B1D] mx-auto mb-4" />
|
<div className="animate-spin rounded-full h-10 w-10 border-2 border-b-transparent border-[#8D6B1D] mx-auto mb-4" />
|
||||||
<p className="text-slate-700">Checking invitation link…</p>
|
<p className="text-slate-700">{t('register.checkingInvitation')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -282,12 +284,12 @@ function RegisterPageInner() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="mx-auto max-w-2xl text-center mb-8">
|
<div className="mx-auto max-w-2xl text-center mb-8">
|
||||||
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">
|
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">
|
||||||
{mode === 'guest' ? 'Guest Registration' : 'Register now'}
|
{mode === 'guest' ? t('register.guestRegistration') : t('register.registerNow')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-slate-700 text-base sm:text-lg">
|
<p className="mt-3 text-slate-700 text-base sm:text-lg">
|
||||||
{mode === 'guest'
|
{mode === 'guest'
|
||||||
? 'Register as a guest to access your coffee abonnement.'
|
? t('register.guestDescription')
|
||||||
: 'Create your personal or company account with Profit Planet.'}
|
: t('register.personalDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -314,7 +316,7 @@ function RegisterPageInner() {
|
|||||||
)}
|
)}
|
||||||
{registered && (
|
{registered && (
|
||||||
<div className="mt-6 mx-auto text-center text-sm text-gray-700">
|
<div className="mt-6 mx-auto text-center text-sm text-gray-700">
|
||||||
Registration successful – redirecting...
|
{t('register.successRedirecting')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -329,22 +331,26 @@ function RegisterPageInner() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: default export only provides the ToastProvider wrapper
|
function RegisterSuspenseFallback() {
|
||||||
export default function RegisterPage() {
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<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-2 border-b-transparent border-[#8D6B1D] mx-auto mb-3" />
|
<div className="animate-spin rounded-full h-10 w-10 border-2 border-b-transparent border-[#8D6B1D] mx-auto mb-3" />
|
||||||
<p className="text-[#4A4A4A]">Loading...</p>
|
<p className="text-[#4A4A4A]">{t('autofix.k832387c5')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
)
|
||||||
>
|
}
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<Suspense fallback={<RegisterSuspenseFallback />}>
|
||||||
<RegisterPageInner />
|
<RegisterPageInner />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { MagnifyingGlassIcon, FunnelIcon, Squares2X2Icon, ListBulletIcon, HeartIcon, StarIcon, ShoppingCartIcon } from '@heroicons/react/20/solid'
|
import { MagnifyingGlassIcon, FunnelIcon, Squares2X2Icon, ListBulletIcon, HeartIcon, StarIcon, ShoppingCartIcon } from '@heroicons/react/20/solid'
|
||||||
@ -131,6 +134,7 @@ const sampleProducts = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function StorePage() {
|
export default function StorePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const SHOW_SHOP = 'true'
|
const SHOW_SHOP = 'true'
|
||||||
if (!SHOW_SHOP) notFound()
|
if (!SHOW_SHOP) notFound()
|
||||||
const [selectedCategory, setSelectedCategory] = useState('Alle Kategorien')
|
const [selectedCategory, setSelectedCategory] = useState('Alle Kategorien')
|
||||||
@ -236,12 +240,8 @@ export default function StorePage() {
|
|||||||
<div className="mx-auto max-w-7xl">
|
<div className="mx-auto max-w-7xl">
|
||||||
{/* Store Title */}
|
{/* Store Title */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
|
<h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">{t('autofix.ka7073aee')}</h1>
|
||||||
Profit Planet Store
|
<p className="mt-2 text-lg text-gray-300">{t('autofix.kd68da70d')}</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-lg text-gray-300">
|
|
||||||
Nachhaltige Produkte für deinen Erfolg
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search & Navigation Section */}
|
{/* Search & Navigation Section */}
|
||||||
@ -257,7 +257,7 @@ export default function StorePage() {
|
|||||||
name="search"
|
name="search"
|
||||||
id="search"
|
id="search"
|
||||||
className="block w-full rounded-lg border-0 bg-white/90 backdrop-blur-sm py-3 pl-10 pr-3 text-gray-900 placeholder-gray-500 shadow-lg ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
className="block w-full rounded-lg border-0 bg-white/90 backdrop-blur-sm py-3 pl-10 pr-3 text-gray-900 placeholder-gray-500 shadow-lg ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
||||||
placeholder="Produkte durchsuchen..."
|
placeholder={t('autofix.k63458f03')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -298,8 +298,7 @@ export default function StorePage() {
|
|||||||
{/* Left: Results Count & Filters */}
|
{/* Left: Results Count & Filters */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
<span className="font-medium">{sortedProducts.length}</span> Produkte gefunden
|
<span className="font-medium">{sortedProducts.length}</span>{t('autofix.k55aba973')}</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -372,14 +371,14 @@ export default function StorePage() {
|
|||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Min €"
|
placeholder={t('autofix.k0c838ec3')}
|
||||||
value={priceRange.min}
|
value={priceRange.min}
|
||||||
onChange={(e) => setPriceRange({...priceRange, min: e.target.value})}
|
onChange={(e) => setPriceRange({...priceRange, min: e.target.value})}
|
||||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Max €"
|
placeholder={t('autofix.k0c87d75d')}
|
||||||
value={priceRange.max}
|
value={priceRange.max}
|
||||||
onChange={(e) => setPriceRange({...priceRange, max: e.target.value})}
|
onChange={(e) => setPriceRange({...priceRange, max: e.target.value})}
|
||||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
||||||
@ -418,7 +417,7 @@ export default function StorePage() {
|
|||||||
|
|
||||||
{/* Availability */}
|
{/* Availability */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">Verfügbarkeit</h3>
|
<h3 className="text-sm font-medium text-gray-900">{t('autofix.k56435c9b')}</h3>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -433,7 +432,7 @@ export default function StorePage() {
|
|||||||
}}
|
}}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-[#8D6B1D] focus:ring-[#8D6B1D]"
|
className="h-4 w-4 rounded border-gray-300 text-[#8D6B1D] focus:ring-[#8D6B1D]"
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 text-sm text-gray-600">Auf Lager</span>
|
<span className="ml-2 text-sm text-gray-600">{t('autofix.k21db276a')}</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -462,7 +461,7 @@ export default function StorePage() {
|
|||||||
onChange={(e) => setSelectedBrand(e.target.value)}
|
onChange={(e) => setSelectedBrand(e.target.value)}
|
||||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
||||||
>
|
>
|
||||||
<option>Alle Marken</option>
|
<option>{t('autofix.kd1c17b3f')}</option>
|
||||||
<option>EcoTech</option>
|
<option>EcoTech</option>
|
||||||
<option>GreenLife</option>
|
<option>GreenLife</option>
|
||||||
<option>SustainableStyle</option>
|
<option>SustainableStyle</option>
|
||||||
@ -477,9 +476,7 @@ export default function StorePage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={resetFilters}
|
onClick={resetFilters}
|
||||||
className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 transition-colors duration-200"
|
className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 transition-colors duration-200"
|
||||||
>
|
>{t('autofix.k5e580e3f')}</button>
|
||||||
Filter zurücksetzen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -489,10 +486,8 @@ export default function StorePage() {
|
|||||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
{sortedProducts.length === 0 ? (
|
{sortedProducts.length === 0 ? (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<p className="text-lg text-gray-500">Keine Produkte gefunden</p>
|
<p className="text-lg text-gray-500">{t('autofix.k4cb62cff')}</p>
|
||||||
<p className="mt-2 text-sm text-gray-400">
|
<p className="mt-2 text-sm text-gray-400">{t('autofix.k0cc2a3ba')}</p>
|
||||||
Versuche andere Suchbegriffe oder Filter
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { MagnifyingGlassIcon, FunnelIcon, Squares2X2Icon, ListBulletIcon, HeartIcon, StarIcon, ShoppingCartIcon } from '@heroicons/react/20/solid'
|
import { MagnifyingGlassIcon, FunnelIcon, Squares2X2Icon, ListBulletIcon, HeartIcon, StarIcon, ShoppingCartIcon } from '@heroicons/react/20/solid'
|
||||||
@ -132,6 +135,7 @@ const sampleProducts = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function StorePage() {
|
export default function StorePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const SHOW_SHOP = process.env.NEXT_PUBLIC_SHOW_SHOP === 'true'
|
const SHOW_SHOP = process.env.NEXT_PUBLIC_SHOW_SHOP === 'true'
|
||||||
if (!SHOW_SHOP) notFound()
|
if (!SHOW_SHOP) notFound()
|
||||||
const [selectedCategory, setSelectedCategory] = useState('Alle Kategorien')
|
const [selectedCategory, setSelectedCategory] = useState('Alle Kategorien')
|
||||||
@ -237,12 +241,8 @@ export default function StorePage() {
|
|||||||
<div className="mx-auto max-w-7xl">
|
<div className="mx-auto max-w-7xl">
|
||||||
{/* Store Title */}
|
{/* Store Title */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
|
<h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">{t('autofix.ka7073aee')}</h1>
|
||||||
Profit Planet Store
|
<p className="mt-2 text-lg text-gray-300">{t('autofix.kd68da70d')}</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-lg text-gray-300">
|
|
||||||
Nachhaltige Produkte für deinen Erfolg
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search & Navigation Section */}
|
{/* Search & Navigation Section */}
|
||||||
@ -258,7 +258,7 @@ export default function StorePage() {
|
|||||||
name="search"
|
name="search"
|
||||||
id="search"
|
id="search"
|
||||||
className="block w-full rounded-lg border-0 bg-white/90 backdrop-blur-sm py-3 pl-10 pr-3 text-gray-900 placeholder-gray-500 shadow-lg ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
className="block w-full rounded-lg border-0 bg-white/90 backdrop-blur-sm py-3 pl-10 pr-3 text-gray-900 placeholder-gray-500 shadow-lg ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
||||||
placeholder="Produkte durchsuchen..."
|
placeholder={t('autofix.k63458f03')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -299,8 +299,7 @@ export default function StorePage() {
|
|||||||
{/* Left: Results Count & Filters */}
|
{/* Left: Results Count & Filters */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
<span className="font-medium">{sortedProducts.length}</span> Produkte gefunden
|
<span className="font-medium">{sortedProducts.length}</span>{t('autofix.k55aba973')}</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -373,14 +372,14 @@ export default function StorePage() {
|
|||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Min €"
|
placeholder={t('autofix.k0c838ec3')}
|
||||||
value={priceRange.min}
|
value={priceRange.min}
|
||||||
onChange={(e) => setPriceRange({...priceRange, min: e.target.value})}
|
onChange={(e) => setPriceRange({...priceRange, min: e.target.value})}
|
||||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Max €"
|
placeholder={t('autofix.k0c87d75d')}
|
||||||
value={priceRange.max}
|
value={priceRange.max}
|
||||||
onChange={(e) => setPriceRange({...priceRange, max: e.target.value})}
|
onChange={(e) => setPriceRange({...priceRange, max: e.target.value})}
|
||||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
||||||
@ -419,7 +418,7 @@ export default function StorePage() {
|
|||||||
|
|
||||||
{/* Availability */}
|
{/* Availability */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">Verfügbarkeit</h3>
|
<h3 className="text-sm font-medium text-gray-900">{t('autofix.k56435c9b')}</h3>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -434,7 +433,7 @@ export default function StorePage() {
|
|||||||
}}
|
}}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-[#8D6B1D] focus:ring-[#8D6B1D]"
|
className="h-4 w-4 rounded border-gray-300 text-[#8D6B1D] focus:ring-[#8D6B1D]"
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 text-sm text-gray-600">Auf Lager</span>
|
<span className="ml-2 text-sm text-gray-600">{t('autofix.k21db276a')}</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
@ -463,7 +462,7 @@ export default function StorePage() {
|
|||||||
onChange={(e) => setSelectedBrand(e.target.value)}
|
onChange={(e) => setSelectedBrand(e.target.value)}
|
||||||
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-[#8D6B1D] sm:text-sm sm:leading-6"
|
||||||
>
|
>
|
||||||
<option>Alle Marken</option>
|
<option>{t('autofix.kd1c17b3f')}</option>
|
||||||
<option>EcoTech</option>
|
<option>EcoTech</option>
|
||||||
<option>GreenLife</option>
|
<option>GreenLife</option>
|
||||||
<option>SustainableStyle</option>
|
<option>SustainableStyle</option>
|
||||||
@ -478,9 +477,7 @@ export default function StorePage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={resetFilters}
|
onClick={resetFilters}
|
||||||
className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 transition-colors duration-200"
|
className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 transition-colors duration-200"
|
||||||
>
|
>{t('autofix.k5e580e3f')}</button>
|
||||||
Filter zurücksetzen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -490,10 +487,8 @@ export default function StorePage() {
|
|||||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
{sortedProducts.length === 0 ? (
|
{sortedProducts.length === 0 ? (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<p className="text-lg text-gray-500">Keine Produkte gefunden</p>
|
<p className="text-lg text-gray-500">{t('autofix.k4cb62cff')}</p>
|
||||||
<p className="mt-2 text-sm text-gray-400">
|
<p className="mt-2 text-sm text-gray-400">{t('autofix.k0cc2a3ba')}</p>
|
||||||
Versuche andere Suchbegriffe oder Filter
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { StarIcon } from '@heroicons/react/20/solid'
|
import { StarIcon } from '@heroicons/react/20/solid'
|
||||||
@ -30,6 +33,7 @@ function classNames(...classes: (string | undefined | null | boolean)[]): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function VipShopPage() {
|
export default function VipShopPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const SHOW_SHOP = process.env.NEXT_PUBLIC_SHOW_SHOP === 'true'
|
const SHOW_SHOP = process.env.NEXT_PUBLIC_SHOW_SHOP === 'true'
|
||||||
if (!SHOW_SHOP) notFound()
|
if (!SHOW_SHOP) notFound()
|
||||||
|
|
||||||
@ -67,7 +71,7 @@ export default function VipShopPage() {
|
|||||||
<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-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||||
<p className="text-[#4A4A4A]">Shop wird geladen...</p>
|
<p className="text-[#4A4A4A]">{t('autofix.k5fb70267')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@ -81,12 +85,8 @@ export default function VipShopPage() {
|
|||||||
<div className="pt-16 pb-80 sm:pt-24 sm:pb-40 lg:pt-40 lg:pb-48">
|
<div className="pt-16 pb-80 sm:pt-24 sm:pb-40 lg:pt-40 lg:pb-48">
|
||||||
<div className="relative mx-auto max-w-7xl px-4 sm:static sm:px-6 lg:px-8">
|
<div className="relative mx-auto max-w-7xl px-4 sm:static sm:px-6 lg:px-8">
|
||||||
<div className="sm:max-w-lg">
|
<div className="sm:max-w-lg">
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">
|
<h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">{t('autofix.kf663ef67')}</h1>
|
||||||
Shop with an infinite variety of products
|
<p className="mt-4 text-xl text-gray-500">{t('autofix.kdca959c3')}</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-4 text-xl text-gray-500">
|
|
||||||
Discover a curated selection of high-quality products that cater to your every need.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
@ -124,9 +124,7 @@ export default function VipShopPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="#" className="inline-block rounded-md border border-transparent bg-indigo-600 px-8 py-3 text-center font-medium text-white hover:bg-indigo-700">
|
<a href="#" className="inline-block rounded-md border border-transparent bg-indigo-600 px-8 py-3 text-center font-medium text-white hover:bg-indigo-700">{t('autofix.kb8d6f3f7')}</a>
|
||||||
Shop Collection
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -136,10 +134,8 @@ export default function VipShopPage() {
|
|||||||
<div className="bg-white">
|
<div className="bg-white">
|
||||||
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
|
||||||
<div className="sm:flex sm:items-baseline sm:justify-between">
|
<div className="sm:flex sm:items-baseline sm:justify-between">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-gray-900">Trending right now</h2>
|
<h2 className="text-2xl font-bold tracking-tight text-gray-900">{t('autofix.k6ca85cda')}</h2>
|
||||||
<a href="#" className="hidden text-sm font-semibold text-indigo-600 hover:text-indigo-500 sm:block">
|
<a href="#" className="hidden text-sm font-semibold text-indigo-600 hover:text-indigo-500 sm:block">{t('autofix.kb0031873')}<span aria-hidden="true"> →</span>
|
||||||
Browse all trending
|
|
||||||
<span aria-hidden="true"> →</span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -159,9 +155,7 @@ export default function VipShopPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 sm:hidden">
|
<div className="mt-6 sm:hidden">
|
||||||
<a href="#" className="block text-sm font-semibold text-indigo-600 hover:text-indigo-500">
|
<a href="#" className="block text-sm font-semibold text-indigo-600 hover:text-indigo-500">{t('autofix.k2cd79a3d')}<span aria-hidden="true"> →</span>
|
||||||
Browse all favorites
|
|
||||||
<span aria-hidden="true"> →</span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,25 +3,27 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import PageLayout from '../components/PageLayout'
|
import PageLayout from '../components/PageLayout'
|
||||||
import PageTransitionEffect from '../components/animation/pageTransitionEffect'
|
import PageTransitionEffect from '../components/animation/pageTransitionEffect'
|
||||||
|
import { useTranslation } from '../i18n/useTranslation'
|
||||||
|
|
||||||
export default function SuspendedPage() {
|
export default function SuspendedPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageTransitionEffect>
|
<PageTransitionEffect>
|
||||||
<PageLayout showFooter={true} className="bg-[#F5F5F0] text-slate-900">
|
<PageLayout showFooter={true} className="bg-[#F5F5F0] text-slate-900">
|
||||||
<div className="min-h-[70vh] flex items-center justify-center px-6">
|
<div className="min-h-[70vh] flex items-center justify-center px-6">
|
||||||
<div className="max-w-xl w-full rounded-3xl border border-slate-200 bg-white/80 shadow-xl p-8 text-center">
|
<div className="max-w-xl w-full rounded-3xl border border-slate-200 bg-white/80 shadow-xl p-8 text-center">
|
||||||
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-rose-100 text-rose-700 flex items-center justify-center text-2xl">!</div>
|
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-rose-100 text-rose-700 flex items-center justify-center text-2xl">!</div>
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-slate-900">Account suspended</h1>
|
<h1 className="text-2xl md:text-3xl font-bold text-slate-900">{t('suspended.title')}</h1>
|
||||||
<p className="mt-3 text-slate-700">
|
<p className="mt-3 text-slate-700">
|
||||||
Your account has been suspended. For more information, contact
|
{t('suspended.message')}
|
||||||
{' '}<a className="text-[#8D6B1D] font-semibold" href="mailto:office@profit-planet.com">office@profit-planet.com</a>.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="inline-flex items-center justify-center rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-semibold text-slate-800 hover:bg-slate-50"
|
className="inline-flex items-center justify-center rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-semibold text-slate-800 hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
Back to login
|
{t('suspended.backToLogin')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
|
|
||||||
@ -16,6 +19,7 @@ function getTokenExpiry(token: string | null): Date | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TestRefreshPage() {
|
export default function TestRefreshPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { accessToken, refreshAuthToken, user } = useAuthStore()
|
const { accessToken, refreshAuthToken, user } = useAuthStore()
|
||||||
const [tokenInfo, setTokenInfo] = useState<any>({})
|
const [tokenInfo, setTokenInfo] = useState<any>({})
|
||||||
const [refreshStatus, setRefreshStatus] = useState<string>('')
|
const [refreshStatus, setRefreshStatus] = useState<string>('')
|
||||||
@ -59,25 +63,25 @@ export default function TestRefreshPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-8 text-black">
|
<div className="min-h-screen bg-gray-50 p-8 text-black">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-8 text-black">🧪 Token Refresh Test</h1>
|
<h1 className="text-3xl font-bold mb-8 text-black">{t('autofix.k00016501')}</h1>
|
||||||
|
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
{/* Token Status */}
|
{/* Token Status */}
|
||||||
<div className="bg-white p-6 rounded-lg shadow text-black">
|
<div className="bg-white p-6 rounded-lg shadow text-black">
|
||||||
<h2 className="text-xl font-semibold mb-4 text-black">🔑 Token Status</h2>
|
<h2 className="text-xl font-semibold mb-4 text-black">{t('autofix.kee28b8c6')}</h2>
|
||||||
<div className="space-y-2 font-mono text-sm text-black">
|
<div className="space-y-2 font-mono text-sm text-black">
|
||||||
<div>Has Token: <span className={tokenInfo.hasToken ? 'text-green-600' : 'text-red-600'}>
|
<div>{t('autofix.k47b952de')}<span className={tokenInfo.hasToken ? 'text-green-600' : 'text-red-600'}>
|
||||||
{tokenInfo.hasToken ? '✅ Yes' : '❌ No'}
|
{tokenInfo.hasToken ? '✅ Yes' : '❌ No'}
|
||||||
</span></div>
|
</span></div>
|
||||||
|
|
||||||
{tokenInfo.hasToken && (
|
{tokenInfo.hasToken && (
|
||||||
<>
|
<>
|
||||||
<div>Token Preview: <span className="text-blue-600">{tokenInfo.tokenPrefix}</span></div>
|
<div>{t('autofix.k61f6cd4e')}<span className="text-blue-600">{tokenInfo.tokenPrefix}</span></div>
|
||||||
<div>Expires At: <span className="text-purple-600">{tokenInfo.expiresAt}</span></div>
|
<div>{t('autofix.k3d5fe74a')}<span className="text-purple-600">{tokenInfo.expiresAt}</span></div>
|
||||||
<div>Time Left: <span className={tokenInfo.timeLeftSec <= 180 ? 'text-red-600' : 'text-green-600'}>
|
<div>{t('autofix.k4ed7f4d1')}<span className={tokenInfo.timeLeftSec <= 180 ? 'text-red-600' : 'text-green-600'}>
|
||||||
{tokenInfo.timeLeftMin}m {tokenInfo.timeLeftSec % 60}s
|
{tokenInfo.timeLeftMin}m {tokenInfo.timeLeftSec % 60}s
|
||||||
</span></div>
|
</span></div>
|
||||||
<div>Status: <span className={tokenInfo.isExpired ? 'text-red-600' : 'text-green-600'}>
|
<div>{t('autofix.k81c0b74b')}<span className={tokenInfo.isExpired ? 'text-red-600' : 'text-green-600'}>
|
||||||
{tokenInfo.isExpired ? '💀 Expired' : '✅ Valid'}
|
{tokenInfo.isExpired ? '💀 Expired' : '✅ Valid'}
|
||||||
</span></div>
|
</span></div>
|
||||||
</>
|
</>
|
||||||
@ -87,7 +91,7 @@ export default function TestRefreshPage() {
|
|||||||
|
|
||||||
{/* User Info */}
|
{/* User Info */}
|
||||||
<div className="bg-white p-6 rounded-lg shadow text-black">
|
<div className="bg-white p-6 rounded-lg shadow text-black">
|
||||||
<h2 className="text-xl font-semibold mb-4 text-black">👤 User Info</h2>
|
<h2 className="text-xl font-semibold mb-4 text-black">{t('autofix.k0d6626e3')}</h2>
|
||||||
<pre className="text-sm bg-gray-100 p-4 rounded overflow-auto text-black">
|
<pre className="text-sm bg-gray-100 p-4 rounded overflow-auto text-black">
|
||||||
{JSON.stringify(user, null, 2)}
|
{JSON.stringify(user, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
@ -95,14 +99,12 @@ export default function TestRefreshPage() {
|
|||||||
|
|
||||||
{/* Manual Controls */}
|
{/* Manual Controls */}
|
||||||
<div className="bg-white p-6 rounded-lg shadow text-black">
|
<div className="bg-white p-6 rounded-lg shadow text-black">
|
||||||
<h2 className="text-xl font-semibold mb-4 text-black">🔧 Manual Controls</h2>
|
<h2 className="text-xl font-semibold mb-4 text-black">{t('autofix.kb74d7c51')}</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
<button
|
||||||
onClick={handleManualRefresh}
|
onClick={handleManualRefresh}
|
||||||
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||||
>
|
>{t('autofix.kf7189e80')}</button>
|
||||||
🔄 Manual Refresh Token
|
|
||||||
</button>
|
|
||||||
{refreshStatus && (
|
{refreshStatus && (
|
||||||
<div className="text-sm font-mono text-black">{refreshStatus}</div>
|
<div className="text-sm font-mono text-black">{refreshStatus}</div>
|
||||||
)}
|
)}
|
||||||
@ -111,14 +113,14 @@ export default function TestRefreshPage() {
|
|||||||
|
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<div className="bg-yellow-50 border border-yellow-200 p-6 rounded-lg text-black">
|
<div className="bg-yellow-50 border border-yellow-200 p-6 rounded-lg text-black">
|
||||||
<h2 className="text-xl font-semibold mb-4 text-black">📋 Testing Instructions</h2>
|
<h2 className="text-xl font-semibold mb-4 text-black">{t('autofix.k9213db6e')}</h2>
|
||||||
<ol className="list-decimal list-inside space-y-2 text-sm text-black">
|
<ol className="list-decimal list-inside space-y-2 text-sm text-black">
|
||||||
<li>Make sure JWT_EXPIRES_IN=2m in backend .env for fast testing</li>
|
<li>{t('autofix.k0778fa87')}</li>
|
||||||
<li>Login and watch the countdown timer</li>
|
<li>{t('autofix.k1405afab')}</li>
|
||||||
<li>When time left ≤ 3 minutes, auto-refresh should trigger</li>
|
<li>{t('autofix.kb6b367b7')}</li>
|
||||||
<li>Check browser console for detailed logs</li>
|
<li>{t('autofix.kf0d33884')}</li>
|
||||||
<li>Check Network tab for /api/refresh requests</li>
|
<li>{t('autofix.k73d4a156')}</li>
|
||||||
<li>Token should automatically renew without user action</li>
|
<li>{t('autofix.kb01addda')}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user