Compare commits
5 Commits
69908887c1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 29cadb83e6 | |||
| 03c7c07a9f | |||
| 666d04ff26 | |||
| 132c8c44ef | |||
| 8abfcc9c2b |
41
src/api/contact.ts
Normal file
41
src/api/contact.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { buildApiError, buildApiUrl } from "./client"
|
||||||
|
|
||||||
|
const normalizeDigits = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
|
||||||
|
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)))
|
||||||
|
|
||||||
|
export interface ContactSubmissionPayload {
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
email: string
|
||||||
|
mobile: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactSubmissionResponse extends ContactSubmissionPayload {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const submitContactForm = async (
|
||||||
|
payload: ContactSubmissionPayload,
|
||||||
|
): Promise<ContactSubmissionResponse> => {
|
||||||
|
const response = await fetch(buildApiUrl("/api/contact/"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
mobile: normalizeDigits(payload.mobile),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await buildApiError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createClient } from "../api/clients";
|
import { createClient } from "../api/clients";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
@@ -48,8 +48,9 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
|||||||
setThumbnailFile(file);
|
setThumbnailFile(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
|
||||||
if (!name.trim()) return;
|
event?.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await createClient(workspaceId, { name, notes, thumbnail: thumbnailFile });
|
await createClient(workspaceId, { name, notes, thumbnail: thumbnailFile });
|
||||||
@@ -72,15 +73,15 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
|||||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
{t.actions?.cancel}
|
{t.actions?.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}>
|
<Button type="submit" form="create-client-form" disabled={isLoading || !name.trim()}>
|
||||||
{isLoading ? "..." : t.clients.create}
|
{isLoading ? "..." : t.clients.create}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
|
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.modalTitle} footer={footer}>
|
||||||
<div className="space-y-4">
|
<form id="create-client-form" onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||||
{t.workspace?.thumbnailLabel || "Thumbnail"}
|
{t.workspace?.thumbnailLabel || "Thumbnail"}
|
||||||
@@ -117,7 +118,7 @@ export default function CreateClientModal({ isOpen, onClose, onSuccess, workspac
|
|||||||
placeholder={t.clients.notesPlaceholder}
|
placeholder={t.clients.notesPlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { type Client } from "../types/client";
|
import { type Client } from "../types/client";
|
||||||
import { deleteClient } from "../api/clients";
|
import { deleteClient } from "../api/clients";
|
||||||
@@ -17,8 +17,9 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (event?: FormEvent<HTMLFormElement>) => {
|
||||||
if (!client) return;
|
event?.preventDefault();
|
||||||
|
if (!client) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await deleteClient(client.id);
|
await deleteClient(client.id);
|
||||||
@@ -38,11 +39,12 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
|
|||||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
{t.actions?.cancel}
|
{t.actions?.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
type="submit"
|
||||||
onClick={handleDelete}
|
form="delete-client-form"
|
||||||
disabled={isLoading}
|
variant="destructive"
|
||||||
>
|
disabled={isLoading}
|
||||||
|
>
|
||||||
{isLoading ? "..." : t.clients.delete}
|
{isLoading ? "..." : t.clients.delete}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -55,10 +57,12 @@ export default function DeleteClientModal({ isOpen, onClose, onSuccess, client }
|
|||||||
title={t.clients.deleteConfirmTitle}
|
title={t.clients.deleteConfirmTitle}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
maxWidth="max-w-sm"
|
maxWidth="max-w-sm"
|
||||||
>
|
>
|
||||||
<p className="text-slate-500 dark:text-slate-400">
|
<form id="delete-client-form" onSubmit={handleDelete}>
|
||||||
{client ? t.clients.deleteConfirmMessage(client.name) : ""}
|
<p className="text-slate-500 dark:text-slate-400">
|
||||||
</p>
|
{client ? t.clients.deleteConfirmMessage(client.name) : ""}
|
||||||
</Modal>
|
</p>
|
||||||
);
|
</form>
|
||||||
}
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, type FormEvent } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { type Client } from "../types/client";
|
import { type Client } from "../types/client";
|
||||||
import { updateClient } from "../api/clients";
|
import { updateClient } from "../api/clients";
|
||||||
@@ -59,8 +59,9 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
|||||||
setClearThumbnail(false);
|
setClearThumbnail(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
|
||||||
if (!client || !name.trim()) return;
|
event?.preventDefault();
|
||||||
|
if (!client || !name.trim()) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await updateClient(client.id, { name, notes, thumbnail: thumbnailFile, clear_thumbnail: clearThumbnail });
|
await updateClient(client.id, { name, notes, thumbnail: thumbnailFile, clear_thumbnail: clearThumbnail });
|
||||||
@@ -80,15 +81,15 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
|||||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
{t.actions?.cancel}
|
{t.actions?.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={isLoading || !name.trim()}>
|
<Button type="submit" form="edit-client-form" disabled={isLoading || !name.trim()}>
|
||||||
{isLoading ? "..." : t.clients.saveChanges}
|
{isLoading ? "..." : t.clients.saveChanges}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
|
<Modal isOpen={isOpen} onClose={onClose} title={t.clients.editClient} footer={footer}>
|
||||||
<div className="space-y-4">
|
<form id="edit-client-form" onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
<label className="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">
|
||||||
{t.workspace?.thumbnailLabel || "Thumbnail"}
|
{t.workspace?.thumbnailLabel || "Thumbnail"}
|
||||||
@@ -138,7 +139,7 @@ export default function EditClientModal({ isOpen, onClose, onSuccess, client }:
|
|||||||
placeholder={t.clients.notesPlaceholder}
|
placeholder={t.clients.notesPlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Card } from "./ui/card";
|
import { Card } from "./ui/card";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -23,16 +23,44 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
footer,
|
footer,
|
||||||
maxWidth = "max-w-lg",
|
maxWidth = "max-w-lg",
|
||||||
}) => {
|
}) => {
|
||||||
useEffect(() => {
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
if (isOpen) {
|
|
||||||
document.body.style.overflow = "hidden";
|
useEffect(() => {
|
||||||
} else {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
document.body.style.overflow = "unset";
|
if (event.key === "Escape") {
|
||||||
}
|
onClose();
|
||||||
return () => {
|
}
|
||||||
document.body.style.overflow = "unset";
|
};
|
||||||
};
|
|
||||||
}, [isOpen]);
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "unset";
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const focusTimer = window.setTimeout(() => {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (activeElement instanceof HTMLElement && cardRef.current?.contains(activeElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstTextInput = cardRef.current?.querySelector<HTMLElement>(
|
||||||
|
'input:not([type]), input[type="text"], input[type="search"], input[type="email"], input[type="tel"], input[type="password"], input[type="number"], textarea',
|
||||||
|
);
|
||||||
|
firstTextInput?.focus();
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(focusTimer);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -42,6 +70,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
|
ref={cardRef}
|
||||||
className={`flex max-h-[calc(100vh-2rem)] w-full flex-col ${maxWidth} bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-0 shadow-xl overflow-hidden rounded-2xl`}
|
className={`flex max-h-[calc(100vh-2rem)] w-full flex-col ${maxWidth} bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-0 shadow-xl overflow-hidden rounded-2xl`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
|||||||
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600 dark:hover:bg-slate-700">
|
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600 dark:hover:bg-slate-700">
|
||||||
{t.actions?.cancel || "Cancel"}
|
{t.actions?.cancel || "Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleSubmit} disabled={loading || !formData.name} type="button" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
<button form="create-project-form" disabled={loading || !formData.name} type="submit" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||||
{loading ? "..." : t.projects?.create}
|
{loading ? "..." : t.projects?.create}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -110,7 +110,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}>
|
<Modal isOpen={isOpen} onClose={onClose} title={t.projects?.createProject} footer={footer}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form id="create-project-form" onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* ردیف اول: عنوان و انتخاب رنگ */}
|
{/* ردیف اول: عنوان و انتخاب رنگ */}
|
||||||
<div className="flex items-end gap-3">
|
<div className="flex items-end gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600">
|
<button onClick={onClose} type="button" className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600">
|
||||||
{t.actions?.cancel || "Cancel"}
|
{t.actions?.cancel || "Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleSubmit} disabled={loading || !formData.name} type="button" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
<button form="edit-project-form" disabled={loading || !formData.name} type="submit" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||||
{loading ? "..." : t.save || "Save"}
|
{loading ? "..." : t.save || "Save"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,7 +165,7 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}>
|
<Modal isOpen={isOpen} onClose={onClose} title={t.projects.editProject} footer={footer}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 mb-6">
|
<form id="edit-project-form" onSubmit={handleSubmit} className="space-y-4 mb-6">
|
||||||
<div className="flex items-end gap-3">
|
<div className="flex items-end gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
<label className="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from "react"
|
import React, { useEffect, useRef, useState } from "react"
|
||||||
import DatePicker, { DateObject } from "react-multi-date-picker"
|
import DatePicker, { DateObject } from "react-multi-date-picker"
|
||||||
import persian from "react-date-object/calendars/persian"
|
import persian from "react-date-object/calendars/persian"
|
||||||
import persian_fa from "react-date-object/locales/persian_fa"
|
import persian_fa from "react-date-object/locales/persian_fa"
|
||||||
@@ -16,8 +16,10 @@ interface JalaliDatePickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function JalaliDatePicker({ value, onChange, label, disabled, inputClassName = "", placeholder }: JalaliDatePickerProps) {
|
export default function JalaliDatePicker({ value, onChange, label, disabled, inputClassName = "", placeholder }: JalaliDatePickerProps) {
|
||||||
const isFa = document.documentElement.dir === 'rtl'
|
const isFa = document.documentElement.dir === 'rtl'
|
||||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
|
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'))
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [calendarPosition, setCalendarPosition] = useState("bottom-right")
|
||||||
|
|
||||||
// Listen for dark mode changes dynamically (optional but good for UX)
|
// Listen for dark mode changes dynamically (optional but good for UX)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,17 +30,28 @@ export default function JalaliDatePicker({ value, onChange, label, disabled, inp
|
|||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleChange = (date: DateObject | null) => {
|
const handleChange = (date: DateObject | null) => {
|
||||||
if (!date) {
|
if (!date) {
|
||||||
onChange("")
|
onChange("")
|
||||||
} else {
|
} else {
|
||||||
// Always output standard Gregorian "YYYY-MM-DD" for backend
|
// Always output standard Gregorian "YYYY-MM-DD" for backend
|
||||||
onChange(date.convert(gregorian, gregorian_en).format("YYYY-MM-DD"))
|
onChange(date.convert(gregorian, gregorian_en).format("YYYY-MM-DD"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const updateCalendarPosition = () => {
|
||||||
<div className="w-full">
|
const rect = containerRef.current?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
|
||||||
|
const estimatedHeight = 340
|
||||||
|
const hasMoreSpaceAbove = rect.top > window.innerHeight - rect.bottom
|
||||||
|
const shouldOpenTop = window.innerHeight - rect.bottom < estimatedHeight && hasMoreSpaceAbove
|
||||||
|
const horizontal = isFa ? "left" : "right"
|
||||||
|
setCalendarPosition(`${shouldOpenTop ? "top" : "bottom"}-${horizontal}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
<label className="text-sm font-medium dark:text-slate-300 mb-1 block">
|
<label className="text-sm font-medium dark:text-slate-300 mb-1 block">
|
||||||
{label}
|
{label}
|
||||||
@@ -52,13 +65,14 @@ export default function JalaliDatePicker({ value, onChange, label, disabled, inp
|
|||||||
format="YYYY/MM/DD"
|
format="YYYY/MM/DD"
|
||||||
placeholder={placeholder || "YYYY/MM/DD"}
|
placeholder={placeholder || "YYYY/MM/DD"}
|
||||||
onOpenPickNewDate={false}
|
onOpenPickNewDate={false}
|
||||||
|
onOpen={updateCalendarPosition}
|
||||||
inputClass={`w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${inputClassName}`}
|
inputClass={`w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm dark:border-slate-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${inputClassName}`}
|
||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
className={isDark ? "bg-dark" : ""}
|
className={isDark ? "bg-dark" : ""}
|
||||||
calendarPosition="bottom-right"
|
calendarPosition={calendarPosition}
|
||||||
fixMainPosition
|
fixMainPosition
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
"contact": {
|
"contact": {
|
||||||
"eyebrow": "Contact",
|
"eyebrow": "Contact",
|
||||||
"title": "Need help or want to talk about Qlockify?",
|
"title": "Need help or want to talk about Qlockify?",
|
||||||
"description": "Send a note or reach out through the support channels below. The form opens an email draft with your message so you can review it before sending.",
|
"description": "Send a note or reach out through the support channels below. Contact form submissions are stored securely so the team can review and follow up.",
|
||||||
"formTitle": "Send a note",
|
"formTitle": "Send a note",
|
||||||
"fields": {
|
"fields": {
|
||||||
"firstName": "First name",
|
"firstName": "First name",
|
||||||
@@ -83,7 +83,10 @@
|
|||||||
"mobile": "09...",
|
"mobile": "09...",
|
||||||
"message": "Tell us what you need help with..."
|
"message": "Tell us what you need help with..."
|
||||||
},
|
},
|
||||||
"submit": "Prepare email",
|
"submit": "Send message",
|
||||||
|
"submitting": "Sending...",
|
||||||
|
"success": "Your message was saved. We will contact you soon.",
|
||||||
|
"error": "Could not send your message. Please try again.",
|
||||||
"channels": [
|
"channels": [
|
||||||
{
|
{
|
||||||
"label": "Telegram support",
|
"label": "Telegram support",
|
||||||
@@ -181,7 +184,7 @@
|
|||||||
"contact": {
|
"contact": {
|
||||||
"eyebrow": "تماس",
|
"eyebrow": "تماس",
|
||||||
"title": "کمک میخواهید یا میخواهید درباره Qlockify صحبت کنیم؟",
|
"title": "کمک میخواهید یا میخواهید درباره Qlockify صحبت کنیم؟",
|
||||||
"description": "یک پیام بفرستید یا از مسیرهای پشتیبانی زیر با ما در ارتباط باشید. فرم یک پیشنویس ایمیل با پیام شما آماده میکند تا قبل از ارسال آن را بررسی کنید.",
|
"description": "یک پیام بفرستید یا از مسیرهای پشتیبانی زیر با ما در ارتباط باشید. پیامهای فرم تماس ذخیره میشوند تا تیم بتواند آنها را بررسی و پیگیری کند.",
|
||||||
"formTitle": "ارسال پیام",
|
"formTitle": "ارسال پیام",
|
||||||
"fields": {
|
"fields": {
|
||||||
"firstName": "نام",
|
"firstName": "نام",
|
||||||
@@ -197,7 +200,10 @@
|
|||||||
"mobile": "09...",
|
"mobile": "09...",
|
||||||
"message": "بگویید برای چه چیزی به کمک نیاز دارید..."
|
"message": "بگویید برای چه چیزی به کمک نیاز دارید..."
|
||||||
},
|
},
|
||||||
"submit": "آمادهسازی ایمیل",
|
"submit": "ارسال پیام",
|
||||||
|
"submitting": "در حال ارسال...",
|
||||||
|
"success": "پیام شما ثبت شد. بهزودی با شما تماس میگیریم.",
|
||||||
|
"error": "ارسال پیام انجام نشد. لطفا دوباره تلاش کنید.",
|
||||||
"channels": [
|
"channels": [
|
||||||
{
|
{
|
||||||
"label": "پشتیبانی تلگرام",
|
"label": "پشتیبانی تلگرام",
|
||||||
|
|||||||
@@ -698,10 +698,14 @@ export const en = {
|
|||||||
searchTagsLabel: "Search tags...",
|
searchTagsLabel: "Search tags...",
|
||||||
noTagsFoundLabel: "No tags found.",
|
noTagsFoundLabel: "No tags found.",
|
||||||
searchProjectsLabel: "Search projects...",
|
searchProjectsLabel: "Search projects...",
|
||||||
noProjectsFoundLabel: "No projects found.",
|
noProjectsFoundLabel: "No projects found.",
|
||||||
deletedProjectLabel: "Deleted project",
|
deletedProjectLabel: "Deleted project",
|
||||||
deletedTagLabel: "Deleted tag",
|
deletedTagLabel: "Deleted tag",
|
||||||
},
|
startRequiredError: "Start date and time are required.",
|
||||||
|
endRequiredError: "End date and time must both be filled.",
|
||||||
|
invalidEndTimeError: "End time is invalid.",
|
||||||
|
endBeforeStartError: "End must be after start.",
|
||||||
|
},
|
||||||
|
|
||||||
reports: {
|
reports: {
|
||||||
title: "Reports",
|
title: "Reports",
|
||||||
|
|||||||
@@ -695,10 +695,14 @@ export const fa = {
|
|||||||
searchTagsLabel: "جستوجوی تگها...",
|
searchTagsLabel: "جستوجوی تگها...",
|
||||||
noTagsFoundLabel: "تگی پیدا نشد.",
|
noTagsFoundLabel: "تگی پیدا نشد.",
|
||||||
searchProjectsLabel: "جستوجوی پروژهها...",
|
searchProjectsLabel: "جستوجوی پروژهها...",
|
||||||
noProjectsFoundLabel: "پروژهای پیدا نشد.",
|
noProjectsFoundLabel: "پروژهای پیدا نشد.",
|
||||||
deletedProjectLabel: "پروژه حذفشده",
|
deletedProjectLabel: "پروژه حذفشده",
|
||||||
deletedTagLabel: "تگ حذفشده",
|
deletedTagLabel: "تگ حذفشده",
|
||||||
},
|
startRequiredError: "تاریخ و زمان شروع الزامی است.",
|
||||||
|
endRequiredError: "تاریخ و زمان پایان باید هر دو وارد شوند.",
|
||||||
|
invalidEndTimeError: "زمان پایان معتبر نیست.",
|
||||||
|
endBeforeStartError: "پایان باید بعد از شروع باشد.",
|
||||||
|
},
|
||||||
reports: {
|
reports: {
|
||||||
title: "گزارشها",
|
title: "گزارشها",
|
||||||
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
|
description: (workspaceName: string) => `مرور گزارش فعالیت برای ${workspaceName}`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, type FormEvent } from "react"
|
import { useState, type FormEvent } from "react"
|
||||||
import { Link, useNavigate } from "react-router-dom"
|
import { Link, useNavigate } from "react-router-dom"
|
||||||
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -29,6 +30,7 @@ import { Input } from "../components/ui/input"
|
|||||||
import { TextAreaInput } from "../components/ui/TextAreaInput"
|
import { TextAreaInput } from "../components/ui/TextAreaInput"
|
||||||
import { useTheme } from "../components/ThemeProvider"
|
import { useTheme } from "../components/ThemeProvider"
|
||||||
import { useTranslation } from "../hooks/useTranslation"
|
import { useTranslation } from "../hooks/useTranslation"
|
||||||
|
import { submitContactForm } from "../api/contact"
|
||||||
import aboutContent from "../content/about.json"
|
import aboutContent from "../content/about.json"
|
||||||
import { cn } from "../lib/utils"
|
import { cn } from "../lib/utils"
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ export default function About() {
|
|||||||
mobile: "",
|
mobile: "",
|
||||||
message: "",
|
message: "",
|
||||||
})
|
})
|
||||||
|
const [isContactSubmitting, setIsContactSubmitting] = useState(false)
|
||||||
|
|
||||||
const content = aboutContent[lang] as AboutContent
|
const content = aboutContent[lang] as AboutContent
|
||||||
const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken")
|
const isAuthenticated = typeof window !== "undefined" && !!localStorage.getItem("accessToken")
|
||||||
@@ -62,21 +65,30 @@ export default function About() {
|
|||||||
setContactForm((current) => ({ ...current, [field]: value }))
|
setContactForm((current) => ({ ...current, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitContactForm = (event: FormEvent<HTMLFormElement>) => {
|
const handleContactSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const subject = encodeURIComponent("Qlockify contact request")
|
setIsContactSubmitting(true)
|
||||||
const body = encodeURIComponent(
|
try {
|
||||||
[
|
await submitContactForm({
|
||||||
`First name: ${contactForm.firstName}`,
|
first_name: contactForm.firstName.trim(),
|
||||||
`Last name: ${contactForm.lastName}`,
|
last_name: contactForm.lastName.trim(),
|
||||||
`Email: ${contactForm.email}`,
|
email: contactForm.email.trim(),
|
||||||
`Mobile: ${contactForm.mobile}`,
|
mobile: contactForm.mobile.trim(),
|
||||||
"",
|
message: contactForm.message.trim(),
|
||||||
"Message:",
|
})
|
||||||
contactForm.message,
|
setContactForm({
|
||||||
].join("\n"),
|
firstName: "",
|
||||||
)
|
lastName: "",
|
||||||
window.location.href = `mailto:qlockify@gmail.com?subject=${subject}&body=${body}`
|
email: "",
|
||||||
|
mobile: "",
|
||||||
|
message: "",
|
||||||
|
})
|
||||||
|
toast.success(content.contact.success)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : content.contact.error)
|
||||||
|
} finally {
|
||||||
|
setIsContactSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -377,7 +389,7 @@ export default function About() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={submitContactForm}
|
onSubmit={handleContactSubmit}
|
||||||
className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/85 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70"
|
className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/85 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70"
|
||||||
>
|
>
|
||||||
<div className="mb-6 flex items-center gap-3">
|
<div className="mb-6 flex items-center gap-3">
|
||||||
@@ -446,10 +458,11 @@ export default function About() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
disabled={isContactSubmitting}
|
||||||
className="mt-6 h-14 w-full rounded-full bg-slate-950 px-7 text-base text-white hover:bg-slate-800 dark:bg-cyan-400 dark:text-slate-950 dark:hover:bg-cyan-300"
|
className="mt-6 h-14 w-full rounded-full bg-slate-950 px-7 text-base text-white hover:bg-slate-800 dark:bg-cyan-400 dark:text-slate-950 dark:hover:bg-cyan-300"
|
||||||
>
|
>
|
||||||
<Send className="me-2 h-4 w-4" />
|
<Send className="me-2 h-4 w-4" />
|
||||||
{content.contact.submit}
|
{isContactSubmitting ? content.contact.submitting : content.contact.submit}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
import { getProjects, deleteProject, type Project } from "../api/projects";
|
import { getProjects, deleteProject, type Project } from "../api/projects";
|
||||||
@@ -149,7 +149,8 @@ export const Projects: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]);
|
}, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]);
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async (event?: FormEvent<HTMLFormElement>) => {
|
||||||
|
event?.preventDefault();
|
||||||
if (!deleteModal.project) return;
|
if (!deleteModal.project) return;
|
||||||
try {
|
try {
|
||||||
const deletedId = deleteModal.project.id;
|
const deletedId = deleteModal.project.id;
|
||||||
@@ -543,7 +544,8 @@ export const Projects: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={deleteInput !== deleteModal.project.name}
|
disabled={deleteInput !== deleteModal.project.name}
|
||||||
onClick={confirmDelete}
|
type="submit"
|
||||||
|
form="delete-project-form"
|
||||||
className="rounded-xl font-semibold"
|
className="rounded-xl font-semibold"
|
||||||
>
|
>
|
||||||
{t.actions?.delete || 'Delete'}
|
{t.actions?.delete || 'Delete'}
|
||||||
@@ -551,7 +553,7 @@ export const Projects: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<form id="delete-project-form" onSubmit={confirmDelete} className="flex flex-col gap-4">
|
||||||
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
|
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
|
||||||
{t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.project.name}</strong>
|
{t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.project.name}</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -562,7 +564,7 @@ export const Projects: React.FC = () => {
|
|||||||
onChange={(e) => setDeleteInput(e.target.value)}
|
onChange={(e) => setDeleteInput(e.target.value)}
|
||||||
placeholder={deleteModal.project.name}
|
placeholder={deleteModal.project.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
|
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -105,7 +105,8 @@ export default function Tags() {
|
|||||||
setFormColor(DEFAULT_COLOR);
|
setFormColor(DEFAULT_COLOR);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (event?: FormEvent<HTMLFormElement>) => {
|
||||||
|
event?.preventDefault();
|
||||||
if (!activeWorkspace?.id || !formName.trim()) return;
|
if (!activeWorkspace?.id || !formName.trim()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -284,13 +285,13 @@ export default function Tags() {
|
|||||||
<Button variant="secondary" onClick={closeModal}>
|
<Button variant="secondary" onClick={closeModal}>
|
||||||
{t.actions?.cancel || "Cancel"}
|
{t.actions?.cancel || "Cancel"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void handleSubmit()} disabled={isSaving || !formName.trim()}>
|
<Button type="submit" form="tag-form" disabled={isSaving || !formName.trim()}>
|
||||||
{isSaving ? "..." : (editingTag ? (t.save || "Save") : (t.create || "Create"))}
|
{isSaving ? "..." : (editingTag ? (t.save || "Save") : (t.create || "Create"))}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<form id="tag-form" onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
{t.tags?.nameLabel || "Tag name"}
|
{t.tags?.nameLabel || "Tag name"}
|
||||||
@@ -304,7 +305,7 @@ export default function Tags() {
|
|||||||
</label>
|
</label>
|
||||||
<input type="color" value={formColor} onChange={(event) => setFormColor(event.target.value)} className="h-10 w-14 cursor-pointer rounded-md border border-slate-200 dark:border-slate-700 bg-transparent" />
|
<input type="color" value={formColor} onChange={(event) => setFormColor(event.target.value)} className="h-10 w-14 cursor-pointer rounded-md border border-slate-200 dark:border-slate-700 bg-transparent" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{deleteModal.tag && (
|
{deleteModal.tag && (
|
||||||
@@ -320,21 +321,28 @@ export default function Tags() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => {
|
type="submit"
|
||||||
if (!deleteModal.tag) return;
|
form="delete-tag-form"
|
||||||
void handleDelete(deleteModal.tag);
|
|
||||||
setDeleteModal({ isOpen: false, tag: null });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t.actions?.delete || "Delete"}
|
{t.actions?.delete || "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
<form
|
||||||
{(t.tags?.deleteConfirmMessage as ((name: string) => string) | undefined)?.(deleteModal.tag.name) ||
|
id="delete-tag-form"
|
||||||
`Are you sure you want to delete "${deleteModal.tag.name}"?`}
|
onSubmit={(event) => {
|
||||||
</p>
|
event.preventDefault();
|
||||||
|
if (!deleteModal.tag) return;
|
||||||
|
void handleDelete(deleteModal.tag);
|
||||||
|
setDeleteModal({ isOpen: false, tag: null });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
|
||||||
|
{(t.tags?.deleteConfirmMessage as ((name: string) => string) | undefined)?.(deleteModal.tag.name) ||
|
||||||
|
`Are you sure you want to delete "${deleteModal.tag.name}"?`}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Plus, Trash2, Pencil, Eye, LayoutDashboard } from 'lucide-react';
|
import { Plus, Trash2, Pencil, Eye, LayoutDashboard } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -94,8 +94,9 @@ export default function Workspaces() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async (event?: FormEvent<HTMLFormElement>) => {
|
||||||
if (!deleteModal.workspace) return;
|
event?.preventDefault();
|
||||||
|
if (!deleteModal.workspace) return;
|
||||||
try {
|
try {
|
||||||
const deletedId = deleteModal.workspace.id;
|
const deletedId = deleteModal.workspace.id;
|
||||||
await deleteWorkspace(deletedId);
|
await deleteWorkspace(deletedId);
|
||||||
@@ -275,15 +276,16 @@ export default function Workspaces() {
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={deleteInput !== deleteModal.workspace.name}
|
disabled={deleteInput !== deleteModal.workspace.name}
|
||||||
onClick={confirmDelete}
|
type="submit"
|
||||||
className="rounded-xl font-semibold"
|
form="delete-workspace-form"
|
||||||
|
className="rounded-xl font-semibold"
|
||||||
>
|
>
|
||||||
{t.actions?.delete || 'Delete'}
|
{t.actions?.delete || 'Delete'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<form id="delete-workspace-form" onSubmit={confirmDelete} className="flex flex-col gap-4">
|
||||||
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
|
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
|
||||||
{t.workspace?.deleteWarning || 'To confirm deletion, please type the workspace name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.workspace.name}</strong>
|
{t.workspace?.deleteWarning || 'To confirm deletion, please type the workspace name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.workspace.name}</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -294,7 +296,7 @@ export default function Workspaces() {
|
|||||||
onChange={(e) => setDeleteInput(e.target.value)}
|
onChange={(e) => setDeleteInput(e.target.value)}
|
||||||
placeholder={deleteModal.workspace.name}
|
placeholder={deleteModal.workspace.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user