feat(client): test consent page + 'test notification' button on /client/test
This commit is contained in:
parent
045c903074
commit
663b31b3df
|
|
@ -0,0 +1,204 @@
|
|||
import React, { useState } from "react";
|
||||
import { Panel } from "../components/UI/Panel";
|
||||
import { Button } from "../components/UI/Button";
|
||||
import { formatDeliveryDate, getDeliveryRelativeDayLabel } from "../components/client/deliveryDateFormatting";
|
||||
|
||||
const TAB_DELIVERY = "delivery";
|
||||
const TAB_PICKUP = "pickup";
|
||||
|
||||
const MOCK_PRODUCTS = [
|
||||
{ name: "Цемент М500, 50 кг", quantity: "10", unit: "меш" },
|
||||
{ name: "Песок строительный", quantity: "2", unit: "м³" },
|
||||
{ name: "Кирпич рядовой", quantity: "400", unit: "шт" },
|
||||
{ name: "Арматура 12 мм", quantity: "60", unit: "м" },
|
||||
];
|
||||
|
||||
const buildMockSlots = () => {
|
||||
const day = (offset) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + offset);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const skipSunday = (dateStr) => {
|
||||
const d = new Date(`${dateStr}T12:00:00Z`);
|
||||
if (d.getUTCDay() === 0) {
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
}
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const d1 = skipSunday(day(1));
|
||||
const d2 = skipSunday(day(2));
|
||||
|
||||
return [
|
||||
{ id: `slot-${d1}-first`, date: d1, time: "Первая половина дня" },
|
||||
{ id: `slot-${d1}-second`, date: d1, time: "Вторая половина дня" },
|
||||
{ id: `slot-${d2}-first`, date: d2, time: "Первая половина дня" },
|
||||
{ id: `slot-${d2}-second`, date: d2, time: "Вторая половина дня" },
|
||||
];
|
||||
};
|
||||
|
||||
const groupSlotsByDate = (slots) => {
|
||||
const groups = new Map();
|
||||
for (const slot of slots) {
|
||||
if (!groups.has(slot.date)) groups.set(slot.date, []);
|
||||
groups.get(slot.date).push(slot);
|
||||
}
|
||||
return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const SlotGroupHeading = ({ dateStr }) => {
|
||||
const relative = getDeliveryRelativeDayLabel(dateStr);
|
||||
const formatted = formatDeliveryDate(dateStr);
|
||||
return relative
|
||||
? `Доставка ${relative.charAt(0).toLowerCase()}${relative.slice(1)} · ${formatted}`
|
||||
: `Доставка ${formatted}`;
|
||||
};
|
||||
|
||||
export const ClientTestConsentPage = () => {
|
||||
const [activeTab, setActiveTab] = useState(TAB_DELIVERY);
|
||||
const [selectedSlot, setSelectedSlot] = useState(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const slots = buildMockSlots();
|
||||
const grouped = groupSlotsByDate(slots);
|
||||
const typeLabel = activeTab === TAB_PICKUP ? "самовывоз" : "доставку";
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selectedSlot) return;
|
||||
setSaved(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
|
||||
|
||||
<Panel className="space-y-3 p-5 sm:p-6">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
||||
Тестовая страница · согласование
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">
|
||||
{activeTab === TAB_PICKUP ? "Выберите время самовывоза" : "Выберите время доставки"}
|
||||
</h1>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
Это тестовая страница — данные ненастоящие. Выберите удобную половину дня для {typeLabel}.
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
<Panel className="space-y-3 p-5 sm:p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/icons/icon-192.png" alt="" className="h-12 w-12 rounded-xl" />
|
||||
<div>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">Заказ TEST-001 · счёт 1</p>
|
||||
<p className="text-base font-semibold text-[var(--color-text)]">Тестов Тест Тестович</p>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
{activeTab === TAB_PICKUP
|
||||
? "Самовывоз со склада"
|
||||
: "г. Симферополь, ул. Тестовая, д. 1"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 rounded-[24px] px-4 py-2.5 text-sm font-semibold transition ${
|
||||
activeTab === TAB_DELIVERY
|
||||
? "bg-[var(--color-accent)] text-white"
|
||||
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
}`}
|
||||
onClick={() => { setActiveTab(TAB_DELIVERY); setSelectedSlot(null); setSaved(false); }}
|
||||
>
|
||||
🚚 Доставка
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 rounded-[24px] px-4 py-2.5 text-sm font-semibold transition ${
|
||||
activeTab === TAB_PICKUP
|
||||
? "bg-[var(--color-accent)] text-white"
|
||||
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
}`}
|
||||
onClick={() => { setActiveTab(TAB_PICKUP); setSelectedSlot(null); setSaved(false); }}
|
||||
>
|
||||
🏪 Самовывоз
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="space-y-3 p-5 sm:p-6">
|
||||
<p className="text-sm font-semibold text-[var(--color-text)]">Состав заказа</p>
|
||||
<div className="space-y-2">
|
||||
{MOCK_PRODUCTS.map((p, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between gap-3 rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3 text-sm"
|
||||
>
|
||||
<span className="leading-6">{p.name}</span>
|
||||
<span className="flex-shrink-0 rounded-full bg-[var(--color-accent-soft)] px-2.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">
|
||||
{p.quantity} {p.unit}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{saved && selectedSlot ? (
|
||||
<Panel className="space-y-2 p-5 sm:p-6">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
|
||||
<h2 className="text-xl font-semibold leading-tight">
|
||||
{activeTab === TAB_PICKUP ? "Самовывоз" : "Доставка"}: {formatDeliveryDate(selectedSlot.date)} / {selectedSlot.time}
|
||||
</h2>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
Выбор сохранен (тестовая страница — в реальном заказе здесь будет подтверждение от менеджера).
|
||||
</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
{grouped.map(([date, dateSlots]) => (
|
||||
<details key={date} className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur" open>
|
||||
<summary className="cursor-pointer list-none p-5 sm:p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 className="font-medium"><SlotGroupHeading dateStr={date} /></h4>
|
||||
<span className="text-sm text-[var(--color-text-muted)] group-open:hidden">Раскрыть</span>
|
||||
<span className="hidden text-sm text-[var(--color-text-muted)] group-open:inline">Свернуть</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="px-5 pb-5 sm:px-6 sm:pb-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{dateSlots.map((slot) => {
|
||||
const isSelected = selectedSlot?.id === slot.id;
|
||||
return (
|
||||
<Button
|
||||
key={slot.id}
|
||||
variant={isSelected ? "primary" : "secondary"}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => { setSelectedSlot(slot); setSaved(false); }}
|
||||
>
|
||||
{slot.time}
|
||||
{isSelected ? " — Выбрано" : ""}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
disabled={!selectedSlot}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientTestConsentPage;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Panel } from "../components/UI/Panel";
|
||||
import { PwaInstallCard } from "../components/client/PwaInstallCard";
|
||||
import { PushSubscriptionBanner } from "../components/client/PushSubscriptionBanner";
|
||||
|
|
@ -8,6 +8,10 @@ import { PushSubscriptionBanner } from "../components/client/PushSubscriptionBan
|
|||
* Mock data — no real order, no API calls.
|
||||
* Route: /client/test
|
||||
*/
|
||||
|
||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
|
||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
|
||||
|
||||
const MOCK_INVITATION = {
|
||||
customerName: "Тестов Тест Тестович",
|
||||
customerPhone: "79990000000",
|
||||
|
|
@ -20,7 +24,42 @@ const MOCK_INVITATION = {
|
|||
orderGroupId: "test-group-001",
|
||||
};
|
||||
|
||||
const sendTestPush = async (phone) => {
|
||||
const resp = await fetch(`${SUPABASE_URL}/functions/v1/send-test-push`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(SUPABASE_ANON_KEY ? { apikey: SUPABASE_ANON_KEY } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
phone_normalized: phone,
|
||||
title: "СуперСам — согласуйте доставку",
|
||||
body: "Ваш заказ готов к доставке. Выберите удобное время.",
|
||||
url: `${window.location.origin}/client/test-consent`,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
return { ok: resp.ok, ...data };
|
||||
};
|
||||
|
||||
export const ClientTestPage = () => {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendResult, setSendResult] = useState(null); // null | {ok, sent, error}
|
||||
|
||||
const handleTestPush = async () => {
|
||||
setSending(true);
|
||||
setSendResult(null);
|
||||
try {
|
||||
const phone = window.localStorage.getItem("supersam_phone") || MOCK_INVITATION.customerPhone;
|
||||
const result = await sendTestPush(phone);
|
||||
setSendResult(result);
|
||||
} catch (e) {
|
||||
setSendResult({ ok: false, error: e instanceof Error ? e.message : "Ошибка отправки" });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
|
||||
|
|
@ -60,6 +99,36 @@ export const ClientTestPage = () => {
|
|||
orderGroupId={MOCK_INVITATION.orderGroupId}
|
||||
/>
|
||||
|
||||
<Panel className="space-y-3 border border-dashed border-[var(--color-accent)] bg-[var(--color-surface)] p-5 sm:p-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--color-text)]">🧪 Тест уведомления</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1 leading-5">
|
||||
Отправить тестовое push-уведомление на этот телефон. Нажмите на уведомление —
|
||||
откроется тестовая страница согласования доставки.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestPush}
|
||||
disabled={sending}
|
||||
className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{sending ? "Отправляем…" : "Отправить тестовое уведомление"}
|
||||
</button>
|
||||
|
||||
{sendResult?.ok ? (
|
||||
<p className="text-sm text-[var(--color-accent)]">
|
||||
✅ Отправлено: {sendResult.sent} уведомление(й). Проверьте телефон.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{sendResult && !sendResult.ok ? (
|
||||
<p className="text-sm text-[var(--color-danger)]">
|
||||
❌ {sendResult.error || "Не удалось отправить уведомление"}
|
||||
</p>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Navigate, createBrowserRouter, useParams } from "react-router-dom";
|
|||
import App from "./App";
|
||||
import { ClientDeliveryPage } from "./pages/ClientDeliveryPage";
|
||||
import { ClientTestPage } from "./pages/ClientTestPage";
|
||||
import { ClientTestConsentPage } from "./pages/ClientTestConsentPage";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { GroupDetailPage } from "./pages/GroupDetailPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
|
|
@ -56,6 +57,10 @@ export const router = createBrowserRouter([
|
|||
path: "client/test",
|
||||
element: <ClientTestPage />,
|
||||
},
|
||||
{
|
||||
path: "client/test-consent",
|
||||
element: <ClientTestConsentPage />,
|
||||
},
|
||||
{
|
||||
path: "delivery/:token",
|
||||
element: <LegacyDeliveryRedirect />,
|
||||
|
|
|
|||
Loading…
Reference in New Issue