- {activeAppointment.user?.full_name?.charAt(0) || '?'}
+ {initials}
{/if}
diff --git a/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte
new file mode 100644
index 0000000..b911baf
--- /dev/null
+++ b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/frontend/src/lib/components/ui/avatar/avatar-image.svelte b/frontend/src/lib/components/ui/avatar/avatar-image.svelte
new file mode 100644
index 0000000..7ccc3ce
--- /dev/null
+++ b/frontend/src/lib/components/ui/avatar/avatar-image.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/frontend/src/lib/components/ui/avatar/avatar.svelte b/frontend/src/lib/components/ui/avatar/avatar.svelte
new file mode 100644
index 0000000..40feecd
--- /dev/null
+++ b/frontend/src/lib/components/ui/avatar/avatar.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/frontend/src/lib/components/ui/avatar/index.ts b/frontend/src/lib/components/ui/avatar/index.ts
new file mode 100644
index 0000000..9585f8a
--- /dev/null
+++ b/frontend/src/lib/components/ui/avatar/index.ts
@@ -0,0 +1,13 @@
+import Root from './avatar.svelte';
+import Image from './avatar-image.svelte';
+import Fallback from './avatar-fallback.svelte';
+
+export {
+ Root,
+ Image,
+ Fallback,
+ //
+ Root as Avatar,
+ Image as AvatarImage,
+ Fallback as AvatarFallback
+};
diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte
index 2105474..9717657 100644
--- a/frontend/src/lib/components/ui/button/button.svelte
+++ b/frontend/src/lib/components/ui/button/button.svelte
@@ -1,82 +1,124 @@
-{#if href}
-
- {@render children?.()}
-
-{:else}
-
-{/if}
+
+
{
+ onclick?.(e);
+
+ if (type === undefined) return;
+
+ if (onClickPromise) {
+ loading = true;
+
+ await onClickPromise(e);
+
+ loading = false;
+ }
+ }}
+>
+ {#if type !== undefined && loading}
+
+
+
+ Loading
+ {/if}
+ {@render children?.()}
+
diff --git a/frontend/src/lib/components/ui/button/index.ts b/frontend/src/lib/components/ui/button/index.ts
index fb585d7..29e85e3 100644
--- a/frontend/src/lib/components/ui/button/index.ts
+++ b/frontend/src/lib/components/ui/button/index.ts
@@ -2,8 +2,11 @@ import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
- buttonVariants,
-} from "./button.svelte";
+ type AnchorElementProps,
+ type ButtonElementProps,
+ type ButtonPropsWithoutHTML,
+ buttonVariants
+} from './button.svelte';
export {
Root,
@@ -14,4 +17,7 @@ export {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
+ type AnchorElementProps,
+ type ButtonElementProps,
+ type ButtonPropsWithoutHTML
};
diff --git a/frontend/src/lib/components/ui/dialog/dialog-close.svelte b/frontend/src/lib/components/ui/dialog/dialog-close.svelte
index 840b2f6..e8a96a7 100644
--- a/frontend/src/lib/components/ui/dialog/dialog-close.svelte
+++ b/frontend/src/lib/components/ui/dialog/dialog-close.svelte
@@ -1,5 +1,5 @@
diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte
index a647d56..c3f06bf 100644
--- a/frontend/src/lib/components/ui/dialog/dialog-content.svelte
+++ b/frontend/src/lib/components/ui/dialog/dialog-content.svelte
@@ -1,21 +1,21 @@
@@ -25,15 +25,15 @@
bind:ref
data-slot="dialog-content"
class={cn(
- "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+ 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...restProps}
>
{@render children?.()}
- {#if showCloseButton}
+ {#if !hideClose}
Close
diff --git a/frontend/src/lib/components/ui/dialog/dialog-description.svelte b/frontend/src/lib/components/ui/dialog/dialog-description.svelte
index 3845023..c658420 100644
--- a/frontend/src/lib/components/ui/dialog/dialog-description.svelte
+++ b/frontend/src/lib/components/ui/dialog/dialog-description.svelte
@@ -1,6 +1,6 @@
diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts
index dce1d9d..d9e5fb8 100644
--- a/frontend/src/lib/components/ui/dialog/index.ts
+++ b/frontend/src/lib/components/ui/dialog/index.ts
@@ -1,13 +1,13 @@
-import { Dialog as DialogPrimitive } from "bits-ui";
+import { Dialog as DialogPrimitive } from 'bits-ui';
-import Title from "./dialog-title.svelte";
-import Footer from "./dialog-footer.svelte";
-import Header from "./dialog-header.svelte";
-import Overlay from "./dialog-overlay.svelte";
-import Content from "./dialog-content.svelte";
-import Description from "./dialog-description.svelte";
-import Trigger from "./dialog-trigger.svelte";
-import Close from "./dialog-close.svelte";
+import Title from './dialog-title.svelte';
+import Footer from './dialog-footer.svelte';
+import Header from './dialog-header.svelte';
+import Overlay from './dialog-overlay.svelte';
+import Content from './dialog-content.svelte';
+import Description from './dialog-description.svelte';
+import Trigger from './dialog-trigger.svelte';
+import Close from './dialog-close.svelte';
const Root = DialogPrimitive.Root;
const Portal = DialogPrimitive.Portal;
@@ -33,5 +33,5 @@ export {
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
- Close as DialogClose,
+ Close as DialogClose
};
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-cancel.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-cancel.svelte
new file mode 100644
index 0000000..00fe531
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-cancel.svelte
@@ -0,0 +1,34 @@
+
+
+
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-controls.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-controls.svelte
new file mode 100644
index 0000000..9adfaed
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-controls.svelte
@@ -0,0 +1,19 @@
+
+
+
+ {@render children?.()}
+
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-crop.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-crop.svelte
new file mode 100644
index 0000000..5a1e6c1
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-crop.svelte
@@ -0,0 +1,34 @@
+
+
+
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-cropper.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-cropper.svelte
new file mode 100644
index 0000000..1092edc
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-cropper.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-dialog.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-dialog.svelte
new file mode 100644
index 0000000..80322bd
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-dialog.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+
+ {@render children?.()}
+
+
+
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-preview.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-preview.svelte
new file mode 100644
index 0000000..3361dfd
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-preview.svelte
@@ -0,0 +1,25 @@
+
+
+{#if child}
+ {@render child({ src: previewState.rootState.src })}
+{:else}
+
+
+
+
+ Upload image
+
+
+{/if}
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper-upload-trigger.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper-upload-trigger.svelte
new file mode 100644
index 0000000..a57c7c7
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/image-cropper-upload-trigger.svelte
@@ -0,0 +1,12 @@
+
+
+
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte b/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte
new file mode 100644
index 0000000..9043f6c
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte
@@ -0,0 +1,43 @@
+
+
+{@render children?.()}
+ {
+ const file = e.currentTarget.files?.[0];
+ if (!file) return;
+ rootState.onUpload(file);
+ // reset so that we can reupload the same file
+ (e.target! as HTMLInputElement).value = '';
+ }}
+ type="file"
+ {id}
+ style="display: none;"
+/>
diff --git a/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte.ts b/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte.ts
new file mode 100644
index 0000000..de91870
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/image-cropper.svelte.ts
@@ -0,0 +1,167 @@
+import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
+import { Context } from 'runed';
+import type { CropArea, DispatchEvents } from 'svelte-easy-crop';
+import { getCroppedImg } from './utils';
+
+// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#supported_image_formats
+export const VALID_IMAGE_TYPES = [
+ 'image/apng',
+ 'image/avif',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/png',
+ 'image/svg+xml',
+ 'image/webp'
+];
+
+export type ImageCropperRootProps = WritableBoxedValues<{
+ src: string;
+}> &
+ ReadableBoxedValues<{
+ id: string;
+ onCropped: (url: string) => void;
+ onUnsupportedFile: (file: File) => void;
+ }>;
+
+class ImageCropperRootState {
+ #createdUrls = $state([]);
+ open = $state(false);
+ tempUrl = $state();
+ pixelCrop = $state();
+
+ constructor(readonly opts: ImageCropperRootProps) {
+ this.onUpload = this.onUpload.bind(this);
+ this.onCancel = this.onCancel.bind(this);
+ this.onCrop = this.onCrop.bind(this);
+ this.dispose = this.dispose.bind(this);
+ }
+
+ onUpload(file: File) {
+ if (!VALID_IMAGE_TYPES.includes(file.type)) {
+ this.opts.onUnsupportedFile.current(file);
+ return;
+ }
+
+ this.tempUrl = URL.createObjectURL(file);
+ this.#createdUrls.push(this.tempUrl);
+ this.open = true;
+ }
+
+ onCancel() {
+ this.tempUrl = undefined;
+ this.open = false;
+ this.pixelCrop = undefined;
+ }
+
+ async onCrop() {
+ if (!this.pixelCrop || !this.tempUrl) return;
+
+ this.opts.src.current = await getCroppedImg(this.tempUrl, this.pixelCrop);
+
+ this.open = false;
+
+ this.opts.onCropped.current(this.opts.src.current);
+ }
+
+ get src() {
+ return this.opts.src.current;
+ }
+
+ get id() {
+ return this.opts.id.current;
+ }
+
+ dispose() {
+ for (const url of this.#createdUrls) {
+ URL.revokeObjectURL(url);
+ }
+ }
+}
+
+export type ImageCropperTriggerProps = ReadableBoxedValues<{
+ id?: string;
+}>;
+
+class ImageCropperTriggerState {
+ constructor(readonly rootState: ImageCropperRootState) {}
+}
+
+class ImageCropperPreviewState {
+ constructor(readonly rootState: ImageCropperRootState) {}
+}
+
+class ImageCropperDialogState {
+ constructor(readonly rootState: ImageCropperRootState) {}
+}
+
+class ImageCropperCropperState {
+ constructor(readonly rootState: ImageCropperRootState) {
+ this.onCropComplete = this.onCropComplete.bind(this);
+ }
+
+ onCropComplete(e: DispatchEvents['cropcomplete']) {
+ this.rootState.pixelCrop = e.pixels;
+ }
+}
+
+class ImageCropperCropState {
+ constructor(readonly rootState: ImageCropperRootState) {
+ this.onclick = this.onclick.bind(this);
+ }
+
+ onclick() {
+ this.rootState.onCrop();
+ }
+}
+
+class ImageCropperCancelState {
+ constructor(readonly rootState: ImageCropperRootState) {
+ this.onclick = this.onclick.bind(this);
+ }
+
+ onclick() {
+ this.rootState.onCancel();
+ }
+}
+
+const ImageCropperRootContext = new Context('ImageCropper.Root');
+
+export const useImageCropperRoot = (props: ImageCropperRootProps) => {
+ return ImageCropperRootContext.set(new ImageCropperRootState(props));
+};
+
+export const useImageCropperTrigger = () => {
+ const rootState = ImageCropperRootContext.get();
+
+ return new ImageCropperTriggerState(rootState);
+};
+
+export const useImageCropperPreview = () => {
+ const rootState = ImageCropperRootContext.get();
+
+ return new ImageCropperPreviewState(rootState);
+};
+
+export const useImageCropperDialog = () => {
+ const rootState = ImageCropperRootContext.get();
+
+ return new ImageCropperDialogState(rootState);
+};
+
+export const useImageCropperCropper = () => {
+ const rootState = ImageCropperRootContext.get();
+
+ return new ImageCropperCropperState(rootState);
+};
+
+export const useImageCropperCrop = () => {
+ const rootState = ImageCropperRootContext.get();
+
+ return new ImageCropperCropState(rootState);
+};
+
+export const useImageCropperCancel = () => {
+ const rootState = ImageCropperRootContext.get();
+
+ return new ImageCropperCancelState(rootState);
+};
diff --git a/frontend/src/lib/components/ui/image-cropper/index.ts b/frontend/src/lib/components/ui/image-cropper/index.ts
new file mode 100644
index 0000000..d81fbcd
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/index.ts
@@ -0,0 +1,13 @@
+import Root from './image-cropper.svelte';
+import UploadTrigger from './image-cropper-upload-trigger.svelte';
+import Preview from './image-cropper-preview.svelte';
+import Dialog from './image-cropper-dialog.svelte';
+import Cropper from './image-cropper-cropper.svelte';
+import Controls from './image-cropper-controls.svelte';
+import Crop from './image-cropper-crop.svelte';
+import Cancel from './image-cropper-cancel.svelte';
+import { getFileFromUrl } from './utils';
+
+export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, getFileFromUrl };
+
+export type * from './types';
diff --git a/frontend/src/lib/components/ui/image-cropper/types.ts b/frontend/src/lib/components/ui/image-cropper/types.ts
new file mode 100644
index 0000000..10659dc
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/types.ts
@@ -0,0 +1,44 @@
+import type {
+ AvatarRootProps,
+ DialogContentProps,
+ WithChildren,
+ WithoutChild,
+ WithoutChildren
+} from 'bits-ui';
+import type { Snippet } from 'svelte';
+import type { CropperProps } from 'svelte-easy-crop';
+import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
+
+export type ImageCropperRootPropsWithoutHTML = WithChildren<{
+ id?: string;
+ src?: string;
+ onCropped?: (url: string) => void;
+ onUnsupportedFile?: (file: File) => void;
+}>;
+
+export type ImageCropperRootProps = ImageCropperRootPropsWithoutHTML & HTMLInputAttributes;
+
+export type ImageCropperDialogProps = DialogContentProps;
+
+export type ImageCropperCropperProps = Omit, 'oncropcomplete' | 'image'>;
+
+export type ImageCropperControlsWithoutHTML = WithChildren<{
+ ref?: HTMLDivElement | null;
+}>;
+
+export type ImageCropperControlsProps = ImageCropperControlsWithoutHTML &
+ WithoutChildren>;
+
+export type ImageCropperPreviewPropsWithoutHTML = {
+ child?: Snippet<[{ src: string }]>;
+};
+
+export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML &
+ WithoutChild;
+
+export type ImageCropperUploadTriggerPropsWithoutHTML = WithChildren<{
+ ref?: HTMLLabelElement | null;
+}>;
+
+export type ImageCropperUploadTriggerProps = ImageCropperUploadTriggerPropsWithoutHTML &
+ WithoutChildren>;
diff --git a/frontend/src/lib/components/ui/image-cropper/utils.ts b/frontend/src/lib/components/ui/image-cropper/utils.ts
new file mode 100644
index 0000000..44c02f3
--- /dev/null
+++ b/frontend/src/lib/components/ui/image-cropper/utils.ts
@@ -0,0 +1,85 @@
+import type { CropArea } from 'svelte-easy-crop';
+
+export const getFileFromUrl = async (url: string, fileName = 'cropped.png'): Promise => {
+ // Fetch the file data from the URL
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch resource: ${response.status} ${response.statusText}`);
+ }
+
+ // Convert the response into a Blob
+ const blob = await response.blob();
+
+ // Create and return a File. You can set a custom type if needed.
+ return new File([blob], fileName, { type: blob.type });
+};
+
+const createImage = (url: string): Promise => {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ image.addEventListener('load', () => resolve(image));
+ image.addEventListener('error', (error) => reject(error));
+ image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox
+ image.src = url;
+ });
+};
+
+const getRadianAngle = (degreeValue: number) => {
+ return (degreeValue * Math.PI) / 180;
+};
+
+/** Gets the cropped image from the src using the cropped area
+ *
+ * @param imageSrc
+ * @param pixelCrop
+ * @param rotation
+ * @returns
+ */
+export const getCroppedImg = async (
+ imageSrc: string,
+ pixelCrop: CropArea,
+ rotation = 0
+): Promise => {
+ const image = await createImage(imageSrc);
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+
+ if (!ctx) {
+ throw new Error('Error getting 2d rendering context');
+ }
+
+ const maxSize = Math.max(image.width, image.height);
+ const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
+
+ // set each dimensions to double largest dimension to allow for a safe area for the
+ // image to rotate in without being clipped by canvas context
+ canvas.width = safeArea;
+ canvas.height = safeArea;
+
+ // translate canvas context to a central location on image to allow rotating around the center.
+ ctx.translate(safeArea / 2, safeArea / 2);
+ ctx.rotate(getRadianAngle(rotation));
+ ctx.translate(-safeArea / 2, -safeArea / 2);
+
+ // draw rotated image and store data.
+ ctx.drawImage(image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5);
+ const data = ctx.getImageData(0, 0, safeArea, safeArea);
+
+ // set canvas width to final desired crop size - this will clear existing context
+ canvas.width = pixelCrop.width;
+ canvas.height = pixelCrop.height;
+
+ // paste generated rotate image with correct offsets for x,y crop values.
+ ctx.putImageData(
+ data,
+ Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
+ Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
+ );
+
+ return new Promise((resolve) => {
+ canvas.toBlob((file) => {
+ resolve(URL.createObjectURL(file!));
+ }, 'image/png');
+ });
+};
diff --git a/frontend/src/lib/stores/auth.svelte.ts b/frontend/src/lib/stores/auth.svelte.ts
index aa6631b..423828a 100644
--- a/frontend/src/lib/stores/auth.svelte.ts
+++ b/frontend/src/lib/stores/auth.svelte.ts
@@ -182,9 +182,9 @@ class AuthStore {
return;
}
- // Refresh if token expires in less than 2 weeks
- const threeDays = 2 * 7 * 24 * 60 * 60 * 1000;
- if (decoded.exp * 1000 - Date.now() < threeDays) {
+ // Refresh if token expires in less than 14 days
+ const fourteenDays = 14 * 24 * 60 * 60 * 1000;
+ if (decoded.exp * 1000 - Date.now() < fourteenDays) {
try {
const response = await fetch('/api/refresh-token', {
method: 'POST',
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
index 55b3a91..97525cc 100644
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -1,13 +1,13 @@
-import { clsx, type ClassValue } from "clsx";
-import { twMerge } from "tailwind-merge";
+import { type ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type WithoutChild = T extends { child?: any } ? Omit : T;
+export type WithoutChild = T extends { child?: any } ? Omit : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type WithoutChildren = T extends { children?: any } ? Omit : T;
+export type WithoutChildren = T extends { children?: any } ? Omit : T;
export type WithoutChildrenOrChild = WithoutChildren>;
export type WithElementRef = T & { ref?: U | null };
diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte
index 4a354b0..e66b6dc 100644
--- a/frontend/src/routes/account/+page.svelte
+++ b/frontend/src/routes/account/+page.svelte
@@ -28,6 +28,8 @@
import { Separator } from '$lib/components/ui/separator';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
+ import * as Dialog from '$lib/components/ui/dialog';
+ import Cropper from 'svelte-easy-crop';
// =============== Auth & Page State ===============
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
@@ -80,6 +82,96 @@
let userData = $state(null);
let loadingUser = $state(true);
let stamps = $state(0);
+ let uploadingPic = $state(false);
+
+ // Image cropper state
+ let cropDialogOpen = $state(false);
+ let cropImageUrl = $state('');
+ let cropArea = $state<{ x: number; y: number; width: number; height: number } | null>(null);
+ let crop = $state({ x: 0, y: 0 });
+ let zoom = $state(1);
+ let previewUrl = $state('');
+
+ function handleFileSelect(e: Event) {
+ const input = e.target as HTMLInputElement;
+ const file = input.files?.[0];
+ if (file) {
+ cropImageUrl = URL.createObjectURL(file);
+ cropDialogOpen = true;
+ }
+ }
+
+ async function handleCropSave() {
+ if (!cropArea || !cropImageUrl) return;
+
+ const img = new Image();
+ img.src = cropImageUrl;
+ await new Promise(resolve => { img.onload = resolve; });
+
+ const canvas = document.createElement('canvas');
+ canvas.width = 350;
+ canvas.height = 350;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ ctx.drawImage(
+ img,
+ cropArea.x, cropArea.y, cropArea.width, cropArea.height,
+ 0, 0, 350, 350
+ );
+
+ canvas.toBlob((blob) => {
+ if (!blob) return;
+
+ const url = URL.createObjectURL(blob);
+ previewUrl = url;
+
+ handleProfilePicUpload(blob).then(() => {
+ URL.revokeObjectURL(cropImageUrl);
+ cropImageUrl = '';
+ cropArea = null;
+ cropDialogOpen = false;
+ });
+ }, 'image/jpeg', 0.9);
+ }
+
+ function handleCropCancel() {
+ if (cropImageUrl) {
+ URL.revokeObjectURL(cropImageUrl);
+ }
+ cropImageUrl = '';
+ cropArea = null;
+ cropDialogOpen = false;
+ }
+
+ async function handleProfilePicUpload(blob: Blob) {
+ uploadingPic = true;
+ try {
+ const formData = new FormData();
+ formData.append('file', blob, 'profile.jpg');
+ const uploadResponse = await fetch('/api/user/profile-picture', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${authStore.currentToken}`
+ },
+ body: formData
+ });
+ if (uploadResponse.ok) {
+ const data = await uploadResponse.json();
+ if (userData) {
+ userData.profilePicUrl = data.url;
+ }
+ toast.success('Profile picture updated');
+ } else {
+ toast.error('Failed to upload profile picture');
+ }
+ } catch (err) {
+ console.error('Upload error:', err);
+ toast.error('Failed to upload profile picture');
+ } finally {
+ uploadingPic = false;
+ }
+ }
// =============== Phone Edit Mode ===============
let editingPhone = $state(false);
@@ -607,6 +699,66 @@
Your personal details and account information
+ {#if userData}
+ {@const initials = userData.firstName && userData.lastName ? userData.firstName.split(' ').map(n => n[0]).join('') + userData.lastName.split(' ').map(n => n[0]).join('') : ''}
+ {@const hasImage = !!userData.profilePicUrl || !!previewUrl}
+ {@const displayUrl = previewUrl || userData.profilePicUrl || ''}
+
+ {#if hasImage || initials}
+ {#if hasImage}
+

+ {:else}
+
+ {initials}
+
+ {/if}
+ {:else}
+
+ {/if}
+
+
+
+ {/if}
+
+
+
+
+ Crop Profile Picture
+
+
+ {#if cropImageUrl}
+ {
+ cropArea = e.pixels;
+ }}
+ />
+ {/if}
+
+
+
+
+
+
+
+
{#if loadingUser}
{#each Array(6) as _, i (i)}
diff --git a/frontend/src/routes/contact/+page.svelte b/frontend/src/routes/contact/+page.svelte
index cd81707..97b70fa 100644
--- a/frontend/src/routes/contact/+page.svelte
+++ b/frontend/src/routes/contact/+page.svelte
@@ -1,15 +1,62 @@
Contact Me
-
+ {#if loading}
+
+ {:else if contact}
+
+ {:else}
+
+ {/if}
diff --git a/frontend/src/routes/schedule/+page.svelte b/frontend/src/routes/schedule/+page.svelte
new file mode 100644
index 0000000..d531499
--- /dev/null
+++ b/frontend/src/routes/schedule/+page.svelte
@@ -0,0 +1,167 @@
+
+
+{#if pageState === 'loading'}
+
+{:else if pageState === 'unauthorized'}
+
+
Please log in to view your schedule.
+
+{:else}
+
+
My Schedule
+
+ {#if loading}
+
Loading...
+ {:else if bookings.length === 0}
+
+
+ No Upcoming Appointments
+ You don't have any upcoming appointments.
+
+
+
+
+
+ {:else}
+
+ {#each bookings as booking (booking.id)}
+
+
+
+
+ {new SvelteDate(booking.start_time).toLocaleDateString('en-GB', {
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric'
+ })}
+
+
+ {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
+ hour: 'numeric',
+ minute: '2-digit'
+ })}
+ {#if booking.duration_minutes}
+ · {booking.duration_minutes} min
+ {/if}
+
+
+
+ {booking.status}
+
+
+
+
+
+ {#if booking.services && booking.services.length > 0}
+
+ {booking.services.map((s: any) => s.service_name).join(', ')}
+
+ {/if}
+ {#if booking.total_amount}
+
£{booking.total_amount.toFixed(2)}
+ {/if}
+
+
+
+
+
+
+
+ {/each}
+
+ {/if}
+
+{/if}
+
+
diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql
index ef67384..5584be2 100644
--- a/init-scripts/init-script.sql
+++ b/init-scripts/init-script.sql
@@ -51,6 +51,8 @@ CREATE OR REPLACE FUNCTION generate_service_id() RETURNS CHAR(12) AS $$ SELECT g
CREATE OR REPLACE FUNCTION generate_booking_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('bookings'); $$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_payment_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('payments'); $$ LANGUAGE sql;
+CREATE OR REPLACE FUNCTION generate_verification_code() RETURNS CHAR(12) AS $$ SELECT substr(encode(gen_random_bytes(6), 'hex'), 1, 12); $$ LANGUAGE sql;
+
CREATE OR REPLACE FUNCTION generate_referral_code()
RETURNS CHAR(12) AS $$
DECLARE
@@ -107,6 +109,8 @@ CREATE TABLE users (
-- Audit fields
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ -- Deposit tracking: remaining deposits needed (0-3). Reduces by 1 when booking with payment completes.
+ deposits_required INT NOT NULL DEFAULT 3,
-- staff fields
notes TEXT
);
@@ -134,7 +138,7 @@ CREATE TYPE verification_purpose AS ENUM ('email_verify', 'password_reset');
CREATE TABLE verification_codes (
id BIGSERIAL PRIMARY KEY,
- code CHAR(32) NOT NULL UNIQUE,
+ code CHAR(12) NOT NULL UNIQUE DEFAULT generate_verification_code(),
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
purpose verification_purpose NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
@@ -273,7 +277,8 @@ CREATE UNIQUE INDEX idx_group_application_week ON exceptional_group_applications
-- =======================================
-- PAYMENTS TABLE
-- =======================================
-
+-- PAYMENTS TABLE
+-- =======================================
CREATE SEQUENCE invoice_number_seq
START WITH 1
INCREMENT BY 1
@@ -360,7 +365,7 @@ INSERT INTO business_settings (
'https://www.website.co.uk'
);
-CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim');
+CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'no_deposit', 'deposit_paid');
CREATE TABLE admin_notifications (
id SERIAL PRIMARY KEY,
diff --git a/obsidian/Crussell/Crussell Nails.md b/obsidian/Crussell/Crussell Nails.md
index f1aca00..93cf53d 100644
--- a/obsidian/Crussell/Crussell Nails.md
+++ b/obsidian/Crussell/Crussell Nails.md
@@ -46,8 +46,8 @@
- [x] `/api/admin/bookings/{id}/progress` - Progress booking status
- [x] `/api/admin/bookings/{id}/confirm` - Confirm booking
- [x] `/api/admin/bookings/{id}/cancel` - Cancel booking
-- [ ] **In-progress auto-infer** - Status should auto-set based on time
-- [ ] **Begin button on Today** - Manual start for early arrivals (gray out if >3hrs away)
+- [x] **In-progress auto-infer** - Status auto-sets based on time (confirmed → in_progress → completed)
+- [x] **Auto-complete** - Bookings auto-complete when duration elapses
#### Admin Endpoints
- [x] `/api/admin/services` - Create, delete, list, toggle
@@ -69,11 +69,24 @@
#### User Endpoints
- [x] `/api/user/profile` - GET, PUT
+- [x] `/api/user/profile-picture` - POST upload profile picture (separate bucket)
- [x] `/api/user/account` - DELETE (GDPR compliant)
- [x] `/api/user/loyalty` - GET loyalty stamps
+- [x] `/api/contact` - Public endpoint returning first admin's contact info (name, phone, email, profilePicUrl)
- [ ] **GDPR data export** - `export_all_user_data()` exists but not wired to endpoint
- [ ] **Tax data export** - Admin endpoint for tax-software-compatible format
+#### Deposits System (Simplified)
+- [x] `users.deposits_required` INT DEFAULT 3
+- [x] 48h notice required when `deposits_required > 0`
+- [x] Reduces by 1 when booking completes with payment
+- [x] Increases by 3 on <12h cancellation (bad behavior)
+- [ ] Frontend display of deposits_required
+
+#### CalDAV Contact Sync
+- [x] Profile photos synced to CardDAV contacts (PHOTO field in vCard)
+- [x] Auto-updates when profile is changed
+
#### Not Yet Wired
- [ ] Social auth (`handlers/auth/social.go` exists, not imported)
- [ ] Analytics (`handlers/admin/analytics.go` exists, not imported)
@@ -91,13 +104,15 @@
#### Core Pages
- [x] Home (`/`)
- [x] Prices (`/prices`)
-- [x] Contact (`/contact`)
+- [x] Contact (`/contact`) - Dynamic, fetches from `/api/contact`
- [x] Book (`/book`) - Full wizard with service selection, date/time, customer details
- [x] Portfolio (`/portfolio`) - S3/R2 storage with tag filtering, category filters, pagination, ?img= featured image, admin upload
-- [x] Today (`/today`) - Admin only, real-time schedule view
-- [x] Account (`/account`)
+- [x] Today (`/today`) - Admin only, real-time schedule view with auto-status transitions
+- [x] Schedule (`/schedule`) - User's upcoming bookings with .ics export
+- [x] Account (`/account`) - Profile management, profile picture upload with cropper
- [x] Login (`/login`)
- [x] Manage (`/manage`)
+- [x] Manage (`/manage`)
#### Admin Dashboard (`/admin`)
- [x] Auth guard with role check
@@ -149,6 +164,7 @@
- [x] CardDAV sync for contacts (SabreDAV)
- [x] CalDAV ready
+- [x] Profile pics bucket - separate bucket `crussell-profile-pics` for user profile pictures
- [ ] Email/SMS reminders - not yet implemented
- [ ] Square payment - placeholder only
- [x] S3/R2 image hosting - Rustfs for dev, Cloudflare R2 for prod via build tags
@@ -356,17 +372,21 @@ admin_notification_reason: pending_booking | cancelled_booking | rescheduled_boo
| Task | Description | Files Affected |
| ------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------- |
| **Customer booking submit** | `submitBooking()` at line 600 only logs, needs `POST /api/bookings` | `frontend/src/lib/components/booking/BookingFlow.svelte` |
-| **Remove console.logs** | Debug logs left in: `BookingFlow.svelte:600`, `BookingCreateModal.svelte:224` | Frontend components |
+| **Remove console.logs** | Debug logs left in: `BookingFlow.svelte:600` | Frontend components |
| **Guest user endpoint** | Create `/api/users/guest` for walk-in bookings | `backend/handlers/user/` (new file) |
-| **In-progress auto-infer** | Auto-set `in_progress` status based on time | Backend booking logic |
+| ~~In-progress auto-infer~~ | ~~Auto-set `in_progress` status based on time~~ DONE | Backend booking logic |
+| ~~Auto-complete~~ | ~~Auto-complete bookings when duration elapses~~ DONE | Backend today handlers |
+| ~~Profile picture upload~~ | ~~Upload with cropper to separate bucket, sync to CalDAV~~ DONE | Backend + Account page |
+| ~~Contact page dynamic~~ | ~~Fetch from `/api/contact` using first admin~~ DONE | Backend + Contact page |
+| ~~Simplified deposits~~ | ~~`deposits_required` INT on users, 48h check, reduce on payment~~ DONE | Backend booking logic |
| **Begin button (Today)** | Manual start for early arrivals, gray out if >3hrs away | `CurrentAppointment.svelte` + backend |
-| **One-off custom services** | Admin creates custom service for single booking without adding to main list | Backend + frontend booking modals |
-| **One-off exceptional hours** | Single-day exceptions (dentist, afternoon off) - not yearly/weekly | Backend scheduling + frontend HolidayHours |
-| **Auto lunch protection** | Block bookings that remove lunch break (1h customer, 30min admin with warning) | Backend `available-hours` logic |
-| **Walk-in slot blocking** | Properly block next available slot during walk-in intake | `WalkInCreateModal.svelte` |
-| **Square payment integration** | Full Square SDK integration | Backend payment handlers + frontend payment step |
-| **GDPR data export** | User button for "give me my data" using `export_all_user_data()` | Backend endpoint + account page |
-| **Tax data export** | Admin button for tax-software-compatible format | Backend endpoint + admin page |
+| **One-off custom services** | Admin creates custom service for single booking without adding to main list | Backend + frontend booking modals |
+| **One-off exceptional hours** | Single-day exceptions (dentist, afternoon off) - not yearly/weekly | Backend scheduling + frontend HolidayHours |
+| **Auto lunch protection** | Block bookings that remove lunch break (1h customer, 30min admin with warning) | Backend `available-hours` logic |
+| **Walk-in slot blocking** | Properly block next available slot during walk-in intake | `WalkInCreateModal.svelte` |
+| **Square payment integration** | Full Square SDK integration | Backend payment handlers + frontend payment step |
+| **GDPR data export** | User button for "give me my data" using `export_all_user_data()` | Backend endpoint + account page |
+| **Tax data export** | Admin button for tax-software-compatible format | Backend endpoint + admin page |
### Medium Priority
@@ -402,6 +422,17 @@ admin_notification_reason: pending_booking | cancelled_booking | rescheduled_boo
| `POSTGRES_USER` | Database username | Docker |
| `POSTGRES_PASSWORD` | Database password | Docker |
| `POSTGRES_DB` | Database name | Docker |
+| `S3_BUCKET` | Main image bucket (portfolio) | No (default: crussell) |
+| `S3_PROFILE_PICS_BUCKET` | Profile pictures bucket | No (default: crussell-profile-pics) |
+| `S3_ENDPOINT` | S3/Rustfs endpoint | Dev |
+| `S3_PUBLIC_URL` | Public URL for S3 bucket | Dev |
+| `S3_ACCESS_KEY` | S3 access key | Dev |
+| `S3_SECRET_KEY` | S3 secret key | Dev |
+| `R2_ENDPOINT` | Cloudflare R2 endpoint | Prod |
+| `R2_BUCKET` | R2 bucket name | Prod |
+| `R2_PUBLIC_URL` | R2 public URL | Prod |
+| `R2_ACCESS_KEY` | R2 access key | Prod |
+| `R2_SECRET_KEY` | R2 secret key | Prod |
---
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..0d80941
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "Crussell",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}