mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 00:08:18 +00:00
configer the stamp
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user