configer the stamp

This commit is contained in:
Marshal
2026-08-02 20:29:09 +00:00
parent 3956c78c54
commit 950bcc0912
7 changed files with 281 additions and 123 deletions

View File

@@ -7,12 +7,14 @@ export class SaveSignatureDto {
@MinLength(1) @MinLength(1)
signerDisplayName!: string; signerDisplayName!: string;
@ApiProperty({ @ApiPropertyOptional({
description: 'PNG signature image as base64 (with or without data URL prefix)', description:
'PNG signature image as base64 (with or without data URL prefix). Omit to keep the existing saved signature (stamp-only update).',
}) })
@IsOptional()
@IsString() @IsString()
@MinLength(20) @MinLength(20)
signatureImageBase64!: string; signatureImageBase64?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:

View File

@@ -12,7 +12,8 @@ import { SavedSignatureDto } from './dto/save-signature.dto';
export interface UpsertSignatureInput { export interface UpsertSignatureInput {
userId: string; userId: string;
signerDisplayName: string; signerDisplayName: string;
signatureImageBase64: string; /** Optional; omitted = keep the existing saved signature (stamp-only update). */
signatureImageBase64?: string;
/** Optional company stamp/seal; omitted = keep the existing saved stamp. */ /** Optional company stamp/seal; omitted = keep the existing saved stamp. */
stampImageBase64?: string; stampImageBase64?: string;
} }
@@ -46,12 +47,14 @@ export class SignaturesService {
const previousFileId = existing?.signatureFileId ?? null; const previousFileId = existing?.signatureFileId ?? null;
const previousStampFileId = existing?.stampFileId ?? null; const previousStampFileId = existing?.stampFileId ?? null;
const fileRecord = await this.filesService.upload({ const fileRecord = input.signatureImageBase64
resourceId: input.userId, ? await this.filesService.upload({
resource: 'saved_signatures', resourceId: input.userId,
code: 'signature', resource: 'saved_signatures',
file: this.toUploadFile('signature', input.userId, input.signatureImageBase64), code: 'signature',
}); file: this.toUploadFile('signature', input.userId, input.signatureImageBase64),
})
: null;
const stampRecord = input.stampImageBase64 const stampRecord = input.stampImageBase64
? await this.filesService.upload({ ? await this.filesService.upload({
@@ -65,13 +68,13 @@ export class SignaturesService {
const saved = await this.signaturesRepository.upsert({ const saved = await this.signaturesRepository.upsert({
userId: input.userId, userId: input.userId,
signerDisplayName: input.signerDisplayName, signerDisplayName: input.signerDisplayName,
signatureFileId: fileRecord.id, // Omitted image keeps whatever was saved before.
// Omitted stamp keeps whatever was saved before. ...(fileRecord ? { signatureFileId: fileRecord.id } : {}),
...(stampRecord ? { stampFileId: stampRecord.id } : {}), ...(stampRecord ? { stampFileId: stampRecord.id } : {}),
}); });
const staleIds = [ const staleIds = [
previousFileId !== fileRecord.id ? previousFileId : null, fileRecord && previousFileId !== fileRecord.id ? previousFileId : null,
stampRecord && previousStampFileId !== stampRecord.id stampRecord && previousStampFileId !== stampRecord.id
? previousStampFileId ? previousStampFileId
: null, : null,

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { FileSignature, Loader2 } from "lucide-react"; import { FileSignature, Loader2, Stamp } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -27,9 +27,9 @@ import {
} from "@edr/ui-common"; } from "@edr/ui-common";
/** /**
* Lets the signed-in user view and update the reusable signature stored on * Lets the signed-in user view and update the reusable signature and company
* their profile. The same signature is offered for approval when signing a * stamp stored on their profile — managed independently of each other. Both
* booking contract. * are offered when signing a booking contract.
*/ */
export function MySignatureCard() { export function MySignatureCard() {
const { user } = useAuth(); const { user } = useAuth();
@@ -38,42 +38,63 @@ export function MySignatureCard() {
); );
const saveMutation = useMutation(api.signatures.save.mutationOptions()); const saveMutation = useMutation(api.signatures.save.mutationOptions());
const [open, setOpen] = useState(false); const [signatureOpen, setSignatureOpen] = useState(false);
const [stampOpen, setStampOpen] = useState(false);
const [signerName, setSignerName] = useState(""); const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null); const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null); const [stampData, setStampData] = useState<string | null>(null);
const defaultName = const defaultName =
user?.name?.en || user?.username || user?.email || ""; user?.name?.en || user?.username || user?.email || "";
const savedName = saved?.signerDisplayName ?? defaultName;
const openDialog = () => { const openSignatureDialog = () => {
setSignerName(saved?.signerDisplayName ?? defaultName); setSignerName(savedName);
setSignatureData(null); setSignatureData(null);
setStampData(saved?.stampImageUrl ?? null); setSignatureOpen(true);
setOpen(true);
}; };
const save = () => { const saveSignature = () => {
if (!signatureData || !signerName.trim()) return; if (!signatureData || !signerName.trim()) return;
saveMutation.mutate( saveMutation.mutate(
{ {
signerDisplayName: signerName.trim(), signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData, signatureImageBase64: signatureData,
// Only send the stamp when it changed — omitted keeps the saved one. // Stamp untouched — it is managed by its own dialog.
...(stampData && stampData !== saved?.stampImageUrl
? { stampImageBase64: stampData }
: {}),
}, },
{ {
onSuccess: () => { onSuccess: () => {
toast.success("Signature saved"); toast.success("Signature saved");
setOpen(false); setSignatureOpen(false);
}, },
onError: () => toast.error("Failed to save signature"), onError: () => toast.error("Failed to save signature"),
}, },
); );
}; };
const openStampDialog = () => {
setStampData(saved?.stampImageUrl ?? null);
setStampOpen(true);
};
const saveStamp = () => {
if (!stampData) return;
saveMutation.mutate(
{
signerDisplayName: savedName || defaultName,
// Signature untouched — stamp-only update.
stampImageBase64: stampData,
},
{
onSuccess: () => {
toast.success("Stamp saved");
setStampOpen(false);
},
onError: () => toast.error("Failed to save stamp"),
},
);
};
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -85,47 +106,64 @@ export function MySignatureCard() {
This signature can be reused to sign booking contracts. This signature can be reused to sign booking contracts.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-6">
{isLoading ? ( {isLoading ? (
<div className="flex h-36 items-center justify-center"> <div className="flex h-36 items-center justify-center">
<Loader2 className="size-6 animate-spin text-primary" /> <Loader2 className="size-6 animate-spin text-primary" />
</div> </div>
) : saved?.signatureImageUrl ? (
<div className="space-y-2">
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</div>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <>
You have not saved a signature yet. <div className="space-y-2">
</p> {saved?.signatureImageUrl ? (
)} <>
{saved?.stampImageUrl && ( <div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<div className="space-y-2"> <img
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white"> src={saved.signatureImageUrl}
<img alt="My saved signature"
src={saved.stampImageUrl} className="mx-auto h-36 w-full object-contain"
alt="My saved company stamp" />
className="mx-auto h-24 w-full object-contain" </div>
/> <p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</div> </div>
<p className="text-xs text-muted-foreground">Company stamp</p>
</div> <div className="space-y-2">
{saved?.stampImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.stampImageUrl}
alt="My saved company stamp"
className="mx-auto h-24 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">Company stamp</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not uploaded a company stamp yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openStampDialog}>
<Stamp className="size-4" />
{saved?.stampImageUrl ? "Update stamp" : "Upload stamp"}
</Button>
</div>
</>
)} )}
<Button variant="outline" size="sm" onClick={openDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</CardContent> </CardContent>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={signatureOpen} onOpenChange={setSignatureOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Save your signature</DialogTitle> <DialogTitle>Save your signature</DialogTitle>
@@ -145,21 +183,16 @@ export function MySignatureCard() {
/> />
</div> </div>
<ContractSignaturePad onChange={setSignatureData} /> <ContractSignaturePad onChange={setSignatureData} />
<StampUpload
value={stampData}
onChange={setStampData}
description="Stored on your profile and prefilled when you sign contracts."
/>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}> <Button variant="outline" onClick={() => setSignatureOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button <Button
disabled={ disabled={
saveMutation.isPending || !signatureData || !signerName.trim() saveMutation.isPending || !signatureData || !signerName.trim()
} }
onClick={save} onClick={saveSignature}
> >
{saveMutation.isPending ? ( {saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
@@ -170,6 +203,39 @@ export function MySignatureCard() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={stampOpen} onOpenChange={setStampOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Company stamp</DialogTitle>
<DialogDescription>
Upload your official company stamp or seal as an image. It is
stored on your profile and applied next to your signature on
contracts.
</DialogDescription>
</DialogHeader>
<StampUpload
value={stampData}
onChange={setStampData}
description="Stored on your profile and prefilled when you sign contracts."
/>
<DialogFooter>
<Button variant="outline" onClick={() => setStampOpen(false)}>
Cancel
</Button>
<Button
disabled={saveMutation.isPending || !stampData}
onClick={saveStamp}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save stamp"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card> </Card>
); );
} }

View File

@@ -11,7 +11,8 @@ export interface SavedSignature {
export interface SaveSignaturePayload { export interface SaveSignaturePayload {
signerDisplayName: string; signerDisplayName: string;
signatureImageBase64: string; /** Omit to keep the existing saved signature (stamp-only update). */
signatureImageBase64?: string;
/** Omit to keep the existing saved stamp. */ /** Omit to keep the existing saved stamp. */
stampImageBase64?: string; stampImageBase64?: string;
} }

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { FileSignature, Loader2 } from "lucide-react"; import { FileSignature, Loader2, Stamp } from "lucide-react";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload"; import { StampUpload } from "@/components/contracts/StampUpload";
@@ -26,41 +26,56 @@ import {
} from "@edr/ui-common"; } from "@edr/ui-common";
/** /**
* Lets the signed-in customer view and update the reusable signature stored on * Lets the signed-in customer view and update the reusable signature and
* their profile. The same signature is offered for approval when signing a * company stamp stored on their profile — managed independently of each
* booking contract. * other. Both are offered when signing a booking contract.
*/ */
export function MySignatureCard() { export function MySignatureCard() {
const { user } = useAuth(); const { user } = useAuth();
const { data: saved, isPending } = useMySignature(); const { data: saved, isPending } = useMySignature();
const saveMutation = useSaveSignature(); const saveMutation = useSaveSignature();
const [open, setOpen] = useState(false); const [signatureOpen, setSignatureOpen] = useState(false);
const [stampOpen, setStampOpen] = useState(false);
const [signerName, setSignerName] = useState(""); const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null); const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null); const [stampData, setStampData] = useState<string | null>(null);
const defaultName = user?.name?.en || user?.username || user?.email || ""; const defaultName = user?.name?.en || user?.username || user?.email || "";
const savedName = saved?.signerDisplayName ?? defaultName;
const openDialog = () => { const openSignatureDialog = () => {
setSignerName(saved?.signerDisplayName ?? defaultName); setSignerName(savedName);
setSignatureData(null); setSignatureData(null);
setStampData(saved?.stampImageUrl ?? null); setSignatureOpen(true);
setOpen(true);
}; };
const save = () => { const saveSignature = () => {
if (!signatureData || !signerName.trim()) return; if (!signatureData || !signerName.trim()) return;
saveMutation.mutate( saveMutation.mutate(
{ {
signerDisplayName: signerName.trim(), signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData, signatureImageBase64: signatureData,
// Only send the stamp when it changed — omitted keeps the saved one. // Stamp untouched — it is managed by its own dialog.
...(stampData && stampData !== saved?.stampImageUrl
? { stampImageBase64: stampData }
: {}),
}, },
{ onSuccess: () => setOpen(false) }, { onSuccess: () => setSignatureOpen(false) },
);
};
const openStampDialog = () => {
setStampData(saved?.stampImageUrl ?? null);
setStampOpen(true);
};
const saveStamp = () => {
if (!stampData) return;
saveMutation.mutate(
{
signerDisplayName: savedName || defaultName,
// Signature untouched — stamp-only update.
stampImageBase64: stampData,
},
{ onSuccess: () => setStampOpen(false) },
); );
}; };
@@ -75,47 +90,64 @@ export function MySignatureCard() {
Reused to approve and sign booking contracts. Reused to approve and sign booking contracts.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-6">
{isPending ? ( {isPending ? (
<div className="flex h-36 items-center justify-center"> <div className="flex h-36 items-center justify-center">
<Loader2 className="size-6 animate-spin text-primary" /> <Loader2 className="size-6 animate-spin text-primary" />
</div> </div>
) : saved?.signatureImageUrl ? (
<div className="flex flex-col gap-2">
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</div>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <>
You have not saved a signature yet. <div className="flex flex-col gap-2">
</p> {saved?.signatureImageUrl ? (
)} <>
{saved?.stampImageUrl && ( <div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<div className="flex flex-col gap-2"> <img
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white"> src={saved.signatureImageUrl}
<img alt="My saved signature"
src={saved.stampImageUrl} className="mx-auto h-36 w-full object-contain"
alt="My saved company stamp" />
className="mx-auto h-24 w-full object-contain" </div>
/> <p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</div> </div>
<p className="text-xs text-muted-foreground">Company stamp</p>
</div> <div className="flex flex-col gap-2">
{saved?.stampImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.stampImageUrl}
alt="My saved company stamp"
className="mx-auto h-24 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">Company stamp</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not uploaded a company stamp yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openStampDialog}>
<Stamp className="size-4" />
{saved?.stampImageUrl ? "Update stamp" : "Upload stamp"}
</Button>
</div>
</>
)} )}
<Button variant="outline" size="sm" onClick={openDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</CardContent> </CardContent>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={signatureOpen} onOpenChange={setSignatureOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Save your signature</DialogTitle> <DialogTitle>Save your signature</DialogTitle>
@@ -135,21 +167,16 @@ export function MySignatureCard() {
/> />
</div> </div>
<ContractSignaturePad onChange={setSignatureData} /> <ContractSignaturePad onChange={setSignatureData} />
<StampUpload
value={stampData}
onChange={setStampData}
description="Stored on your profile and prefilled when you sign contracts."
/>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}> <Button variant="outline" onClick={() => setSignatureOpen(false)}>
Cancel Cancel
</Button> </Button>
<Button <Button
disabled={ disabled={
saveMutation.isPending || !signatureData || !signerName.trim() saveMutation.isPending || !signatureData || !signerName.trim()
} }
onClick={save} onClick={saveSignature}
> >
{saveMutation.isPending ? ( {saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
@@ -160,6 +187,39 @@ export function MySignatureCard() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={stampOpen} onOpenChange={setStampOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Company stamp</DialogTitle>
<DialogDescription>
Upload your official company stamp or seal as an image. It is
stored on your profile and applied next to your signature on
contracts.
</DialogDescription>
</DialogHeader>
<StampUpload
value={stampData}
onChange={setStampData}
description="Stored on your profile and prefilled when you sign contracts."
/>
<DialogFooter>
<Button variant="outline" onClick={() => setStampOpen(false)}>
Cancel
</Button>
<Button
disabled={saveMutation.isPending || !stampData}
onClick={saveStamp}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save stamp"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card> </Card>
); );
} }

View File

@@ -21,7 +21,9 @@ import AuthShell from "@/components/auth/AuthShell";
import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep"; import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep";
import PasswordChecklist from "@/components/auth/PasswordChecklist"; import PasswordChecklist from "@/components/auth/PasswordChecklist";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { StampUpload } from "@/components/contracts/StampUpload";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { signaturesService } from "@/services/signatures.service";
import { import {
confirmPasswordField, confirmPasswordField,
passwordField, passwordField,
@@ -59,6 +61,10 @@ export default function SignupPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { signup } = useAuth(); const { signup } = useAuth();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Company stamp image, captured at signup and stored on the new profile so
// it is ready when the customer signs their first contract. Optional —
// individuals without a stamp can add one later from Settings.
const [stampData, setStampData] = useState<string | null>(null);
// Two-stage signup: fill the form, then a mandatory OTP challenge before the // Two-stage signup: fill the form, then a mandatory OTP challenge before the
// account is actually created. The code goes to BOTH the email and phone just // account is actually created. The code goes to BOTH the email and phone just
@@ -179,6 +185,18 @@ export default function SignupPage() {
}; };
const result = await signup(payload); const result = await signup(payload);
if (result.success) { if (result.success) {
// Signup logs the user in, so the stamp can land on their profile
// right away. Non-fatal — it can also be added later from Settings.
if (stampData) {
try {
await signaturesService.saveMySignature({
signerDisplayName: payload.name.en,
stampImageBase64: stampData,
});
} catch {
// Ignore — account exists; the stamp can be re-uploaded later.
}
}
navigate("/portal"); navigate("/portal");
} else { } else {
setOtpError(result.error.message); setOtpError(result.error.message);
@@ -269,6 +287,13 @@ export default function SignupPage() {
{...register("confirmPassword")} {...register("confirmPassword")}
/> />
<StampUpload
value={stampData}
onChange={setStampData}
label="Company stamp (optional)"
description="Stored on your profile and applied next to your signature when you sign contracts."
/>
{error ? ( {error ? (
<Alert <Alert
color="red" color="red"

View File

@@ -10,7 +10,8 @@ export interface SavedSignature {
export interface SaveSignaturePayload { export interface SaveSignaturePayload {
signerDisplayName: string; signerDisplayName: string;
signatureImageBase64: string; /** Omit to keep the existing saved signature (stamp-only update). */
signatureImageBase64?: string;
/** Omit to keep the existing saved stamp. */ /** Omit to keep the existing saved stamp. */
stampImageBase64?: string; stampImageBase64?: string;
} }