feat: client portal — public order tracking page at /track/:token
Adds anonymous order tracking via a unique public_token UUID on each order, a standalone React page at /track/:token, and a share button in OrderModal.
This commit is contained in:
@@ -20,6 +20,7 @@ const UserManagementPage = React.lazy(() => import('./admin/users/UserManagement
|
||||
const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
|
||||
const ClientsPage = React.lazy(() => import('./admin/clients/ClientsPage'));
|
||||
const CalendarPage = React.lazy(() => import('./admin/calendar/CalendarPage'));
|
||||
const OrderTrackingPage = React.lazy(() => import('./portal/OrderTrackingPage'));
|
||||
|
||||
function PublicSite() {
|
||||
return (
|
||||
@@ -46,6 +47,7 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<PublicSite />} />
|
||||
<Route path="/admin/login" element={<LoginPage />} />
|
||||
<Route path="/track/:token" element={<OrderTrackingPage />} />
|
||||
<Route path="/admin" element={<Navigate to="/admin/kanban" replace />} />
|
||||
<Route path="/admin/*" element={<ProtectedRoute />}>
|
||||
<Route element={<AdminLayout />}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, FileText, Camera, X as XIcon } from 'lucide-react';
|
||||
import { X, Save, FileText, Camera, X as XIcon, Share2 } from 'lucide-react';
|
||||
import { useOrderPhotos } from './useOrderPhotos';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
@@ -57,6 +57,7 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (order?.id) {
|
||||
@@ -96,6 +97,14 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
scheduled_at: f.scheduled_at === '' ? null : f.scheduled_at,
|
||||
});
|
||||
|
||||
const handleShare = () => {
|
||||
const url = `${window.location.origin}/track/${order.public_token}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim() || !form.phone.trim()) {
|
||||
setError('Имя и телефон обязательны');
|
||||
@@ -210,13 +219,37 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
<h2 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.2rem', fontWeight: 800 }}>
|
||||
{mode === 'create' ? 'Новая заявка' : order?.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{mode === 'edit' && order?.public_token && (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
aria-label="Поделиться ссылкой на заявку"
|
||||
title="Скопировать ссылку для клиента"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '5px',
|
||||
background: copied ? '#10b981' : 'var(--bg-lighter)',
|
||||
border: '1.5px solid var(--border-light)',
|
||||
borderRadius: '7px',
|
||||
padding: '5px 10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
color: copied ? '#fff' : 'var(--text-muted)',
|
||||
transition: 'background 0.2s, color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
{copied ? 'Скопировано!' : 'Поделиться'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useOrderTracking } from './useOrderTracking';
|
||||
|
||||
const STATUS_COLORS = {
|
||||
'новая': '#3b82f6',
|
||||
'замер': '#8b5cf6',
|
||||
'согласование': '#f59e0b',
|
||||
'монтаж': '#f59e0b',
|
||||
'закрыт': '#10b981',
|
||||
'в работе': '#f59e0b',
|
||||
'завершена': '#10b981',
|
||||
'отменена': '#6b7280',
|
||||
};
|
||||
|
||||
const STATUS_LABELS = {
|
||||
'новая': 'Новая',
|
||||
'замер': 'Замер',
|
||||
'согласование': 'Согласование',
|
||||
'монтаж': 'Монтаж',
|
||||
'закрыт': 'Завершена',
|
||||
'в работе': 'В работе',
|
||||
'завершена': 'Завершена',
|
||||
'отменена': 'Отменена',
|
||||
};
|
||||
|
||||
const FILM_LABELS = {
|
||||
200: '200 микрон',
|
||||
300: '300 микрон',
|
||||
};
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const color = STATUS_COLORS[status] || '#6b7280';
|
||||
const label = STATUS_LABELS[status] || status;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 14px',
|
||||
borderRadius: '20px',
|
||||
background: color + '1a',
|
||||
color: color,
|
||||
fontWeight: 700,
|
||||
fontSize: '0.88rem',
|
||||
border: `1.5px solid ${color}33`,
|
||||
letterSpacing: '0.2px',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }) {
|
||||
if (!value && value !== 0) return null;
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', padding: '10px 0', borderBottom: '1px solid #E9ECEF' }}>
|
||||
<span style={{ fontSize: '0.82rem', color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.4px', flexShrink: 0, marginRight: '12px' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.95rem', color: '#1e293b', textAlign: 'right', wordBreak: 'break-word' }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||
<div style={{
|
||||
width: '36px', height: '36px',
|
||||
border: '3px solid #e2e8f0',
|
||||
borderTopColor: '#3b82f6',
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 16px',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}} />
|
||||
<p style={{ color: '#64748b', fontSize: '0.95rem' }}>Загрузка заявки...</p>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotFoundState() {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||
<div style={{ fontSize: '3rem', marginBottom: '16px' }}>🔍</div>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#1e293b', marginBottom: '8px' }}>
|
||||
Заявка не найдена
|
||||
</h2>
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem' }}>
|
||||
Проверьте ссылку или обратитесь к менеджеру.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderTrackingPage() {
|
||||
const { token } = useParams();
|
||||
const { order, loading, error } = useOrderTracking(token);
|
||||
|
||||
const pageStyle = {
|
||||
minHeight: '100vh',
|
||||
background: '#F5F7FA',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '24px 16px 48px',
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
};
|
||||
|
||||
const cardStyle = {
|
||||
background: '#fff',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 4px 24px rgba(15,23,42,0.08)',
|
||||
width: '100%',
|
||||
maxWidth: '480px',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
{/* Logo / Brand header */}
|
||||
<div style={{ marginBottom: '28px', textAlign: 'center' }}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '10px',
|
||||
background: '#fff', borderRadius: '12px',
|
||||
padding: '12px 20px',
|
||||
boxShadow: '0 2px 12px rgba(15,23,42,0.07)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '32px', height: '32px',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #1d4ed8)',
|
||||
borderRadius: '8px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<span style={{ color: '#fff', fontSize: '16px', fontWeight: 800, lineHeight: 1 }}>О</span>
|
||||
</div>
|
||||
<span style={{ fontWeight: 800, fontSize: '1.05rem', color: '#1e293b', letterSpacing: '-0.3px' }}>
|
||||
Осколкам.Нет
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ marginTop: '8px', fontSize: '0.82rem', color: '#94a3b8' }}>
|
||||
Отслеживание заявки
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={cardStyle}>
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : error || !order ? (
|
||||
<NotFoundState />
|
||||
) : (
|
||||
<div>
|
||||
{/* Status banner */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #f8fafc, #f1f5f9)',
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '10px',
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px' }}>
|
||||
Статус заявки
|
||||
</p>
|
||||
<StatusBadge status={order.status} />
|
||||
</div>
|
||||
{order.id && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<p style={{ fontSize: '0.72rem', color: '#94a3b8', marginBottom: '2px' }}>ID заявки</p>
|
||||
<p style={{ fontSize: '0.8rem', color: '#64748b', fontFamily: 'monospace' }}>
|
||||
#{String(order.id).slice(0, 8)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order details */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<InfoRow label="Клиент" value={order.name} />
|
||||
<InfoRow label="Телефон" value={order.phone} />
|
||||
<InfoRow label="Адрес" value={order.address} />
|
||||
<InfoRow label="Тип плёнки" value={FILM_LABELS[order.film_thickness] || order.film_thickness ? `${FILM_LABELS[order.film_thickness] || order.film_thickness}` : null} />
|
||||
<InfoRow label="Площадь" value={order.area_sqm ? `${order.area_sqm} м²` : null} />
|
||||
<InfoRow
|
||||
label="Стоимость"
|
||||
value={order.final_cost ? `${Number(order.final_cost).toLocaleString('ru-RU')} ₽` : null}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Дата визита"
|
||||
value={order.scheduled_at
|
||||
? new Date(order.scheduled_at).toLocaleString('ru-RU', {
|
||||
day: '2-digit', month: 'long', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
: null}
|
||||
/>
|
||||
{order.notes && (
|
||||
<div style={{ marginTop: '12px', padding: '12px', background: '#f8fafc', borderRadius: '8px', border: '1px solid #e2e8f0' }}>
|
||||
<p style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: '6px' }}>
|
||||
Заметки
|
||||
</p>
|
||||
<p style={{ fontSize: '0.9rem', color: '#334155', lineHeight: 1.5 }}>{order.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
padding: '14px 24px',
|
||||
background: '#f8fafc',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<p style={{ fontSize: '0.78rem', color: '#94a3b8' }}>
|
||||
Есть вопросы? Свяжитесь с вашим менеджером.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderTrackingPage;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export function useOrderTracking(token) {
|
||||
const [order, setOrder] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('public_token', token)
|
||||
.single()
|
||||
.then(({ data, error }) => {
|
||||
if (!isMounted) return;
|
||||
if (error) setError(error.message);
|
||||
else setOrder(data);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
return { order, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Add public_token column to orders for anonymous order tracking
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS public_token UUID DEFAULT gen_random_uuid();
|
||||
|
||||
-- Backfill existing orders that have NULL public_token
|
||||
UPDATE orders SET public_token = gen_random_uuid() WHERE public_token IS NULL;
|
||||
|
||||
-- Allow anon role to read orders if they know the public_token
|
||||
-- (filtered further in app code via .eq('public_token', token))
|
||||
CREATE POLICY "orders_read_by_public_token" ON orders
|
||||
FOR SELECT TO anon
|
||||
USING (public_token IS NOT NULL);
|
||||
Reference in New Issue
Block a user