Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 68 additions & 23 deletions src/components/features/stampbook/stamp-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { Textarea } from "@/components/ui/textarea";
import { subscribeToHackathons } from "@/lib/firebase/firestore";
import type { FilterRowsSelection, Hackathon, Stamp } from "@/lib/firebase/types";
import { getHackathonType } from "@/lib/utils";
import { deleteStampWithImage, upsertStampWithImage } from "@/services/stamps";
import { deleteStampQR, deleteStampWithImage, upsertStampWithImage } from "@/services/stamps";
import { zodResolver } from "@hookform/resolvers/zod";
import { Download, Loader2 } from "lucide-react";
import QRCode from "qrcode";
Expand All @@ -50,7 +50,7 @@ const EMPTY_FORM = {
const formSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").max(100),
description: z.string().min(2, "Description must be at least 2 characters").max(500),
hackathon: z.string().optional(),
hackathon: z.string().min(1, "Please select a hackathon"),
isHidden: z.boolean(),
isTitle: z.boolean(),
isQRUnlockable: z.boolean(),
Expand All @@ -75,13 +75,12 @@ const PORTAL_BASE_URL = "https://portal.nwplus.io";

/**
* Generate a QR code as a Blob
* URL format: https://portal.nwplus.io/{hackathon}/stampbook?unlockStamp={stampId}
* TODO: will be https://portal.nwplus.io/stampbook?unlockStamp={stampId} for global stamps now; portal currently does not support this
* URL format: https://portal.nwplus.io/{hackathonSlug}/stampbook?unlockStamp={stampId}
* hackathonSlug is one of: hackcamp, nwhacks, cmd-f
*/
async function generateQRBlob(stampId: string, hackathonId?: string): Promise<Blob> {
const hackathonSlug = hackathonId ? getHackathonType(hackathonId) : undefined;
const path = hackathonSlug ? `/${hackathonSlug}/stampbook` : "/stampbook";
const qrContent = `${PORTAL_BASE_URL}${path}?unlockStamp=${stampId}`;
async function generateQRBlob(stampId: string, hackathonId: string): Promise<Blob> {
const hackathonSlug = getHackathonType(hackathonId);
const qrContent = `${PORTAL_BASE_URL}/${hackathonSlug}/stampbook?unlockStamp=${stampId}`;

const dataUrl = await QRCode.toDataURL(qrContent, {
width: 512,
Expand All @@ -105,6 +104,10 @@ export function StampDialog({ open, activeStamp, onClose }: StampDialogProps) {
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageError, setImageError] = useState<string | null>(null);

const [lockedImageFile, setLockedImageFile] = useState<File | null>(null);
const [lockedImagePreview, setLockedImagePreview] = useState<string | null>(null);
const [lockedImageError, setLockedImageError] = useState<string | null>(null);

const [criteria, setCriteria] = useState<FilterRowsSelection[]>([]);

useEffect(() => {
Expand All @@ -118,6 +121,9 @@ export function StampDialog({ open, activeStamp, onClose }: StampDialogProps) {
if (activeStamp?.imgURL) {
setImagePreview(activeStamp.imgURL);
}
if (activeStamp?.lockedImgURL) {
setLockedImagePreview(activeStamp.lockedImgURL);
}
if (activeStamp?.criteria) {
setCriteria(activeStamp.criteria);
}
Expand Down Expand Up @@ -162,10 +168,40 @@ export function StampDialog({ open, activeStamp, onClose }: StampDialogProps) {
setImageError(null);
};

const handleLockedImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

const validation = imageSchema.safeParse(file);
if (!validation.success) {
setLockedImageError(validation.error.errors[0].message);
return;
}

setLockedImageError(null);
setLockedImageFile(file);

const reader = new FileReader();
reader.onload = (e) => {
setLockedImagePreview(e.target?.result as string);
};
reader.readAsDataURL(file);
};

const handleLockedImageRemove = () => {
setLockedImageFile(null);
setLockedImagePreview(null);
setLockedImageError(null);
};

const close = () => {
setImageFile(null);
setImagePreview(null);
setImageError(null);

setLockedImageFile(null);
setLockedImagePreview(null);
setLockedImageError(null);
setCriteria([]);
form.reset(EMPTY_FORM);
onClose();
Expand All @@ -191,27 +227,30 @@ export function StampDialog({ open, activeStamp, onClose }: StampDialogProps) {
imgName: imageFile?.name || activeStamp?.imgName || "",
};

if (values.hackathon) {
stampData.hackathon = values.hackathon;
}
stampData.hackathon = values.hackathon;
if (criteria.length > 0) {
stampData.criteria = criteria;
}

// Generate QR blob if isQRUnlockable is true and this is a new stamp
// or if it's being enabled for the first time
// Generate QR blob if isQRUnlockable is being enabled for the first time
// else delete QR if isQRUnlockable is being disabled and a QR exists
let qrBlob: Blob | null = null;
const stampId = activeStamp?._id;
const needsNewQR = values.isQRUnlockable && (!activeStamp || !activeStamp.isQRUnlockable);
const hackathonForQR = values.hackathon || undefined;
const needsQRDeletion = !values.isQRUnlockable && activeStamp?.isQRUnlockable && activeStamp?.qrURL;

if (needsNewQR && stampId) {
qrBlob = await generateQRBlob(stampId, hackathonForQR);
qrBlob = await generateQRBlob(stampId, values.hackathon);
}

if (needsQRDeletion && stampId) {
await deleteStampQR(stampId);
}

const upsertedStamp = await upsertStampWithImage(
stampData,
imageFile,
lockedImageFile,
qrBlob,
activeStamp?._id,
);
Expand All @@ -220,10 +259,11 @@ export function StampDialog({ open, activeStamp, onClose }: StampDialogProps) {

if (values.isQRUnlockable && !activeStamp?._id) {
const newStampId = upsertedStamp.id;
const newQrBlob = await generateQRBlob(newStampId, hackathonForQR);
const newQrBlob = await generateQRBlob(newStampId, values.hackathon);
await upsertStampWithImage(
{ ...stampData, isQRUnlockable: true },
null,
null,
newQrBlob,
newStampId,
);
Expand Down Expand Up @@ -281,6 +321,15 @@ export function StampDialog({ open, activeStamp, onClose }: StampDialogProps) {
altText="Stamp image preview"
/>

<ImageUpload
label="Locked Image"
imagePreview={lockedImagePreview}
imageError={lockedImageError}
onImageSelect={handleLockedImageSelect}
onImageRemove={handleLockedImageRemove}
altText="Locked stamp image preview"
/>

<FormField
control={form.control}
name="name"
Expand Down Expand Up @@ -315,17 +364,13 @@ export function StampDialog({ open, activeStamp, onClose }: StampDialogProps) {
render={({ field }) => (
<FormItem>
<FormLabel>Hackathon</FormLabel>
<Select
onValueChange={(value) => field.onChange(value === "__global__" ? "" : value)}
value={field.value || "__global__"}
>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Global" />
<SelectValue placeholder="Select a hackathon" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="__global__">Global</SelectItem>
{[...new Map(hackathons.map((h) => [h._id, h])).values()].map((h) => (
<SelectItem key={h._id} value={h._id}>
{h._id}
Expand Down
27 changes: 27 additions & 0 deletions src/lib/firebase/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,33 @@ export const deleteStampImage = async (stampId: string) => {
}
};

/**
* Uploads a locked stamp image to storage (this is what the user sees before unlocking the stamp)
* @param stampId - The stamp document's ID used for naming
* @param file - The image file
* @returns a downloadable url of the image just uploaded
*/
export const uploadLockedStampImage = async (stampId: string, file: File) => {
try {
const imageRef = ref(storage, `stampImages/${stampId}_locked`);
await uploadBytes(imageRef, file);
return await getDownloadURL(imageRef);
} catch (error) {
console.error("Error uploading locked stamp image", stampId, error);
return null;
}
};

export const deleteLockedStampImage = async (stampId: string) => {
try {
const imageRef = ref(storage, `stampImages/${stampId}_locked`);
await deleteObject(imageRef);
} catch (error: unknown) {
if ((error as FirebaseError)?.code === "storage/object-not-found") return;
console.error("Error deleting locked stamp image", stampId, error);
}
};

/**
* Uploads a stamp QR code to storage.
* The generated QR code points to the portal, where the logic for unlocking stamps is handled.
Expand Down
1 change: 1 addition & 0 deletions src/lib/firebase/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ export interface Stamp {
description: string;
imgURL?: string;
imgName?: string;
lockedImgURL?: string;
hackathon?: string; // for stamps unlockable in specific hackathons
criteria?: FilterRowsSelection[];
isHidden: boolean;
Expand Down
39 changes: 35 additions & 4 deletions src/services/stamps.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { auth, db } from "@/lib/firebase/client";
import {
deleteLockedStampImage,
deleteStampImage,
deleteStampQR,
deleteStampQR as deleteStampQRFromStorage,
uploadLockedStampImage,
uploadStampImage,
uploadStampQR,
} from "@/lib/firebase/storage";
Expand All @@ -11,10 +13,12 @@ import {
Timestamp,
collection,
deleteDoc,
deleteField,
doc,
onSnapshot,
query,
runTransaction,
updateDoc,
} from "firebase/firestore";

/**
Expand All @@ -36,15 +40,17 @@ export const subscribeToStamps = (callback: (docs: Stamp[]) => void) =>

/**
* Utility function that updates or adds a stamp document, depending on if an id argument is passed
* @param stamp - the stamp to update or insert
* @param imageFile - optional image to upsert
* @param stamp - the stamp to upsert
* @param imageFile - optional stamp image
* @param lockedImageFile - optional image for stamp in locked state
* @param qrBlob - optional QR blob to upload
* @param id - optional existing stamp ID for updates
* @returns the upsert stamp document ref
*/
export const upsertStampWithImage = async (
stamp: Stamp,
imageFile?: File | null,
lockedImageFile?: File | null,
qrBlob?: Blob | null,
id?: string,
): Promise<DocumentReference | null> => {
Expand All @@ -68,6 +74,13 @@ export const upsertStampWithImage = async (
});
}

if (lockedImageFile) {
const lockedImageUrl = await uploadLockedStampImage(stampId, lockedImageFile);
transaction.update(stampRef, {
lockedImgURL: lockedImageUrl,
});
}

if (qrBlob) {
const qrUrl = await uploadStampQR(stampId, qrBlob);
transaction.update(stampRef, {
Expand All @@ -90,11 +103,29 @@ export const upsertStampWithImage = async (
export const deleteStampWithImage = async (stampId: string) => {
try {
await deleteStampImage(stampId);
await deleteStampQR(stampId);
await deleteLockedStampImage(stampId);
await deleteStampQRFromStorage(stampId);
await deleteDoc(doc(db, "Stamps", stampId));
} catch (error) {
console.error("Error deleting stamp:", error);
throw error;
}
};

/**
* Delete a stamp's QR code from storage and remove the qrURL field from Firestore
* @param stampId - the ID of the stamp whose QR should be deleted
*/
export const deleteStampQR = async (stampId: string) => {
try {
await deleteStampQRFromStorage(stampId);
const stampRef = doc(db, "Stamps", stampId);
await updateDoc(stampRef, {
qrURL: deleteField(),
});
} catch (error) {
console.error("Error deleting stamp QR:", error);
throw error;
}
};