feature/WNPT-301 #6
8 changed files with 139 additions and 36 deletions
4
src/assets/icons/copy.svg
Normal file
4
src/assets/icons/copy.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.3327 10.7493V14.2493C13.3327 17.166 12.166 18.3327 9.24935 18.3327H5.74935C2.83268 18.3327 1.66602 17.166 1.66602 14.2493V10.7493C1.66602 7.83268 2.83268 6.66602 5.74935 6.66602H9.24935C12.166 6.66602 13.3327 7.83268 13.3327 10.7493Z" stroke="#376195" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.3327 5.74935V9.24935C18.3327 12.166 17.166 13.3327 14.2493 13.3327H13.3327V10.7493C13.3327 7.83268 12.166 6.66602 9.24935 6.66602H6.66602V5.74935C6.66602 2.83268 7.83268 1.66602 10.7493 1.66602H14.2493C17.166 1.66602 18.3327 2.83268 18.3327 5.74935Z" stroke="#376195" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 785 B |
|
|
@ -5,6 +5,7 @@
|
|||
// icons are referenced and tree-shake the rest — this file is the *only* place all 17
|
||||
// icons are ever imported, so an icon that no call site uses never makes it into a chunk.
|
||||
import ArrowRight from '../../assets/icons/arrow-right.svg?react'
|
||||
import Copy from '../../assets/icons/copy.svg?react'
|
||||
import Book from '../../assets/icons/book.svg?react'
|
||||
import Calendar from '../../assets/icons/calendar.svg?react'
|
||||
import Call from '../../assets/icons/call.svg?react'
|
||||
|
|
@ -28,6 +29,7 @@ import Success from '../../assets/icons/success.svg?react'
|
|||
// is used, with no second list to keep in sync.
|
||||
export const icons = {
|
||||
arrowRight: ArrowRight,
|
||||
copy: Copy,
|
||||
book: Book,
|
||||
calendar: Calendar,
|
||||
call: Call,
|
||||
|
|
|
|||
76
src/components/SupportButton.tsx
Normal file
76
src/components/SupportButton.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Icon from './Icon'
|
||||
|
||||
const SUPPORT_PHONE = '02111111111'
|
||||
const SUPPORT_PHONE_DISPLAY = '۰۲۱-۱۱۱۱۱۱۱۱'
|
||||
|
||||
function isMobileDevice() {
|
||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||
}
|
||||
|
||||
export default function SupportButton({ className }: { className?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMobileDevice()) {
|
||||
window.location.href = `tel:${SUPPORT_PHONE}`
|
||||
} else {
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(SUPPORT_PHONE)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// silent fallback
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={className ?? 'rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white'}
|
||||
>
|
||||
پشتیبانی
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-9998" onClick={() => setOpen(false)} />
|
||||
<div className="fixed inset-x-4 top-20 z-9999 rounded-2xl bg-white px-5 py-4 shadow-xl sm:inset-x-auto sm:left-1/2 sm:w-96 sm:-translate-x-1/2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="absolute left-4 top-4 flex h-7 w-7 items-center justify-center text-gray-400 hover:text-gray-600"
|
||||
aria-label="بستن"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label="کپی شماره"
|
||||
className="shrink-0 text-gray-400 transition-colors hover:text-gray-700"
|
||||
>
|
||||
<Icon name="copy" className="h-5 w-5" />
|
||||
</button>
|
||||
<p dir="ltr" className="text-right text-gray-900">
|
||||
{copied ? 'کپی شد!' : `شماره تماس پشتیبانی: ${SUPPORT_PHONE_DISPLAY}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Icon from '../components/Icon'
|
||||
import SupportButton from '../components/SupportButton'
|
||||
import { useNow } from '../hooks/useNow'
|
||||
import { CafeSchedule, fetchCafeSchedules } from '../services/campaignApi'
|
||||
import { getCafeUuid } from '../utils/cafe'
|
||||
|
|
@ -96,9 +97,7 @@ export default function CafeOwnerPanel() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg">
|
||||
پشتیبانی
|
||||
</button>
|
||||
<SupportButton className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white shadow-lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "reac
|
|||
import "leaflet/dist/leaflet.css";
|
||||
import "../utils/leafletIcons";
|
||||
import Icon from "../components/Icon";
|
||||
import SupportButton from "../components/SupportButton";
|
||||
import {
|
||||
CafeFeature,
|
||||
CafeMediaItem,
|
||||
|
|
@ -831,12 +832,7 @@ export default function CafeOwnerRegister() {
|
|||
</div>
|
||||
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-white px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
|
||||
>
|
||||
پشتیبانی
|
||||
</button>
|
||||
<SupportButton className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import Icon from "../components/Icon";
|
||||
import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents";
|
||||
import { EventReservation, fetchEventReservations } from "../services/campaignApi";
|
||||
import {
|
||||
EventReservation,
|
||||
fetchEventReservations,
|
||||
participateReservation,
|
||||
} from "../services/campaignApi";
|
||||
import { getCafeUuid } from "../utils/cafe";
|
||||
import HostBanner from "../assets/images/banners/Host-banner.jpg";
|
||||
import { formatPersianNumber } from "../utils/formatNumber";
|
||||
import { fromE164 } from "../utils/phone";
|
||||
|
||||
function toEnglishDigits(value: string) {
|
||||
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
|
||||
|
|
@ -47,13 +52,14 @@ function downloadReservationsCsv(reservations: EventReservation[]) {
|
|||
|
||||
function ReservationCard({
|
||||
reservation,
|
||||
selected,
|
||||
isToggling,
|
||||
onToggle,
|
||||
}: {
|
||||
reservation: EventReservation;
|
||||
selected: boolean;
|
||||
isToggling: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const participated = reservation.is_participated;
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-2xl shadow-[0px_4px_12px_0px_#0000001F] p-6">
|
||||
<div className="flex flex-col items-start gap-2 text-right">
|
||||
|
|
@ -66,15 +72,19 @@ function ReservationCard({
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-gray-900">{reservation.full_name}</p>
|
||||
<p className="text-gray-500">{reservation.phone_number}</p>
|
||||
<p dir="ltr" className="text-left text-gray-500">{fromE164(reservation.phone_number)}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center self-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={isToggling}
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[1.5px] border-[#223D5D] transition-colors disabled:opacity-50 ${participated ? "bg-[#223D5D]" : "bg-transparent"}`}
|
||||
>
|
||||
{participated && <Icon name="check" className="h-3 w-3 text-white" />}
|
||||
</button>
|
||||
<p className="text-xs text-black">تایید ورود</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-[#223D5D] ${selected ? "bg-[#223D5D]" : "bg-transparent"}`}
|
||||
>
|
||||
{selected && <Icon name="check" className="h-3 w-3 text-white" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -84,7 +94,7 @@ export default function EventReservations() {
|
|||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set());
|
||||
const [reservations, setReservations] = useState<EventReservation[]>([]);
|
||||
|
||||
const event: CafeEvent | undefined =
|
||||
|
|
@ -107,12 +117,24 @@ export default function EventReservations() {
|
|||
[reservations, query],
|
||||
);
|
||||
|
||||
const toggleSelected = (reservationId: string) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(reservationId)
|
||||
? prev.filter((id) => id !== reservationId)
|
||||
: [...prev, reservationId],
|
||||
);
|
||||
const handleParticipate = async (reservation: EventReservation) => {
|
||||
if (togglingIds.has(reservation.uuid)) return;
|
||||
const newValue = !reservation.is_participated;
|
||||
setTogglingIds((prev) => new Set([...prev, reservation.uuid]));
|
||||
try {
|
||||
await participateReservation(reservation.uuid, newValue);
|
||||
setReservations((prev) =>
|
||||
prev.map((r) => (r.uuid === reservation.uuid ? { ...r, is_participated: newValue } : r)),
|
||||
);
|
||||
} catch {
|
||||
// leave checkbox at current state on failure
|
||||
} finally {
|
||||
setTogglingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(reservation.uuid);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
|
|
@ -128,7 +150,10 @@ export default function EventReservations() {
|
|||
onClick={() => navigate(-1)}
|
||||
className="mt-4 flex w-full items-center justify-start gap-2 px-4"
|
||||
>
|
||||
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
|
||||
<Icon
|
||||
name="arrowRight"
|
||||
className="h-5 w-5 -scale-x-100 text-gray-900"
|
||||
/>
|
||||
<span className="text-md font-semibold text-gray-900">
|
||||
رزروهای {event?.matchLabel ?? ""}
|
||||
</span>
|
||||
|
|
@ -153,8 +178,8 @@ export default function EventReservations() {
|
|||
<ReservationCard
|
||||
key={reservation.uuid}
|
||||
reservation={reservation}
|
||||
selected={selectedIds.includes(reservation.uuid)}
|
||||
onToggle={() => toggleSelected(reservation.uuid)}
|
||||
isToggling={togglingIds.has(reservation.uuid)}
|
||||
onToggle={() => handleParticipate(reservation)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import SupportButton from '../components/SupportButton'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { MapContainer, TileLayer, Marker } from 'react-leaflet'
|
||||
|
|
@ -351,12 +352,7 @@ export default function Home() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg"
|
||||
>
|
||||
پشتیبانی
|
||||
</button>
|
||||
<SupportButton className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white shadow-lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ export interface EventReservation {
|
|||
full_name: string
|
||||
phone_number: string
|
||||
status: string
|
||||
is_participated: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
|
@ -215,6 +216,10 @@ export async function fetchEventReservations(cafeUuid: string, eventUuid: string
|
|||
return data
|
||||
}
|
||||
|
||||
export async function participateReservation(uuid: string, isParticipated: boolean): Promise<void> {
|
||||
await api.patch(`/campaign/reservations/${uuid}/participate/`, { is_participated: isParticipated })
|
||||
}
|
||||
|
||||
export interface MyReservation {
|
||||
uuid: string
|
||||
reservation_code: string
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue