Compare commits

...

9 Commits

Author SHA1 Message Date
29cadb83e6 feat(timesheet): improve inline edit autosave
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-07 15:37:40 +03:30
03c7c07a9f fix(datepicker): avoid offscreen calendar placement 2026-06-07 15:37:26 +03:30
666d04ff26 fix(forms): submit modal actions with enter 2026-06-07 15:37:02 +03:30
132c8c44ef fix(modal): add keyboard close and autofocus 2026-06-07 15:36:43 +03:30
8abfcc9c2b feat(about): submit contact form to api 2026-06-07 14:09:54 +03:30
69908887c1 fix(landing): change navbar active link style
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-07 13:19:57 +03:30
e4ab9d2a12 feat(brand): add qlockify profile images
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-07 12:50:04 +03:30
b4e06b641d feat(about): add contact section 2026-06-07 12:49:53 +03:30
e8eff6c2cb fix(routing): simplify not found page 2026-06-07 12:49:38 +03:30
20 changed files with 1017 additions and 477 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

41
src/api/contact.ts Normal file
View 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()
}

View File

@@ -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>
); );
} }

View File

@@ -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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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()}
> >

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>
) )
} }

View File

@@ -64,6 +64,52 @@
"Project access and user roles help limit what each person can see or use.", "Project access and user roles help limit what each person can see or use.",
"Exports and logs are designed to make review easier, not to hide important details." "Exports and logs are designed to make review easier, not to hide important details."
], ],
"contact": {
"eyebrow": "Contact",
"title": "Need help or want to talk about Qlockify?",
"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",
"fields": {
"firstName": "First name",
"lastName": "Last name",
"email": "Email",
"mobile": "Mobile",
"message": "Message"
},
"placeholders": {
"firstName": "Your first name",
"lastName": "Your last name",
"email": "you@example.com",
"mobile": "09...",
"message": "Tell us what you need help with..."
},
"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": [
{
"label": "Telegram support",
"value": "qlockify_support",
"href": "https://t.me/qlockify_support"
},
{
"label": "Telegram channel",
"value": "qlockify",
"href": "https://t.me/qlockify"
},
{
"label": "Support email",
"value": "qlockify@gmail.com",
"href": "mailto:qlockify@gmail.com"
},
{
"label": "Mobile (message or call)",
"value": "09938228438",
"href": "tel:09938228438"
}
]
},
"cta": { "cta": {
"title": "Start with one workspace and make time easier to explain.", "title": "Start with one workspace and make time easier to explain.",
"description": "Open Qlockify, track a real workday, and compare the report with the way your team reviews work today.", "description": "Open Qlockify, track a real workday, and compare the report with the way your team reviews work today.",
@@ -135,6 +181,52 @@
"دسترسی پروژه و نقش کاربران کمک می‌کند هر فرد فقط چیزی را ببیند یا استفاده کند که باید.", "دسترسی پروژه و نقش کاربران کمک می‌کند هر فرد فقط چیزی را ببیند یا استفاده کند که باید.",
"خروجی‌ها و لاگ‌ها برای ساده‌تر کردن بررسی طراحی شده‌اند، نه برای پنهان کردن جزئیات مهم." "خروجی‌ها و لاگ‌ها برای ساده‌تر کردن بررسی طراحی شده‌اند، نه برای پنهان کردن جزئیات مهم."
], ],
"contact": {
"eyebrow": "تماس",
"title": "کمک می‌خواهید یا می‌خواهید درباره Qlockify صحبت کنیم؟",
"description": "یک پیام بفرستید یا از مسیرهای پشتیبانی زیر با ما در ارتباط باشید. پیام‌های فرم تماس ذخیره می‌شوند تا تیم بتواند آن‌ها را بررسی و پیگیری کند.",
"formTitle": "ارسال پیام",
"fields": {
"firstName": "نام",
"lastName": "نام خانوادگی",
"email": "ایمیل",
"mobile": "موبایل",
"message": "پیام"
},
"placeholders": {
"firstName": "نام شما",
"lastName": "نام خانوادگی شما",
"email": "you@example.com",
"mobile": "09...",
"message": "بگویید برای چه چیزی به کمک نیاز دارید..."
},
"submit": "ارسال پیام",
"submitting": "در حال ارسال...",
"success": "پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.",
"error": "ارسال پیام انجام نشد. لطفا دوباره تلاش کنید.",
"channels": [
{
"label": "پشتیبانی تلگرام",
"value": "qlockify_support",
"href": "https://t.me/qlockify_support"
},
{
"label": "کانال تلگرام",
"value": "qlockify",
"href": "https://t.me/qlockify"
},
{
"label": "ایمیل پشتیبانی",
"value": "qlockify@gmail.com",
"href": "mailto:qlockify@gmail.com"
},
{
"label": "موبایل (پیام یا تماس)",
"value": "09938228438",
"href": "tel:09938228438"
}
]
},
"cta": { "cta": {
"title": "با یک ورک‌اسپیس شروع کنید و توضیح زمان را ساده‌تر کنید.", "title": "با یک ورک‌اسپیس شروع کنید و توضیح زمان را ساده‌تر کنید.",
"description": "Qlockify را باز کنید، یک روز کاری واقعی را ثبت کنید و گزارش آن را با روش فعلی مرور کار در تیم مقایسه کنید.", "description": "Qlockify را باز کنید، یک روز کاری واقعی را ثبت کنید و گزارش آن را با روش فعلی مرور کار در تیم مقایسه کنید.",

View File

@@ -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",

View File

@@ -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}`,

View File

@@ -1,7 +1,10 @@
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,
AtSign,
BarChart3, BarChart3,
CheckCircle2, CheckCircle2,
Command, Command,
@@ -9,7 +12,11 @@ import {
Globe2, Globe2,
Layers3, Layers3,
LockKeyhole, LockKeyhole,
Mail,
MessageCircle,
Moon, Moon,
Phone,
Send,
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
Sun, Sun,
@@ -19,8 +26,11 @@ import {
} from "lucide-react" } from "lucide-react"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { Input } from "../components/ui/input"
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"
@@ -29,11 +39,20 @@ type AboutContent = typeof aboutContent.en
const sectionIcons = [Sparkles, ShieldCheck, Layers3] const sectionIcons = [Sparkles, ShieldCheck, Layers3]
const principleIcons = [TimerReset, Waypoints, BarChart3] const principleIcons = [TimerReset, Waypoints, BarChart3]
const capabilityIcons = [TimerReset, Users, FileText, LockKeyhole] const capabilityIcons = [TimerReset, Users, FileText, LockKeyhole]
const contactIcons = [MessageCircle, MessageCircle, Mail, Phone]
export default function About() { export default function About() {
const navigate = useNavigate() const navigate = useNavigate()
const { t, lang, setLanguage } = useTranslation() const { t, lang, setLanguage } = useTranslation()
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const [contactForm, setContactForm] = useState({
firstName: "",
lastName: "",
email: "",
mobile: "",
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")
@@ -42,6 +61,36 @@ export default function About() {
(theme === "system" && document.documentElement.classList.contains("dark")) (theme === "system" && document.documentElement.classList.contains("dark"))
const ctaTarget = isAuthenticated ? "/timesheet" : "/auth" const ctaTarget = isAuthenticated ? "/timesheet" : "/auth"
const updateContactField = (field: keyof typeof contactForm, value: string) => {
setContactForm((current) => ({ ...current, [field]: value }))
}
const handleContactSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsContactSubmitting(true)
try {
await submitContactForm({
first_name: contactForm.firstName.trim(),
last_name: contactForm.lastName.trim(),
email: contactForm.email.trim(),
mobile: contactForm.mobile.trim(),
message: contactForm.message.trim(),
})
setContactForm({
firstName: "",
lastName: "",
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 (
<div className="scroll-smooth min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_36%,#eef2ff_100%)] text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_40%,#020617_100%)] dark:text-slate-50"> <div className="scroll-smooth min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_36%,#eef2ff_100%)] text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_40%,#020617_100%)] dark:text-slate-50">
<div className="landing-aurora pointer-events-none fixed inset-0 opacity-80" /> <div className="landing-aurora pointer-events-none fixed inset-0 opacity-80" />
@@ -67,13 +116,13 @@ export default function About() {
<nav className="hidden items-center gap-2 md:flex"> <nav className="hidden items-center gap-2 md:flex">
<Link <Link
to="/" to="/"
className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition dark:text-slate-200 dark:hover:text-white"
> >
{lang === "fa" ? "خانه" : "Home"} {lang === "fa" ? "خانه" : "Home"}
</Link> </Link>
<Link <Link
to="/about" to="/about"
className="rounded-full bg-slate-950 px-4 py-2 text-sm font-medium text-white shadow-sm dark:bg-white dark:text-slate-950" className="rounded-ful px-4 py-2 text-sm text-white shadow-sm font-bold"
> >
{lang === "fa" ? "درباره ما" : "About us"} {lang === "fa" ? "درباره ما" : "About us"}
</Link> </Link>
@@ -291,6 +340,133 @@ export default function About() {
</div> </div>
</section> </section>
<section className="grid gap-6 py-8 lg:grid-cols-[0.95fr_1.05fr]">
<div className="animate-landing-rise rounded-[2rem] border border-white/70 bg-white/80 p-7 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.6)] backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/65">
<div className="text-sm font-semibold uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">
{content.contact.eyebrow}
</div>
<h2 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-slate-950 dark:text-white">
{content.contact.title}
</h2>
<p className="mt-4 text-base leading-8 text-slate-600 dark:text-slate-300">
{content.contact.description}
</p>
<div className="mt-7 grid gap-3">
{content.contact.channels.map((channel, index) => {
const Icon = contactIcons[index] ?? AtSign
return (
<a
key={channel.label}
href={channel.href}
target={channel.href.startsWith("http") ? "_blank" : undefined}
rel={channel.href.startsWith("http") ? "noreferrer" : undefined}
className="group flex items-center justify-between gap-4 rounded-2xl border border-slate-200/80 bg-white/80 p-4 text-start shadow-sm transition hover:border-cyan-300 hover:bg-cyan-50/70 dark:border-slate-800 dark:bg-slate-900/75 dark:hover:border-cyan-500/40 dark:hover:bg-cyan-950/30"
>
<span className="flex min-w-0 items-center gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-cyan-400 dark:text-slate-950">
<Icon className="h-5 w-5" />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-slate-950 dark:text-white">
{channel.label}
</span>
<span className="block truncate text-sm text-slate-600 dark:text-slate-300">
{channel.value}
</span>
</span>
</span>
{lang === "fa" ? (
<ArrowLeft className="h-4 w-4 shrink-0 text-slate-400 transition group-hover:text-cyan-600 dark:group-hover:text-cyan-300" />
) : (
<ArrowRight className="h-4 w-4 shrink-0 text-slate-400 transition group-hover:text-cyan-600 dark:group-hover:text-cyan-300" />
)}
</a>
)
})}
</div>
</div>
<form
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"
>
<div className="mb-6 flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-950 text-white dark:bg-cyan-400 dark:text-slate-950">
<Send className="h-5 w-5" />
</span>
<h2 className="text-2xl font-semibold tracking-[-0.04em] text-slate-950 dark:text-white">
{content.contact.formTitle}
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.firstName}
<Input
value={contactForm.firstName}
onChange={(event) => updateContactField("firstName", event.target.value)}
placeholder={content.contact.placeholders.firstName}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.lastName}
<Input
value={contactForm.lastName}
onChange={(event) => updateContactField("lastName", event.target.value)}
placeholder={content.contact.placeholders.lastName}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.email}
<Input
type="email"
value={contactForm.email}
onChange={(event) => updateContactField("email", event.target.value)}
placeholder={content.contact.placeholders.email}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.mobile}
<Input
value={contactForm.mobile}
onChange={(event) => updateContactField("mobile", event.target.value)}
placeholder={content.contact.placeholders.mobile}
className="mt-2 h-12 rounded-2xl"
required
/>
</label>
</div>
<label className="mt-4 block text-sm font-medium text-slate-700 dark:text-slate-300">
{content.contact.fields.message}
<TextAreaInput
value={contactForm.message}
onChange={(event) => updateContactField("message", event.target.value)}
placeholder={content.contact.placeholders.message}
className="mt-2 min-h-40 rounded-2xl"
required
/>
</label>
<Button
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"
>
<Send className="me-2 h-4 w-4" />
{isContactSubmitting ? content.contact.submitting : content.contact.submit}
</Button>
</form>
</section>
<section className="py-8"> <section className="py-8">
<div className="relative overflow-hidden rounded-[2.5rem] border border-slate-950/5 bg-slate-950 px-6 py-10 text-white shadow-[0_40px_100px_-40px_rgba(15,23,42,0.8)] dark:border-white/10 sm:px-10"> <div className="relative overflow-hidden rounded-[2.5rem] border border-slate-950/5 bg-slate-950 px-6 py-10 text-white shadow-[0_40px_100px_-40px_rgba(15,23,42,0.8)] dark:border-white/10 sm:px-10">
<div className="pointer-events-none absolute inset-y-0 right-0 w-[45%] bg-[radial-gradient(circle_at_top_right,rgba(34,211,238,0.35),transparent_55%),radial-gradient(circle_at_bottom_right,rgba(16,185,129,0.24),transparent_45%)]" /> <div className="pointer-events-none absolute inset-y-0 right-0 w-[45%] bg-[radial-gradient(circle_at_top_right,rgba(34,211,238,0.35),transparent_55%),radial-gradient(circle_at_bottom_right,rgba(16,185,129,0.24),transparent_45%)]" />

View File

@@ -131,10 +131,10 @@ export default function Landing() {
</button> </button>
<div className="hidden items-center gap-2 md:flex"> <div className="hidden items-center gap-2 md:flex">
<Link to="/" className="rounded-full bg-slate-950 px-4 py-2 text-sm font-medium text-white shadow-sm dark:bg-white dark:text-slate-950"> <Link to="/" className="rounded-ful px-4 py-2 text-sm text-white shadow-sm font-bold">
{lang === "fa" ? "خانه" : "Home"} {lang === "fa" ? "خانه" : "Home"}
</Link> </Link>
<Link to="/about" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"> <Link to="/about" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition dark:text-slate-200 dark:hover:text-white">
{t.landing.nav.about} {t.landing.nav.about}
</Link> </Link>
</div> </div>

View File

@@ -1,70 +1,48 @@
import { Link, useLocation } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { ArrowLeft, ArrowRight, Command, Compass, Home } from "lucide-react" import { ArrowLeft, ArrowRight, Home } from "lucide-react"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { useTranslation } from "../hooks/useTranslation" import { useTranslation } from "../hooks/useTranslation"
import { cn } from "../lib/utils"
export default function NotFound() { export default function NotFound() {
const location = useLocation() const navigate = useNavigate()
const { lang, t } = useTranslation() const { lang } = useTranslation()
const isFa = lang === "fa" const isFa = lang === "fa"
return ( return (
<div className="min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_36%,#eef2ff_100%)] text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_40%,#020617_100%)] dark:text-slate-50"> <main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#e0f2fe_0%,#f8fafc_42%,#eef2ff_100%)] px-4 text-center text-slate-950 dark:bg-[radial-gradient(circle_at_top,#082f49_0%,#020617_44%,#020617_100%)] dark:text-slate-50">
<div className="landing-aurora pointer-events-none fixed inset-0 opacity-80" /> <section className="mx-auto max-w-2xl">
<div className="landing-hero-grid pointer-events-none fixed inset-0 opacity-70 dark:opacity-40" /> <div className="text-[7rem] font-semibold leading-none tracking-[-0.08em] text-slate-950 sm:text-[10rem] dark:text-white">
404
<main className="relative mx-auto flex min-h-screen max-w-5xl items-center px-4 py-12 sm:px-6 lg:px-8">
<div className="animate-landing-rise w-full overflow-hidden rounded-[2.5rem] border border-white/70 bg-white/80 p-6 shadow-[0_45px_110px_-48px_rgba(15,23,42,0.6)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/70 sm:p-10">
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<div className="mb-5 inline-flex items-center gap-2 rounded-full border border-cyan-200/70 bg-cyan-50/80 px-4 py-2 text-sm font-semibold text-cyan-900 dark:border-cyan-500/20 dark:bg-cyan-500/10 dark:text-cyan-100">
<Compass className="h-4 w-4" />
{isFa ? "مسیر پیدا نشد" : "Route not found"}
</div>
<h1 className="text-5xl font-semibold tracking-[-0.06em] text-slate-950 sm:text-7xl dark:text-white">
404
</h1>
<p className="mt-5 text-xl leading-9 text-slate-600 dark:text-slate-300">
{isFa
? "این آدرس در رابط کاربری Qlockify تعریف نشده است."
: "This endpoint is not defined in the Qlockify interface."}
</p>
<div className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 font-mono text-sm text-slate-700 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200">
{location.pathname}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row lg:flex-col">
<Button
asChild
className="h-14 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"
>
<Link to="/">
<Home className="me-2 h-4 w-4" />
{isFa ? "بازگشت به خانه" : "Back home"}
</Link>
</Button>
<Button
asChild
variant="outline"
className="h-14 rounded-full border-slate-200 bg-white/85 px-7 text-base text-slate-800 shadow-sm backdrop-blur hover:bg-white dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900"
>
<Link to="/about">
<Command className="me-2 h-4 w-4" />
{isFa ? "درباره Qlockify" : "About Qlockify"}
{isFa ? (
<ArrowLeft className="ms-2 h-4 w-4" />
) : (
<ArrowRight className={cn("ms-2 h-4 w-4")} />
)}
</Link>
</Button>
</div>
</div>
</div> </div>
</main> <h1 className="mt-5 text-3xl font-semibold tracking-[-0.04em] sm:text-5xl">
</div> {isFa ? "صفحه پیدا نشد" : "Page not found"}
</h1>
<p className="mx-auto mt-5 max-w-xl text-base leading-8 text-slate-600 sm:text-lg dark:text-slate-300">
{isFa
? "این صفحه وجود ندارد یا آدرس آن تغییر کرده است."
: "This page does not exist or its address has changed."}
</p>
<div className="mt-9 flex flex-col justify-center gap-3 sm:flex-row">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
className="h-14 rounded-full border-slate-200 bg-white/80 px-7 text-base text-slate-800 backdrop-blur hover:bg-white dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-100 dark:hover:bg-slate-900"
>
{isFa ? <ArrowRight className="me-2 h-4 w-4" /> : <ArrowLeft className="me-2 h-4 w-4" />}
{isFa ? "بازگشت" : "Go back"}
</Button>
<Button
type="button"
onClick={() => navigate("/")}
className="h-14 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"
>
<Home className="me-2 h-4 w-4" />
{isFa ? "صفحه اصلی" : "Home page"}
</Button>
</div>
</section>
</main>
) )
} }

View File

@@ -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>
)} )}

View File

@@ -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

View File

@@ -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>