diff --git a/src/components/features/stampbook/stamp-dialog.tsx b/src/components/features/stampbook/stamp-dialog.tsx index c0fcc9e..e69345c 100644 --- a/src/components/features/stampbook/stamp-dialog.tsx +++ b/src/components/features/stampbook/stamp-dialog.tsx @@ -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"; @@ -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(), @@ -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 { - 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 { + const hackathonSlug = getHackathonType(hackathonId); + const qrContent = `${PORTAL_BASE_URL}/${hackathonSlug}/stampbook?unlockStamp=${stampId}`; const dataUrl = await QRCode.toDataURL(qrContent, { width: 512, @@ -105,6 +104,10 @@ export function StampDialog({ open, activeStamp, onClose }: StampDialogProps) { const [imagePreview, setImagePreview] = useState(null); const [imageError, setImageError] = useState(null); + const [lockedImageFile, setLockedImageFile] = useState(null); + const [lockedImagePreview, setLockedImagePreview] = useState(null); + const [lockedImageError, setLockedImageError] = useState(null); + const [criteria, setCriteria] = useState([]); useEffect(() => { @@ -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); } @@ -162,10 +168,40 @@ export function StampDialog({ open, activeStamp, onClose }: StampDialogProps) { setImageError(null); }; + const handleLockedImageSelect = (e: React.ChangeEvent) => { + 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(); @@ -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, ); @@ -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, ); @@ -281,6 +321,15 @@ export function StampDialog({ open, activeStamp, onClose }: StampDialogProps) { altText="Stamp image preview" /> + + ( Hackathon - - + - Global {[...new Map(hackathons.map((h) => [h._id, h])).values()].map((h) => ( {h._id} diff --git a/src/lib/firebase/storage.ts b/src/lib/firebase/storage.ts index 5ed1135..cb04bfd 100644 --- a/src/lib/firebase/storage.ts +++ b/src/lib/firebase/storage.ts @@ -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. diff --git a/src/lib/firebase/types.ts b/src/lib/firebase/types.ts index 6bbadf0..df99c7a 100644 --- a/src/lib/firebase/types.ts +++ b/src/lib/firebase/types.ts @@ -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; diff --git a/src/services/stamps.ts b/src/services/stamps.ts index fbe6e6c..92ee6d7 100644 --- a/src/services/stamps.ts +++ b/src/services/stamps.ts @@ -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"; @@ -11,10 +13,12 @@ import { Timestamp, collection, deleteDoc, + deleteField, doc, onSnapshot, query, runTransaction, + updateDoc, } from "firebase/firestore"; /** @@ -36,8 +40,9 @@ 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 @@ -45,6 +50,7 @@ export const subscribeToStamps = (callback: (docs: Stamp[]) => void) => export const upsertStampWithImage = async ( stamp: Stamp, imageFile?: File | null, + lockedImageFile?: File | null, qrBlob?: Blob | null, id?: string, ): Promise => { @@ -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, { @@ -90,7 +103,8 @@ 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); @@ -98,3 +112,20 @@ export const deleteStampWithImage = async (stampId: string) => { } }; +/** + * 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; + } +}; +