update onboarding colors and add image upload
This commit is contained in:
4
App.tsx
4
App.tsx
@@ -46,12 +46,8 @@ const RequireOnboarding: React.FC<{ children: React.ReactNode }> = ({ children }
|
|||||||
|
|
||||||
if (!org) return <div className="p-8">Loading organization...</div>;
|
if (!org) return <div className="p-8">Loading organization...</div>;
|
||||||
|
|
||||||
// Get the user's relationship to this organization
|
|
||||||
const userOrgRelation = organizations.find(o => o.orgId === selectedOrgId);
|
const userOrgRelation = organizations.find(o => o.orgId === selectedOrgId);
|
||||||
const isOrgOwner = userOrgRelation?.role === 'owner';
|
const isOrgOwner = userOrgRelation?.role === 'owner';
|
||||||
|
|
||||||
// SINGLE SOURCE OF TRUTH: Organization onboarding completion is the authoritative source
|
|
||||||
// User organization records are updated to reflect this, but org.onboardingCompleted is primary
|
|
||||||
const onboardingCompleted = org.onboardingCompleted === true;
|
const onboardingCompleted = org.onboardingCompleted === true;
|
||||||
|
|
||||||
console.log('RequireOnboarding check:', {
|
console.log('RequireOnboarding check:', {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode, useState } from 'react';
|
||||||
|
import ImageUpload from '../ui/ImageUpload';
|
||||||
|
import { StoredImage, uploadCompanyLogo } from '../../services/imageStorageService';
|
||||||
|
|
||||||
interface FigmaQuestionProps {
|
interface FigmaQuestionProps {
|
||||||
question: string;
|
question: string;
|
||||||
@@ -15,6 +17,10 @@ interface FigmaQuestionProps {
|
|||||||
totalSteps?: number;
|
totalSteps?: number;
|
||||||
stepTitle?: string;
|
stepTitle?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
// Image upload props
|
||||||
|
orgId?: string;
|
||||||
|
onImageUploaded?: (image: StoredImage) => void;
|
||||||
|
currentImage?: StoredImage | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnhancedFigmaQuestion: React.FC<FigmaQuestionProps> = ({
|
export const EnhancedFigmaQuestion: React.FC<FigmaQuestionProps> = ({
|
||||||
@@ -31,8 +37,44 @@ export const EnhancedFigmaQuestion: React.FC<FigmaQuestionProps> = ({
|
|||||||
currentStep = 1,
|
currentStep = 1,
|
||||||
totalSteps = 8,
|
totalSteps = 8,
|
||||||
stepTitle = "Company Overview & Mission.",
|
stepTitle = "Company Overview & Mission.",
|
||||||
className = ""
|
className = "",
|
||||||
|
// Image upload props
|
||||||
|
orgId,
|
||||||
|
onImageUploaded,
|
||||||
|
currentImage
|
||||||
}) => {
|
}) => {
|
||||||
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string>('');
|
||||||
|
|
||||||
|
const handleImageSelected = async (file: File) => {
|
||||||
|
if (!orgId) {
|
||||||
|
setUploadError('Organization ID is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadingImage(true);
|
||||||
|
setUploadError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadedImage = await uploadCompanyLogo(file, orgId);
|
||||||
|
if (onImageUploaded) {
|
||||||
|
onImageUploaded(uploadedImage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload image:', error);
|
||||||
|
setUploadError(error instanceof Error ? error.message : 'Failed to upload image');
|
||||||
|
} finally {
|
||||||
|
setUploadingImage(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageRemove = () => {
|
||||||
|
// For now, just call the callback with null
|
||||||
|
// You could also implement deleteCompanyLogo here if needed
|
||||||
|
if (onImageUploaded) {
|
||||||
|
onImageUploaded(null as any);
|
||||||
|
}
|
||||||
|
};
|
||||||
// Generate the progress indicator dots
|
// Generate the progress indicator dots
|
||||||
const renderProgressDots = () => {
|
const renderProgressDots = () => {
|
||||||
const dots = [];
|
const dots = [];
|
||||||
@@ -62,12 +104,52 @@ export const EnhancedFigmaQuestion: React.FC<FigmaQuestionProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-[1440px] h-[810px] py-6 relative bg-Neutrals-NeutralSlate0 inline-flex flex-col justify-center items-center gap-9 ${className}`}>
|
<div className={`w-[1440px] h-[810px] py-6 relative bg-[--Neutrals-NeutralSlate0] inline-flex flex-col justify-center items-center gap-9 ${className}`}>
|
||||||
<div className="w-full max-w-[464px] min-w-[464px] flex flex-col justify-start items-start gap-12">
|
<div className="w-full max-w-[464px] min-w-[464px] flex flex-col justify-start items-start gap-12">
|
||||||
<div className="self-stretch flex flex-col justify-start items-start gap-8">
|
<div className="self-stretch flex flex-col justify-start items-start gap-8">
|
||||||
<div className="self-stretch text-center justify-start text-Neutrals-NeutralSlate950 text-2xl font-medium font-['Neue_Montreal'] leading-normal">
|
{currentStep === 1 ?
|
||||||
|
<div className="self-stretch inline-flex flex-col justify-start items-start gap-2">
|
||||||
|
<div className="self-stretch inline-flex justify-start items-center gap-0.5">
|
||||||
|
<div className="justify-start text-[--Neutrals-NeutralSlate950] text-sm font-normal font-['Inter'] leading-tight">Company Logo</div>
|
||||||
|
</div>
|
||||||
|
<div className="self-stretch p-4 rounded-3xl outline outline-1 outline-offset-[-1px] outline-[--Neutrals-NeutralSlate200] inline-flex justify-start items-center gap-4">
|
||||||
|
<ImageUpload
|
||||||
|
onImageSelected={handleImageSelected}
|
||||||
|
onImageRemove={currentImage ? handleImageRemove : undefined}
|
||||||
|
currentImage={currentImage}
|
||||||
|
loading={uploadingImage}
|
||||||
|
error={uploadError}
|
||||||
|
size="medium"
|
||||||
|
/>
|
||||||
|
<div className="inline-flex flex-col justify-start items-start gap-4">
|
||||||
|
<div className="self-stretch inline-flex justify-start items-center gap-3">
|
||||||
|
<div className="flex justify-start items-center gap-2">
|
||||||
|
<div data-svg-wrapper className="relative">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 2H2M12 8.66667L8 4.66667M8 4.66667L4 8.66667M8 4.66667V14" stroke="var(--Neutrals-NeutralSlate950, #FDFDFD)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="justify-center text-[--Neutrals-NeutralSlate950] text-sm font-medium font-['Inter'] leading-tight">
|
||||||
|
{currentImage ? 'Change image' : 'Upload image'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{uploadError && (
|
||||||
|
<div className="text-red-500 text-xs">{uploadError}</div>
|
||||||
|
)}
|
||||||
|
{currentImage && (
|
||||||
|
<div className="text-[--Neutrals-NeutralSlate500] text-xs">
|
||||||
|
{Math.round(currentImage.compressedSize / 1024)}KB • {currentImage.width}×{currentImage.height}px
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
<div className="self-stretch text-center justify-start text-[--Neutrals-NeutralSlate950] text-2xl font-medium font-['Neue_Montreal'] leading-normal">
|
||||||
{question}
|
{question}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<div className="self-stretch inline-flex justify-start items-start gap-2">
|
<div className="self-stretch inline-flex justify-start items-start gap-2">
|
||||||
@@ -78,11 +160,11 @@ export const EnhancedFigmaQuestion: React.FC<FigmaQuestionProps> = ({
|
|||||||
data-show-icon-right="false"
|
data-show-icon-right="false"
|
||||||
data-show-text="true"
|
data-show-text="true"
|
||||||
data-size="Big"
|
data-size="Big"
|
||||||
className={`h-12 px-8 py-3.5 bg-Neutrals-NeutralSlate100 rounded-[999px] flex justify-center items-center gap-1 overflow-hidden ${backDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-Neutrals-NeutralSlate200'}`}
|
className={`h-12 px-8 py-3.5 bg-[--Neutrals-NeutralSlate100] rounded-[999px] flex justify-center items-center gap-1 overflow-hidden ${backDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-[--Neutrals-NeutralSlate200]'}`}
|
||||||
onClick={!backDisabled ? onBack : undefined}
|
onClick={!backDisabled ? onBack : undefined}
|
||||||
>
|
>
|
||||||
<div className="px-1 flex justify-center items-center">
|
<div className="px-1 flex justify-center items-center">
|
||||||
<div className="justify-center text-Neutrals-NeutralSlate950 text-sm font-medium font-['Inter'] leading-tight">
|
<div className="justify-center text-[--Neutrals-NeutralSlate950] text-sm font-medium font-['Inter'] leading-tight">
|
||||||
{backText}
|
{backText}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,11 +176,11 @@ export const EnhancedFigmaQuestion: React.FC<FigmaQuestionProps> = ({
|
|||||||
data-show-icon-right="false"
|
data-show-icon-right="false"
|
||||||
data-show-text="true"
|
data-show-text="true"
|
||||||
data-size="Big"
|
data-size="Big"
|
||||||
className={`flex-1 h-12 px-4 py-3.5 bg-Brand-Orange rounded-[999px] outline outline-2 outline-offset-[-2px] outline-blue-400 flex justify-center items-center gap-1 overflow-hidden ${nextDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:opacity-90'}`}
|
className={`flex-1 h-12 px-4 py-3.5 bg-[--Brand-Orange] rounded-[999px] outline outline-2 outline-offset-[-2px] outline-blue-400 flex justify-center items-center gap-1 overflow-hidden ${nextDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:opacity-90'}`}
|
||||||
onClick={!nextDisabled ? onNext : undefined}
|
onClick={!nextDisabled ? onNext : undefined}
|
||||||
>
|
>
|
||||||
<div className="px-1 flex justify-center items-center">
|
<div className="px-1 flex justify-center items-center">
|
||||||
<div className="justify-center text-Other-White text-sm font-medium font-['Inter'] leading-tight">
|
<div className="justify-center text-white text-sm font-medium font-['Inter'] leading-tight">
|
||||||
{nextText}
|
{nextText}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,25 +189,25 @@ export const EnhancedFigmaQuestion: React.FC<FigmaQuestionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step indicator - top left */}
|
{/* Step indicator - top left */}
|
||||||
<div className="px-3 py-1.5 left-[24px] top-[24px] absolute bg-Neutrals-NeutralSlate100 rounded-[50px] inline-flex justify-center items-center gap-2 overflow-hidden">
|
<div className="px-3 py-1.5 left-[24px] top-[24px] absolute bg-[--Neutrals-NeutralSlate100] rounded-[50px] inline-flex justify-center items-center gap-2 overflow-hidden">
|
||||||
<div className="justify-start text-Neutrals-NeutralSlate500 text-sm font-medium font-['Inter'] uppercase leading-none">
|
<div className="justify-start text-[--Neutrals-NeutralSlate500] text-sm font-medium font-['Inter'] uppercase leading-none">
|
||||||
{currentStep} of {totalSteps}
|
{currentStep} of {totalSteps}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skip button - top right */}
|
{/* Skip button - top right */}
|
||||||
<div className="px-3 py-1.5 left-[1363px] top-[24px] absolute bg-Neutrals-NeutralSlate100 rounded-[50px] inline-flex justify-center items-center gap-2 overflow-hidden cursor-pointer hover:bg-Neutrals-NeutralSlate200">
|
<div className="px-3 py-1.5 left-[1363px] top-[24px] absolute bg-[--Neutrals-NeutralSlate100] rounded-[50px] inline-flex justify-center items-center gap-2 overflow-hidden cursor-pointer hover:bg-[--Neutrals-NeutralSlate200]">
|
||||||
<div className="justify-start text-Neutrals-NeutralSlate500 text-sm font-medium font-['Inter'] leading-none">
|
<div className="justify-start text-[--Neutrals-NeutralSlate500] text-sm font-medium font-['Inter'] leading-none">
|
||||||
Skip
|
Skip
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress indicator and title - top center */}
|
{/* Progress indicator and title - top center */}
|
||||||
<div className="w-[464px] max-w-[464px] min-w-[464px] left-[488px] top-[24px] absolute flex flex-col justify-start items-center gap-4">
|
<div className="w-[464px] max-w-[464px] min-w-[464px] left-[488px] top-[24px] absolute flex flex-col justify-start items-center gap-4">
|
||||||
<div className="p-4 bg-Neutrals-NeutralSlate100 rounded-[50px] inline-flex justify-center items-center gap-2 overflow-hidden">
|
<div className="p-4 bg-[--Neutrals-NeutralSlate100] rounded-[50px] inline-flex justify-center items-center gap-2 overflow-hidden">
|
||||||
{renderProgressDots()}
|
{renderProgressDots()}
|
||||||
</div>
|
</div>
|
||||||
<div className="self-stretch text-center justify-start text-Neutrals-NeutralSlate500 text-base font-medium font-['Neue_Montreal'] leading-normal">
|
<div className="self-stretch text-center justify-start text-[--Neutrals-NeutralSlate500] text-base font-medium font-['Neue_Montreal'] leading-normal">
|
||||||
{stepTitle}
|
{stepTitle}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,21 +230,21 @@ export const FigmaQuestionCard: React.FC<FigmaQuestionCardProps> = ({
|
|||||||
className = ""
|
className = ""
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={`w-full px-5 pt-5 pb-6 bg-Other-White rounded-2xl outline outline-1 outline-offset-[-1px] outline-Neutrals-NeutralSlate200 inline-flex flex-col justify-end items-end gap-4 ${className}`}>
|
<div className="w-full px-5 pt-5 pb-6 bg-white rounded-2xl outline outline-1 outline-offset-[-1px] outline-[--Neutrals-NeutralSlate200] inline-flex flex-col justify-end items-end gap-4">
|
||||||
<div className="self-stretch inline-flex justify-start items-start gap-3">
|
<div className="self-stretch inline-flex justify-start items-start gap-3">
|
||||||
<div className="justify-start text-zinc-300 text-xl font-medium font-['Inter'] leading-loose">Q</div>
|
<div className="justify-start text-zinc-300 text-xl font-medium font-['Inter'] leading-loose">Q</div>
|
||||||
<div className="flex-1 inline-flex flex-col justify-start items-start gap-2">
|
<div className="flex-1 inline-flex flex-col justify-start items-start gap-2">
|
||||||
<div className="self-stretch justify-start text-Neutrals-NeutralSlate950 text-xl font-semibold font-['Inter'] leading-loose">
|
<div className="self-stretch justify-start text-[--Neutrals-NeutralSlate950] text-xl font-semibold font-['Inter'] leading-loose">
|
||||||
{question}
|
{question}
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<div className="self-stretch justify-start text-Neutrals-NeutralSlate500 text-sm font-normal font-['Inter'] leading-tight">
|
<div className="self-stretch justify-start text-[--Neutrals-NeutralSlate500] text-sm font-normal font-['Inter'] leading-tight">
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="self-stretch h-0 rotate-90 shadow-[0px_1.5px_1.5px_0px_rgba(255,255,255,1.00)] outline outline-1 outline-offset-[-0.50px] border-Neutrals-NeutralSlate200" />
|
<div className="self-stretch h-0 rotate-90 shadow-[0px_1.5px_1.5px_0px_rgba(255,255,255,1.00)] outline outline-1 outline-offset-[-0.50px] border-[--Neutrals-NeutralSlate200]" />
|
||||||
<div className="self-stretch inline-flex justify-start items-center gap-3">
|
<div className="self-stretch inline-flex justify-start items-center gap-3">
|
||||||
<div className="justify-start text-zinc-300 text-xl font-medium font-['Inter'] leading-loose">A</div>
|
<div className="justify-start text-zinc-300 text-xl font-medium font-['Inter'] leading-loose">A</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -191,8 +273,8 @@ export const EnhancedFigmaInput: React.FC<EnhancedFigmaInputProps> = ({
|
|||||||
rows = 4,
|
rows = 4,
|
||||||
className = ""
|
className = ""
|
||||||
}) => {
|
}) => {
|
||||||
const baseClasses = "self-stretch min-h-40 p-5 relative bg-Neutrals-NeutralSlate100 rounded-xl inline-flex justify-start items-start gap-2.5 overflow-hidden";
|
const baseClasses = "self-stretch min-h-40 p-5 relative bg-[--Neutrals-NeutralSlate100] rounded-xl inline-flex justify-start items-start gap-2.5 overflow-hidden";
|
||||||
const inputClasses = "flex-1 justify-start text-Neutrals-NeutralSlate500 text-base font-normal font-['Inter'] leading-normal bg-transparent border-none outline-none resize-none";
|
const inputClasses = "flex-1 justify-start text-[--Neutrals-NeutralSlate500] text-base font-normal font-['Inter'] leading-normal bg-transparent border-none outline-none resize-none";
|
||||||
|
|
||||||
if (multiline) {
|
if (multiline) {
|
||||||
return (
|
return (
|
||||||
@@ -205,8 +287,8 @@ export const EnhancedFigmaInput: React.FC<EnhancedFigmaInputProps> = ({
|
|||||||
className={inputClasses}
|
className={inputClasses}
|
||||||
/>
|
/>
|
||||||
<div className="w-3 h-3 absolute right-[18px] bottom-[18px]">
|
<div className="w-3 h-3 absolute right-[18px] bottom-[18px]">
|
||||||
<div className="w-2 h-2 left-[2px] top-[2px] absolute outline outline-1 outline-offset-[-0.50px] outline-Neutrals-NeutralSlate500" />
|
<div className="w-2 h-2 left-[2px] top-[2px] absolute outline outline-1 outline-offset-[-0.50px] outline-[--Neutrals-NeutralSlate500]" />
|
||||||
<div className="w-1 h-1 left-[7px] top-[7px] absolute outline outline-1 outline-offset-[-0.50px] outline-Neutrals-NeutralSlate500" />
|
<div className="w-1 h-1 left-[7px] top-[7px] absolute outline outline-1 outline-offset-[-0.50px] outline-[--Neutrals-NeutralSlate500]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,17 +31,17 @@ export const FigmaInput: React.FC<FigmaInputProps> = ({
|
|||||||
<div className={`self-stretch flex flex-col justify-start items-start gap-2 ${className}`}>
|
<div className={`self-stretch flex flex-col justify-start items-start gap-2 ${className}`}>
|
||||||
{showLabel && label && (
|
{showLabel && label && (
|
||||||
<div className="self-stretch inline-flex justify-start items-center gap-0.5">
|
<div className="self-stretch inline-flex justify-start items-center gap-0.5">
|
||||||
<div className="justify-start text-Neutrals-NeutralSlate900 text-sm font-normal font-['Inter'] leading-tight">
|
<div className="justify-start text-[--Neutrals-NeutralSlate900] text-sm font-normal font-['Inter'] leading-tight">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
{required && (
|
{required && (
|
||||||
<div className="justify-start text-Brand-Orange text-sm font-medium font-['Inter'] leading-tight">*</div>
|
<div className="justify-start text-[--Brand-Orange] text-sm font-medium font-['Inter'] leading-tight">*</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="self-stretch flex flex-col justify-start items-start gap-1">
|
<div className="self-stretch flex flex-col justify-start items-start gap-1">
|
||||||
<div className="self-stretch px-4 py-3.5 bg-Neutrals-NeutralSlate100 rounded-[999px] inline-flex justify-start items-center gap-2 overflow-hidden">
|
<div className="self-stretch px-4 py-3.5 bg-[--Neutrals-NeutralSlate100] rounded-[999px] inline-flex justify-start items-center gap-2 overflow-hidden">
|
||||||
{icon && (
|
{icon && (
|
||||||
<div data-svg-wrapper className="relative">
|
<div data-svg-wrapper className="relative">
|
||||||
{icon}
|
{icon}
|
||||||
@@ -52,7 +52,7 @@ export const FigmaInput: React.FC<FigmaInputProps> = ({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="flex-1 justify-start text-Neutrals-NeutralSlate950 text-sm font-normal font-['Inter'] leading-tight bg-transparent border-none outline-none placeholder:text-Neutrals-NeutralSlate500"
|
className="flex-1 justify-start text-[--Neutrals-NeutralSlate950] text-sm font-normal font-['Inter'] leading-tight bg-transparent border-none outline-none placeholder:text-Neutrals-NeutralSlate500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,9 +60,9 @@ export const FigmaInput: React.FC<FigmaInputProps> = ({
|
|||||||
{buttonText && (
|
{buttonText && (
|
||||||
<button
|
<button
|
||||||
onClick={onButtonClick}
|
onClick={onButtonClick}
|
||||||
className="w-32 max-w-32 px-4 py-3.5 bg-Neutrals-NeutralSlate100 rounded-[999px] flex justify-center items-center gap-1 overflow-hidden hover:bg-Neutrals-NeutralSlate200 transition-colors"
|
className="w-32 max-w-32 px-4 py-3.5 bg-[--Neutrals-NeutralSlate100] rounded-[999px] flex justify-center items-center gap-1 overflow-hidden hover:bg-[--Neutrals-NeutralSlate200] transition-colors"
|
||||||
>
|
>
|
||||||
<div className="justify-center text-Neutrals-NeutralSlate500 text-sm font-medium font-['Inter'] leading-tight">
|
<div className="justify-center text-[--Neutrals-NeutralSlate500] text-sm font-medium font-['Inter'] leading-tight">
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -95,23 +95,23 @@ export const FigmaSelect: React.FC<FigmaSelectProps> = ({
|
|||||||
<div className={`self-stretch flex flex-col justify-start items-start gap-2 ${className}`}>
|
<div className={`self-stretch flex flex-col justify-start items-start gap-2 ${className}`}>
|
||||||
{label && (
|
{label && (
|
||||||
<div className="self-stretch inline-flex justify-start items-center gap-0.5">
|
<div className="self-stretch inline-flex justify-start items-center gap-0.5">
|
||||||
<div className="justify-start text-Neutrals-NeutralSlate900 text-sm font-normal font-['Inter'] leading-tight">
|
<div className="justify-start text-[--Neutrals-NeutralSlate900] text-sm font-normal font-['Inter'] leading-tight">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
{required && (
|
{required && (
|
||||||
<div className="justify-start text-Brand-Orange text-sm font-medium font-['Inter'] leading-tight">*</div>
|
<div className="justify-start text-[--Brand-Orange] text-sm font-medium font-['Inter'] leading-tight">*</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="self-stretch flex flex-col justify-start items-start gap-1">
|
<div className="self-stretch flex flex-col justify-start items-start gap-1">
|
||||||
<div className="self-stretch px-4 py-3.5 bg-Neutrals-NeutralSlate100 rounded-[999px] inline-flex justify-start items-center gap-2 overflow-hidden">
|
<div className="self-stretch px-4 py-3.5 bg-[--Neutrals-NeutralSlate100] rounded-[999px] inline-flex justify-start items-center gap-2 overflow-hidden">
|
||||||
<select
|
<select
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className="flex-1 justify-start text-Neutrals-NeutralSlate950 text-sm font-normal font-['Inter'] leading-tight bg-transparent border-none outline-none appearance-none"
|
className="flex-1 justify-start text-[--Neutrals-NeutralSlate950] text-sm font-normal font-['Inter'] leading-tight bg-transparent border-none outline-none appearance-none"
|
||||||
>
|
>
|
||||||
<option value="" className="text-Neutrals-NeutralSlate500">{placeholder}</option>
|
<option value="" className="text-[--Neutrals-NeutralSlate500]">{placeholder}</option>
|
||||||
{options.map((option, index) => (
|
{options.map((option, index) => (
|
||||||
<option key={index} value={option.value}>
|
<option key={index} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
181
components/ui/ImageUpload.tsx
Normal file
181
components/ui/ImageUpload.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { StoredImage } from '../../services/imageStorageService';
|
||||||
|
|
||||||
|
interface ImageUploadProps {
|
||||||
|
onImageSelected: (file: File) => void;
|
||||||
|
onImageRemove?: () => void;
|
||||||
|
currentImage?: StoredImage | null;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string;
|
||||||
|
className?: string;
|
||||||
|
maxSizeMB?: number;
|
||||||
|
acceptedFormats?: string[];
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageUpload: React.FC<ImageUploadProps> = ({
|
||||||
|
onImageSelected,
|
||||||
|
onImageRemove,
|
||||||
|
currentImage,
|
||||||
|
loading = false,
|
||||||
|
error,
|
||||||
|
className = '',
|
||||||
|
maxSizeMB = 10,
|
||||||
|
acceptedFormats = ['JPEG', 'PNG', 'GIF', 'WebP'],
|
||||||
|
size = 'medium'
|
||||||
|
}) => {
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'w-12 h-12',
|
||||||
|
medium: 'w-16 h-16',
|
||||||
|
large: 'w-24 h-24'
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (file: File) => {
|
||||||
|
// Basic validation
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageSelected(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileSelect(file);
|
||||||
|
}
|
||||||
|
// Reset input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileSelect(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!loading) {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onImageRemove) {
|
||||||
|
onImageRemove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="hidden"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
${sizeClasses[size]} relative rounded-[250px] overflow-hidden
|
||||||
|
cursor-pointer transition-all duration-200
|
||||||
|
${dragOver ? 'ring-2 ring-Brand-Orange ring-opacity-50' : ''}
|
||||||
|
${loading ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80'}
|
||||||
|
`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{currentImage ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={currentImage.dataUrl}
|
||||||
|
alt="Uploaded"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{!loading && onImageRemove && (
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
||||||
|
title="Remove image"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-spin w-6 h-6 border-2 border-Brand-Orange border-t-transparent rounded-full" />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="text-gray-400"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 16L12 8M12 8L8 12M12 8L16 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 16V20C4 20.5523 4.44772 21 5 21H19C19.5523 21 20 20.5523 20 20V16"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dragOver && (
|
||||||
|
<div className="absolute inset-0 bg-Brand-Orange bg-opacity-20 flex items-center justify-center">
|
||||||
|
<span className="text-Brand-Orange text-xs font-medium">Drop image</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 text-xs text-red-500 whitespace-nowrap">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageUpload;
|
||||||
@@ -82,70 +82,6 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('OrgContext effect running, orgId:', orgId, 'isFirebaseConfigured:', isFirebaseConfigured);
|
console.log('OrgContext effect running, orgId:', orgId, 'isFirebaseConfigured:', isFirebaseConfigured);
|
||||||
if (!orgId) return; // Wait for orgId to be available
|
if (!orgId) return; // Wait for orgId to be available
|
||||||
if (!isFirebaseConfigured) {
|
|
||||||
// Demo mode data - use persistent localStorage with proper initialization
|
|
||||||
console.log('Setting up demo org data with persistence');
|
|
||||||
|
|
||||||
// Get or create persistent demo org
|
|
||||||
let demoOrg = demoStorage.getOrganization(orgId);
|
|
||||||
if (!demoOrg) {
|
|
||||||
demoOrg = {
|
|
||||||
orgId: orgId,
|
|
||||||
name: 'Demo Company',
|
|
||||||
onboardingCompleted: false
|
|
||||||
};
|
|
||||||
demoStorage.saveOrganization(demoOrg);
|
|
||||||
|
|
||||||
// Initialize with empty employee list for clean start
|
|
||||||
// (Removed automatic seeding of 6 default employees per user feedback)
|
|
||||||
|
|
||||||
// Don't automatically create sample submissions - let users create real data
|
|
||||||
// through the proper questionnaire flow
|
|
||||||
|
|
||||||
// Note: Sample employee reports removed - real reports generated via AI after questionnaire submission
|
|
||||||
|
|
||||||
// Don't save sample company report - let users generate real AI-powered reports
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load persistent demo data
|
|
||||||
setOrg({ orgId, name: demoOrg.name, onboardingCompleted: demoOrg.onboardingCompleted });
|
|
||||||
|
|
||||||
// Convert employees to expected format
|
|
||||||
const demoEmployees = demoStorage.getEmployeesByOrg(orgId);
|
|
||||||
const convertedEmployees: Employee[] = demoEmployees.map(emp => ({
|
|
||||||
id: emp.id,
|
|
||||||
name: emp.name,
|
|
||||||
email: emp.email,
|
|
||||||
initials: emp.name ? emp.name.split(' ').map(n => n[0]).join('').toUpperCase() : emp.email.substring(0, 2).toUpperCase(),
|
|
||||||
department: emp.department,
|
|
||||||
role: emp.role,
|
|
||||||
isOwner: emp.id === user?.uid
|
|
||||||
}));
|
|
||||||
setEmployees(convertedEmployees);
|
|
||||||
|
|
||||||
// Load any existing submissions from localStorage
|
|
||||||
const orgSubmissions = demoStorage.getSubmissionsByOrg(orgId);
|
|
||||||
const convertedSubmissions: Record<string, Submission> = {};
|
|
||||||
Object.entries(orgSubmissions).forEach(([employeeId, demoSub]) => {
|
|
||||||
convertedSubmissions[employeeId] = {
|
|
||||||
employeeId,
|
|
||||||
answers: Object.entries(demoSub.answers).map(([question, answer]) => ({
|
|
||||||
question,
|
|
||||||
answer
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setSubmissions(convertedSubmissions);
|
|
||||||
|
|
||||||
// Load any existing AI-generated reports from localStorage
|
|
||||||
const orgReports = demoStorage.getEmployeeReportsByOrg(orgId);
|
|
||||||
setReports(orgReports);
|
|
||||||
|
|
||||||
// Load any existing company reports from localStorage
|
|
||||||
const companyReports = demoStorage.getCompanyReportsByOrg(orgId);
|
|
||||||
setFullCompanyReports(companyReports);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('Setting up Firebase org data');
|
console.log('Setting up Firebase org data');
|
||||||
const orgRef = doc(db, 'orgs', orgId);
|
const orgRef = doc(db, 'orgs', orgId);
|
||||||
getDoc(orgRef).then(async (snap) => {
|
getDoc(orgRef).then(async (snap) => {
|
||||||
@@ -183,35 +119,6 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
|
|||||||
}, [orgId]);
|
}, [orgId]);
|
||||||
|
|
||||||
const upsertOrg = async (data: Partial<OrgData>) => {
|
const upsertOrg = async (data: Partial<OrgData>) => {
|
||||||
if (!isFirebaseConfigured) {
|
|
||||||
const updatedOrg = { ...(org || { orgId, name: 'Demo Company' }), ...data } as OrgData;
|
|
||||||
setOrg(updatedOrg);
|
|
||||||
|
|
||||||
// If onboarding was completed, update localStorage for persistence and notify other contexts
|
|
||||||
if (data.onboardingCompleted) {
|
|
||||||
const demoOrgData = {
|
|
||||||
orgId: updatedOrg.orgId,
|
|
||||||
name: updatedOrg.name,
|
|
||||||
onboardingCompleted: updatedOrg.onboardingCompleted || false,
|
|
||||||
...updatedOrg // Include all additional fields
|
|
||||||
};
|
|
||||||
demoStorage.saveOrganization(demoOrgData);
|
|
||||||
|
|
||||||
console.log('OrgContext: Onboarding completed, dispatching update event', {
|
|
||||||
orgId: updatedOrg.orgId,
|
|
||||||
onboardingCompleted: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Signal to UserOrganizationsContext and other components about completion
|
|
||||||
window.dispatchEvent(new CustomEvent('organizationUpdated', {
|
|
||||||
detail: { orgId: updatedOrg.orgId, onboardingCompleted: true }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Organization already exists, no need to sync with server during onboarding
|
|
||||||
// We'll update Firestore directly in the Firebase mode below
|
|
||||||
} else {
|
|
||||||
// Firebase mode - save to Firestore
|
|
||||||
const orgRef = doc(db, 'orgs', orgId);
|
const orgRef = doc(db, 'orgs', orgId);
|
||||||
await setDoc(orgRef, data, { merge: true });
|
await setDoc(orgRef, data, { merge: true });
|
||||||
|
|
||||||
@@ -230,42 +137,9 @@ export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: s
|
|||||||
detail: { orgId: updatedOrg.orgId, onboardingCompleted: true }
|
detail: { orgId: updatedOrg.orgId, onboardingCompleted: true }
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateOrg = async (data: Partial<OrgData>) => {
|
|
||||||
if (!isFirebaseConfigured) {
|
|
||||||
const updatedOrg = { ...(org || { orgId, name: 'Demo Company' }), ...data } as OrgData;
|
|
||||||
setOrg(updatedOrg);
|
|
||||||
|
|
||||||
// Also sync with server for multi-tenant persistence
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_URL}/api/organizations/${orgId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.warn('Failed to sync organization data with server');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to sync organization data:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const orgRef = doc(db, 'orgs', orgId);
|
|
||||||
await setDoc(orgRef, data, { merge: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveReport = async (employeeId: string, report: Report) => {
|
const saveReport = async (employeeId: string, report: Report) => {
|
||||||
if (!isFirebaseConfigured) {
|
|
||||||
setReports(prev => ({ ...prev, [employeeId]: report }));
|
|
||||||
// Persist to localStorage
|
|
||||||
demoStorage.saveEmployeeReport(orgId, employeeId, report);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ref = doc(db, 'orgs', orgId, 'reports', employeeId);
|
const ref = doc(db, 'orgs', orgId, 'reports', employeeId);
|
||||||
await setDoc(ref, report, { merge: true });
|
await setDoc(ref, report, { merge: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,17 +41,6 @@ export const UserOrganizationsProvider: React.FC<{ children: React.ReactNode }>
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!isFirebaseConfigured) {
|
|
||||||
// Demo mode - fetch from server API
|
|
||||||
const response = await fetch(`${API_URL}/api/user/${user.uid}/organizations`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setOrganizations(data.organizations || []);
|
|
||||||
} else {
|
|
||||||
console.error('Failed to load organizations:', response.status);
|
|
||||||
setOrganizations([]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Firebase mode - fetch from Cloud Functions
|
// Firebase mode - fetch from Cloud Functions
|
||||||
const response = await fetch(`${API_URL}/getUserOrganizations?userId=${user.uid}`);
|
const response = await fetch(`${API_URL}/getUserOrganizations?userId=${user.uid}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -61,7 +50,6 @@ export const UserOrganizationsProvider: React.FC<{ children: React.ReactNode }>
|
|||||||
console.error('Failed to load organizations:', response.status);
|
console.error('Failed to load organizations:', response.status);
|
||||||
setOrganizations([]);
|
setOrganizations([]);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load organizations:', error);
|
console.error('Failed to load organizations:', error);
|
||||||
setOrganizations([]);
|
setOrganizations([]);
|
||||||
@@ -134,29 +122,6 @@ export const UserOrganizationsProvider: React.FC<{ children: React.ReactNode }>
|
|||||||
let newOrg: UserOrganization;
|
let newOrg: UserOrganization;
|
||||||
let requiresSubscription = false;
|
let requiresSubscription = false;
|
||||||
|
|
||||||
if (!isFirebaseConfigured) {
|
|
||||||
// Demo mode - use server API
|
|
||||||
const response = await fetch(`${API_URL}/api/organizations`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name, userId: user.uid })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to create organization: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
newOrg = {
|
|
||||||
orgId: data.orgId,
|
|
||||||
name: data.name,
|
|
||||||
role: data.role,
|
|
||||||
onboardingCompleted: data.onboardingCompleted,
|
|
||||||
joinedAt: data.joinedAt
|
|
||||||
};
|
|
||||||
|
|
||||||
setOrganizations(prev => [...prev, newOrg]);
|
|
||||||
} else {
|
|
||||||
// Firebase mode - use Cloud Function
|
// Firebase mode - use Cloud Function
|
||||||
const response = await fetch(`${API_URL}/createOrganization`, {
|
const response = await fetch(`${API_URL}/createOrganization`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -179,7 +144,6 @@ export const UserOrganizationsProvider: React.FC<{ children: React.ReactNode }>
|
|||||||
|
|
||||||
requiresSubscription = data.requiresSubscription || false;
|
requiresSubscription = data.requiresSubscription || false;
|
||||||
setOrganizations(prev => [...prev, newOrg]);
|
setOrganizations(prev => [...prev, newOrg]);
|
||||||
}
|
|
||||||
|
|
||||||
return { orgId: newOrg.orgId, requiresSubscription };
|
return { orgId: newOrg.orgId, requiresSubscription };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -192,46 +156,46 @@ export const UserOrganizationsProvider: React.FC<{ children: React.ReactNode }>
|
|||||||
if (!user) throw new Error('User not authenticated');
|
if (!user) throw new Error('User not authenticated');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!isFirebaseConfigured) {
|
// if (!isFirebaseConfigured) {
|
||||||
// Demo mode - use server API to get and consume invite
|
// // Demo mode - use server API to get and consume invite
|
||||||
const inviteStatusRes = await fetch(`/api/invitations/${inviteCode}`);
|
// const inviteStatusRes = await fetch(`/api/invitations/${inviteCode}`);
|
||||||
if (!inviteStatusRes.ok) {
|
// if (!inviteStatusRes.ok) {
|
||||||
throw new Error('Invalid or expired invite code');
|
// throw new Error('Invalid or expired invite code');
|
||||||
}
|
// }
|
||||||
|
|
||||||
const inviteData = await inviteStatusRes.json();
|
// const inviteData = await inviteStatusRes.json();
|
||||||
if (inviteData.used) {
|
// if (inviteData.used) {
|
||||||
throw new Error('Invite code has already been used');
|
// throw new Error('Invite code has already been used');
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Consume the invite
|
// // Consume the invite
|
||||||
const consumeRes = await fetch(`/api/invitations/${inviteCode}/consume`, {
|
// const consumeRes = await fetch(`/api/invitations/${inviteCode}/consume`, {
|
||||||
method: 'POST'
|
// method: 'POST'
|
||||||
});
|
// });
|
||||||
if (!consumeRes.ok) {
|
// if (!consumeRes.ok) {
|
||||||
throw new Error('Failed to consume invite');
|
// throw new Error('Failed to consume invite');
|
||||||
}
|
// }
|
||||||
|
|
||||||
const consumedData = await consumeRes.json();
|
// const consumedData = await consumeRes.json();
|
||||||
const orgId = consumedData.orgId;
|
// const orgId = consumedData.orgId;
|
||||||
|
|
||||||
// Get organization data (this might be from localStorage for demo mode)
|
// // Get organization data (this might be from localStorage for demo mode)
|
||||||
const orgData = demoStorage.getOrganization(orgId);
|
// const orgData = demoStorage.getOrganization(orgId);
|
||||||
if (!orgData) {
|
// if (!orgData) {
|
||||||
throw new Error('Organization not found');
|
// throw new Error('Organization not found');
|
||||||
}
|
// }
|
||||||
|
|
||||||
const userOrg: UserOrganization = {
|
// const userOrg: UserOrganization = {
|
||||||
orgId: orgId,
|
// orgId: orgId,
|
||||||
name: orgData.name,
|
// name: orgData.name,
|
||||||
role: 'employee',
|
// role: 'employee',
|
||||||
onboardingCompleted: orgData.onboardingCompleted || false,
|
// onboardingCompleted: orgData.onboardingCompleted || false,
|
||||||
joinedAt: Date.now()
|
// joinedAt: Date.now()
|
||||||
};
|
// };
|
||||||
|
|
||||||
setOrganizations(prev => [...prev, userOrg]);
|
// setOrganizations(prev => [...prev, userOrg]);
|
||||||
return orgId;
|
// return orgId;
|
||||||
} else {
|
// } else {
|
||||||
// Firebase mode - use Cloud Function
|
// Firebase mode - use Cloud Function
|
||||||
const response = await fetch(`${API_URL}/joinOrganization`, {
|
const response = await fetch(`${API_URL}/joinOrganization`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -255,7 +219,7 @@ export const UserOrganizationsProvider: React.FC<{ children: React.ReactNode }>
|
|||||||
|
|
||||||
setOrganizations(prev => [...prev, userOrg]);
|
setOrganizations(prev => [...prev, userOrg]);
|
||||||
return data.orgId;
|
return data.orgId;
|
||||||
}
|
// }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to join organization:', error);
|
console.error('Failed to join organization:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -68,8 +68,7 @@ const QuestionInput: React.FC<{
|
|||||||
<button
|
<button
|
||||||
key={ratingValue}
|
key={ratingValue}
|
||||||
onClick={() => onChange(ratingValue.toString())}
|
onClick={() => onChange(ratingValue.toString())}
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
|
className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${isSelected
|
||||||
isSelected
|
|
||||||
? 'bg-Brand-Orange text-white'
|
? 'bg-Brand-Orange text-white'
|
||||||
: 'bg-Neutrals-NeutralSlate100 text-Neutrals-NeutralSlate700 hover:bg-Neutrals-NeutralSlate200'
|
: 'bg-Neutrals-NeutralSlate100 text-Neutrals-NeutralSlate700 hover:bg-Neutrals-NeutralSlate200'
|
||||||
}`}
|
}`}
|
||||||
@@ -93,28 +92,24 @@ const QuestionInput: React.FC<{
|
|||||||
<div className="self-stretch inline-flex justify-center items-center gap-3">
|
<div className="self-stretch inline-flex justify-center items-center gap-3">
|
||||||
<div
|
<div
|
||||||
onClick={() => onChange('No')}
|
onClick={() => onChange('No')}
|
||||||
className={`w-20 h-20 relative rounded-[999px] overflow-hidden cursor-pointer transition-colors ${
|
className={`w-20 h-20 relative rounded-[999px] overflow-hidden cursor-pointer transition-colors ${value === 'No'
|
||||||
value === 'No'
|
|
||||||
? 'bg-Neutrals-NeutralSlate800'
|
? 'bg-Neutrals-NeutralSlate800'
|
||||||
: 'bg-Neutrals-NeutralSlate100 hover:bg-Neutrals-NeutralSlate200'
|
: 'bg-Neutrals-NeutralSlate100 hover:bg-Neutrals-NeutralSlate200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`absolute inset-0 flex items-center justify-center text-center text-base font-normal font-['Inter'] leading-normal ${
|
<div className={`absolute inset-0 flex items-center justify-center text-center text-base font-normal font-['Inter'] leading-normal ${value === 'No' ? 'text-Neutrals-NeutralSlate0' : 'text-Neutrals-NeutralSlate950'
|
||||||
value === 'No' ? 'text-Neutrals-NeutralSlate0' : 'text-Neutrals-NeutralSlate950'
|
|
||||||
}`}>
|
}`}>
|
||||||
No
|
No
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => onChange('Yes')}
|
onClick={() => onChange('Yes')}
|
||||||
className={`w-20 h-20 relative rounded-[999px] overflow-hidden cursor-pointer transition-colors ${
|
className={`w-20 h-20 relative rounded-[999px] overflow-hidden cursor-pointer transition-colors ${value === 'Yes'
|
||||||
value === 'Yes'
|
|
||||||
? 'bg-Neutrals-NeutralSlate800'
|
? 'bg-Neutrals-NeutralSlate800'
|
||||||
: 'bg-Neutrals-NeutralSlate100 hover:bg-Neutrals-NeutralSlate200'
|
: 'bg-Neutrals-NeutralSlate100 hover:bg-Neutrals-NeutralSlate200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`absolute inset-0 flex items-center justify-center text-center text-base font-normal font-['Inter'] leading-normal ${
|
<div className={`absolute inset-0 flex items-center justify-center text-center text-base font-normal font-['Inter'] leading-normal ${value === 'Yes' ? 'text-Neutrals-NeutralSlate0' : 'text-Neutrals-NeutralSlate950'
|
||||||
value === 'Yes' ? 'text-Neutrals-NeutralSlate0' : 'text-Neutrals-NeutralSlate950'
|
|
||||||
}`}>
|
}`}>
|
||||||
Yes
|
Yes
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
|
|
||||||
const LoginNew: React.FC = () => {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const { login } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
if (!email) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await login(email);
|
|
||||||
navigate('/company-wiki');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-[1440px] h-[810px] bg-Neutrals-NeutralSlate0 inline-flex justify-start items-end overflow-hidden">
|
|
||||||
<div className="flex-1 self-stretch px-32 py-48 flex justify-center items-center gap-2.5 overflow-hidden">
|
|
||||||
<div className="flex-1 max-w-[464px] inline-flex flex-col justify-start items-start gap-6">
|
|
||||||
<div className="w-12 h-12 relative bg-Brand-Orange rounded-xl outline outline-2 outline-offset-[-2px] outline-blue-400 overflow-hidden">
|
|
||||||
<div className="w-12 h-12 left-0 top-0 absolute bg-gradient-to-b from-white/0 to-white/10" />
|
|
||||||
<div className="left-[12px] top-[9.33px] absolute">
|
|
||||||
<svg width="24" height="30" viewBox="0 0 24 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path opacity="0.5" fillRule="evenodd" clipRule="evenodd" d="M2.57425 17.8128C3.11852 18.3639 3.11851 19.2575 2.57423 19.8087L2.54635 19.8369C2.00207 20.3881 1.11963 20.3881 0.575357 19.8369C0.0310869 19.2857 0.0310953 18.3921 0.575376 17.841L0.603251 17.8128C1.14753 17.2616 2.02998 17.2616 2.57425 17.8128Z" fill="url(#paint0_linear_710_14140)" />
|
|
||||||
<path opacity="0.7" fillRule="evenodd" clipRule="evenodd" d="M9.12599 18.2379C9.66928 18.7901 9.66769 19.6837 9.12243 20.2338L5.26187 24.1291C4.71661 24.6792 3.83416 24.6776 3.29087 24.1255C2.74758 23.5733 2.74918 22.6797 3.29444 22.1296L7.155 18.2343C7.70026 17.6842 8.5827 17.6858 9.12599 18.2379Z" fill="url(#paint1_linear_710_14140)" />
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="paint0_linear_710_14140" x1="1.5748" y1="17.3994" x2="1.5748" y2="20.2503" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stopColor="white" stopOpacity="0.8" />
|
|
||||||
<stop offset="1" stopColor="white" stopOpacity="0.5" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="paint1_linear_710_14140" x1="6.20843" y1="17.8228" x2="6.20843" y2="24.5406" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stopColor="white" stopOpacity="0.8" />
|
|
||||||
<stop offset="1" stopColor="white" stopOpacity="0.5" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="self-stretch flex flex-col justify-start items-start gap-3">
|
|
||||||
<div className="self-stretch justify-start text-Neutrals-NeutralSlate950 text-2xl font-semibold font-['Inter'] leading-8">Welcome to Auditly</div>
|
|
||||||
<div className="self-stretch justify-start text-Neutrals-NeutralSlate500 text-base font-normal font-['Inter'] leading-normal">Sign in to your account to continue</div>
|
|
||||||
</div>
|
|
||||||
<div className="self-stretch flex flex-col justify-start items-start gap-4">
|
|
||||||
<div className="self-stretch flex flex-col justify-start items-start gap-2">
|
|
||||||
<div className="self-stretch inline-flex justify-start items-center gap-0.5">
|
|
||||||
<div className="justify-start text-Neutrals-NeutralSlate900 text-sm font-normal font-['Inter'] leading-tight">Email Address</div>
|
|
||||||
<div className="justify-start text-Brand-Orange text-sm font-medium font-['Inter'] leading-tight">*</div>
|
|
||||||
</div>
|
|
||||||
<div className="self-stretch flex flex-col justify-start items-start gap-1">
|
|
||||||
<div className="self-stretch px-4 py-3.5 bg-Neutrals-NeutralSlate100 rounded-[999px] inline-flex justify-start items-center gap-2 overflow-hidden">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="flex-1 bg-transparent text-Neutrals-NeutralSlate950 text-sm font-normal font-['Inter'] leading-tight placeholder:text-Neutrals-NeutralSlate500 outline-none"
|
|
||||||
placeholder="Enter your email address"
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleLogin()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleLogin}
|
|
||||||
disabled={!email || isLoading}
|
|
||||||
className="self-stretch px-6 py-3.5 bg-Brand-Orange rounded-[999px] inline-flex justify-center items-center gap-2 overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<div className="justify-center text-Neutrals-NeutralSlate0 text-base font-medium font-['Inter'] leading-normal">
|
|
||||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<div className="self-stretch text-center text-Neutrals-NeutralSlate500 text-sm font-normal font-['Inter'] leading-tight">
|
|
||||||
Don't have an account? Contact your administrator.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LoginNew;
|
|
||||||
@@ -4,6 +4,7 @@ import { useOrg } from '../contexts/OrgContext';
|
|||||||
import { EnhancedFigmaQuestion, FigmaQuestionCard, EnhancedFigmaInput } from '../components/figma/EnhancedFigmaQuestion';
|
import { EnhancedFigmaQuestion, FigmaQuestionCard, EnhancedFigmaInput } from '../components/figma/EnhancedFigmaQuestion';
|
||||||
import { FigmaInput, FigmaSelect } from '../components/figma/FigmaInput';
|
import { FigmaInput, FigmaSelect } from '../components/figma/FigmaInput';
|
||||||
import { FigmaMultipleChoice } from '../components/figma/FigmaMultipleChoice';
|
import { FigmaMultipleChoice } from '../components/figma/FigmaMultipleChoice';
|
||||||
|
import { StoredImage } from '../services/imageStorageService';
|
||||||
|
|
||||||
interface OnboardingData {
|
interface OnboardingData {
|
||||||
// Step 0: Company Details
|
// Step 0: Company Details
|
||||||
@@ -51,6 +52,7 @@ const Onboarding: React.FC = () => {
|
|||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
|
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
|
||||||
|
const [companyLogo, setCompanyLogo] = useState<StoredImage | null>(null);
|
||||||
const [formData, setFormData] = useState<OnboardingData>({
|
const [formData, setFormData] = useState<OnboardingData>({
|
||||||
companyName: org?.name || '',
|
companyName: org?.name || '',
|
||||||
yourName: '',
|
yourName: '',
|
||||||
@@ -174,6 +176,10 @@ const Onboarding: React.FC = () => {
|
|||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageUploaded = (image: StoredImage | null) => {
|
||||||
|
setCompanyLogo(image);
|
||||||
|
};
|
||||||
|
|
||||||
const canProceed = () => {
|
const canProceed = () => {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: // Company Details
|
case 0: // Company Details
|
||||||
@@ -365,6 +371,10 @@ const Onboarding: React.FC = () => {
|
|||||||
? 'Complete Setup'
|
? 'Complete Setup'
|
||||||
: 'Next'
|
: 'Next'
|
||||||
}
|
}
|
||||||
|
// Image upload props
|
||||||
|
orgId={org?.orgId}
|
||||||
|
onImageUploaded={handleImageUploaded}
|
||||||
|
currentImage={companyLogo}
|
||||||
>
|
>
|
||||||
{renderStepContent()}
|
{renderStepContent()}
|
||||||
</EnhancedFigmaQuestion>
|
</EnhancedFigmaQuestion>
|
||||||
|
|||||||
179
services/imageStorageService.ts
Normal file
179
services/imageStorageService.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { doc, setDoc, getDoc, updateDoc, deleteDoc } from 'firebase/firestore';
|
||||||
|
import { db } from './firebase';
|
||||||
|
import { processImage, validateImageFile, generateUniqueFileName, ProcessedImage } from '../utils/imageUtils';
|
||||||
|
|
||||||
|
export interface StoredImage {
|
||||||
|
id: string;
|
||||||
|
dataUrl: string;
|
||||||
|
filename: string;
|
||||||
|
originalSize: number;
|
||||||
|
compressedSize: number;
|
||||||
|
uploadedAt: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload and store an image in Firestore
|
||||||
|
* @param file - The image file to upload
|
||||||
|
* @param collectionName - Firestore collection name (e.g., 'company-logos')
|
||||||
|
* @param documentId - Document ID (e.g., orgId)
|
||||||
|
* @param maxWidth - Maximum width for resizing (default: 128)
|
||||||
|
* @param maxHeight - Maximum height for resizing (default: 128)
|
||||||
|
* @returns Promise with stored image data
|
||||||
|
*/
|
||||||
|
export const uploadImage = async (
|
||||||
|
file: File,
|
||||||
|
collectionName: string,
|
||||||
|
documentId: string,
|
||||||
|
maxWidth: number = 128,
|
||||||
|
maxHeight: number = 128
|
||||||
|
): Promise<StoredImage> => {
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('Firebase not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the image file
|
||||||
|
const validation = validateImageFile(file);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(validation.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the image
|
||||||
|
const processedImage: ProcessedImage = await processImage(file, maxWidth, maxHeight);
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const filename = generateUniqueFileName(file.name, 'logo');
|
||||||
|
|
||||||
|
// Create image data to store
|
||||||
|
const imageData: StoredImage = {
|
||||||
|
id: filename,
|
||||||
|
dataUrl: processedImage.dataUrl,
|
||||||
|
filename,
|
||||||
|
originalSize: processedImage.originalSize,
|
||||||
|
compressedSize: processedImage.compressedSize,
|
||||||
|
uploadedAt: Date.now(),
|
||||||
|
width: processedImage.width,
|
||||||
|
height: processedImage.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in Firestore
|
||||||
|
const docRef = doc(db, collectionName, documentId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get existing document to preserve other data
|
||||||
|
const existingDoc = await getDoc(docRef);
|
||||||
|
|
||||||
|
if (existingDoc.exists()) {
|
||||||
|
// Update existing document
|
||||||
|
await updateDoc(docRef, {
|
||||||
|
logo: imageData,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new document
|
||||||
|
await setDoc(docRef, {
|
||||||
|
logo: imageData,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to store image in Firestore:', error);
|
||||||
|
throw new Error('Failed to upload image');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve an image from Firestore
|
||||||
|
* @param collectionName - Firestore collection name
|
||||||
|
* @param documentId - Document ID
|
||||||
|
* @returns Promise with stored image data or null if not found
|
||||||
|
*/
|
||||||
|
export const getImage = async (
|
||||||
|
collectionName: string,
|
||||||
|
documentId: string
|
||||||
|
): Promise<StoredImage | null> => {
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('Firebase not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const docRef = doc(db, collectionName, documentId);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
|
|
||||||
|
if (docSnap.exists()) {
|
||||||
|
const data = docSnap.data();
|
||||||
|
return data.logo || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to retrieve image from Firestore:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an image from Firestore
|
||||||
|
* @param collectionName - Firestore collection name
|
||||||
|
* @param documentId - Document ID
|
||||||
|
* @returns Promise indicating success
|
||||||
|
*/
|
||||||
|
export const deleteImage = async (
|
||||||
|
collectionName: string,
|
||||||
|
documentId: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('Firebase not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const docRef = doc(db, collectionName, documentId);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
|
|
||||||
|
if (docSnap.exists()) {
|
||||||
|
const data = docSnap.data();
|
||||||
|
if (data.logo) {
|
||||||
|
// Remove only the logo field, keep other data
|
||||||
|
const updatedData = { ...data };
|
||||||
|
delete updatedData.logo;
|
||||||
|
updatedData.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await updateDoc(docRef, updatedData);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete image from Firestore:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company-specific image upload (convenience function)
|
||||||
|
*/
|
||||||
|
export const uploadCompanyLogo = async (
|
||||||
|
file: File,
|
||||||
|
orgId: string
|
||||||
|
): Promise<StoredImage> => {
|
||||||
|
return uploadImage(file, 'company-logos', orgId, 128, 128);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get company logo (convenience function)
|
||||||
|
*/
|
||||||
|
export const getCompanyLogo = async (orgId: string): Promise<StoredImage | null> => {
|
||||||
|
return getImage('company-logos', orgId);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete company logo (convenience function)
|
||||||
|
*/
|
||||||
|
export const deleteCompanyLogo = async (orgId: string): Promise<boolean> => {
|
||||||
|
return deleteImage('company-logos', orgId);
|
||||||
|
};
|
||||||
157
utils/imageUtils.ts
Normal file
157
utils/imageUtils.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Image processing utilities for resizing and encoding images
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ProcessedImage {
|
||||||
|
dataUrl: string;
|
||||||
|
blob: Blob;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
originalSize: number;
|
||||||
|
compressedSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize an image to a specific size and convert to base64
|
||||||
|
* @param file - The image file to process
|
||||||
|
* @param maxWidth - Maximum width (default: 128)
|
||||||
|
* @param maxHeight - Maximum height (default: 128)
|
||||||
|
* @param quality - JPEG quality (0-1, default: 0.8)
|
||||||
|
* @returns Promise with processed image data
|
||||||
|
*/
|
||||||
|
export const processImage = async (
|
||||||
|
file: File,
|
||||||
|
maxWidth: number = 128,
|
||||||
|
maxHeight: number = 128,
|
||||||
|
quality: number = 0.8
|
||||||
|
): Promise<ProcessedImage> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
reject(new Error('File must be an image'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Calculate dimensions to maintain aspect ratio
|
||||||
|
let { width, height } = calculateDimensions(
|
||||||
|
img.width,
|
||||||
|
img.height,
|
||||||
|
maxWidth,
|
||||||
|
maxHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
// Draw and resize image
|
||||||
|
ctx!.imageSmoothingEnabled = true;
|
||||||
|
ctx!.imageSmoothingQuality = 'high';
|
||||||
|
ctx!.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Convert to blob and data URL
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('Failed to process image'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', quality);
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
dataUrl,
|
||||||
|
blob,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
originalSize: file.size,
|
||||||
|
compressedSize: blob.size,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'image/jpeg',
|
||||||
|
quality
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load the image
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate dimensions to fit within max bounds while maintaining aspect ratio
|
||||||
|
*/
|
||||||
|
const calculateDimensions = (
|
||||||
|
originalWidth: number,
|
||||||
|
originalHeight: number,
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number
|
||||||
|
): { width: number; height: number } => {
|
||||||
|
const aspectRatio = originalWidth / originalHeight;
|
||||||
|
|
||||||
|
let width = maxWidth;
|
||||||
|
let height = maxHeight;
|
||||||
|
|
||||||
|
if (originalWidth > originalHeight) {
|
||||||
|
// Landscape
|
||||||
|
height = width / aspectRatio;
|
||||||
|
if (height > maxHeight) {
|
||||||
|
height = maxHeight;
|
||||||
|
width = height * aspectRatio;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Portrait or square
|
||||||
|
width = height * aspectRatio;
|
||||||
|
if (width > maxWidth) {
|
||||||
|
width = maxWidth;
|
||||||
|
height = width / aspectRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.round(width),
|
||||||
|
height: Math.round(height),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate image file
|
||||||
|
*/
|
||||||
|
export const validateImageFile = (file: File): { valid: boolean; error?: string } => {
|
||||||
|
// Check file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
return { valid: false, error: 'File must be an image' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size (max 10MB)
|
||||||
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return { valid: false, error: 'Image must be smaller than 10MB' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check supported formats
|
||||||
|
const supportedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
if (!supportedTypes.includes(file.type)) {
|
||||||
|
return { valid: false, error: 'Supported formats: JPEG, PNG, GIF, WebP' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique filename
|
||||||
|
*/
|
||||||
|
export const generateUniqueFileName = (originalName: string, prefix: string = 'img'): string => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
|
const extension = originalName.split('.').pop() || 'jpg';
|
||||||
|
return `${prefix}_${timestamp}_${random}.${extension}`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user