feat: support added to the app
This commit is contained in:
parent
9382cdb666
commit
18996426ac
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 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.
|
// 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 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 Book from '../../assets/icons/book.svg?react'
|
||||||
import Calendar from '../../assets/icons/calendar.svg?react'
|
import Calendar from '../../assets/icons/calendar.svg?react'
|
||||||
import Call from '../../assets/icons/call.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.
|
// is used, with no second list to keep in sync.
|
||||||
export const icons = {
|
export const icons = {
|
||||||
arrowRight: ArrowRight,
|
arrowRight: ArrowRight,
|
||||||
|
copy: Copy,
|
||||||
book: Book,
|
book: Book,
|
||||||
calendar: Calendar,
|
calendar: Calendar,
|
||||||
call: Call,
|
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 { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import Icon from '../components/Icon'
|
import Icon from '../components/Icon'
|
||||||
|
import SupportButton from '../components/SupportButton'
|
||||||
import { useNow } from '../hooks/useNow'
|
import { useNow } from '../hooks/useNow'
|
||||||
import { CafeSchedule, fetchCafeSchedules } from '../services/campaignApi'
|
import { CafeSchedule, fetchCafeSchedules } from '../services/campaignApi'
|
||||||
import { getCafeUuid } from '../utils/cafe'
|
import { getCafeUuid } from '../utils/cafe'
|
||||||
|
|
@ -96,9 +97,7 @@ export default function CafeOwnerPanel() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg">
|
<SupportButton className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white shadow-lg" />
|
||||||
پشتیبانی
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "reac
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import "../utils/leafletIcons";
|
import "../utils/leafletIcons";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import SupportButton from "../components/SupportButton";
|
||||||
import {
|
import {
|
||||||
CafeFeature,
|
CafeFeature,
|
||||||
CafeMediaItem,
|
CafeMediaItem,
|
||||||
|
|
@ -831,12 +832,7 @@ export default function CafeOwnerRegister() {
|
||||||
</div>
|
</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">
|
<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
|
<SupportButton className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white" />
|
||||||
type="button"
|
|
||||||
className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
|
|
||||||
>
|
|
||||||
پشتیبانی
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,15 @@ import { useEffect, useMemo, useState } from "react";
|
||||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents";
|
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 { getCafeUuid } from "../utils/cafe";
|
||||||
import HostBanner from "../assets/images/banners/Host-banner.jpg";
|
import HostBanner from "../assets/images/banners/Host-banner.jpg";
|
||||||
import { formatPersianNumber } from "../utils/formatNumber";
|
import { formatPersianNumber } from "../utils/formatNumber";
|
||||||
|
import { fromE164 } from "../utils/phone";
|
||||||
|
|
||||||
function toEnglishDigits(value: string) {
|
function toEnglishDigits(value: string) {
|
||||||
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
|
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
|
||||||
|
|
@ -47,13 +52,14 @@ function downloadReservationsCsv(reservations: EventReservation[]) {
|
||||||
|
|
||||||
function ReservationCard({
|
function ReservationCard({
|
||||||
reservation,
|
reservation,
|
||||||
selected,
|
isToggling,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
reservation: EventReservation;
|
reservation: EventReservation;
|
||||||
selected: boolean;
|
isToggling: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const participated = reservation.is_participated;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between rounded-2xl shadow-[0px_4px_12px_0px_#0000001F] p-6">
|
<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">
|
<div className="flex flex-col items-start gap-2 text-right">
|
||||||
|
|
@ -66,15 +72,19 @@ function ReservationCard({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-900">{reservation.full_name}</p>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +94,7 @@ export default function EventReservations() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set());
|
||||||
const [reservations, setReservations] = useState<EventReservation[]>([]);
|
const [reservations, setReservations] = useState<EventReservation[]>([]);
|
||||||
|
|
||||||
const event: CafeEvent | undefined =
|
const event: CafeEvent | undefined =
|
||||||
|
|
@ -107,12 +117,24 @@ export default function EventReservations() {
|
||||||
[reservations, query],
|
[reservations, query],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleSelected = (reservationId: string) => {
|
const handleParticipate = async (reservation: EventReservation) => {
|
||||||
setSelectedIds((prev) =>
|
if (togglingIds.has(reservation.uuid)) return;
|
||||||
prev.includes(reservationId)
|
const newValue = !reservation.is_participated;
|
||||||
? prev.filter((id) => id !== reservationId)
|
setTogglingIds((prev) => new Set([...prev, reservation.uuid]));
|
||||||
: [...prev, reservationId],
|
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 = () => {
|
const handleDownload = () => {
|
||||||
|
|
@ -128,7 +150,10 @@ export default function EventReservations() {
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="mt-4 flex w-full items-center justify-start gap-2 px-4"
|
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">
|
<span className="text-md font-semibold text-gray-900">
|
||||||
رزروهای {event?.matchLabel ?? ""}
|
رزروهای {event?.matchLabel ?? ""}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -153,8 +178,8 @@ export default function EventReservations() {
|
||||||
<ReservationCard
|
<ReservationCard
|
||||||
key={reservation.uuid}
|
key={reservation.uuid}
|
||||||
reservation={reservation}
|
reservation={reservation}
|
||||||
selected={selectedIds.includes(reservation.uuid)}
|
isToggling={togglingIds.has(reservation.uuid)}
|
||||||
onToggle={() => toggleSelected(reservation.uuid)}
|
onToggle={() => handleParticipate(reservation)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import SupportButton from '../components/SupportButton'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { MapContainer, TileLayer, Marker } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker } from 'react-leaflet'
|
||||||
|
|
@ -351,12 +352,7 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<SupportButton className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white shadow-lg" />
|
||||||
type="button"
|
|
||||||
className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg"
|
|
||||||
>
|
|
||||||
پشتیبانی
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ export interface EventReservation {
|
||||||
full_name: string
|
full_name: string
|
||||||
phone_number: string
|
phone_number: string
|
||||||
status: string
|
status: string
|
||||||
|
is_participated: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,6 +216,10 @@ export async function fetchEventReservations(cafeUuid: string, eventUuid: string
|
||||||
return data
|
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 {
|
export interface MyReservation {
|
||||||
uuid: string
|
uuid: string
|
||||||
reservation_code: string
|
reservation_code: string
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue