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
54 changes: 50 additions & 4 deletions backend/src/mongodb/company.go
Original file line number Diff line number Diff line change
Expand Up @@ -911,10 +911,10 @@ func (c *CompaniesType) UpdateCompanyParticipationStatus(companyID primitive.Obj

// UpdateCompanyData is the data used to update a company, using the method UpdateCompany.
type UpdateCompanyData struct {
Name *string `json:"name"`
Description *string `json:"description"`
Site *string `json:"site"`
LinkedIn *string `json:"linkedin"`
Name *string `json:"name"`
Description *string `json:"description"`
Site *string `json:"site"`
LinkedIn *string `json:"linkedin"`
BillingInfo *models.CompanyBillingInfo `json:"billingInfo"`
}

Expand Down Expand Up @@ -1434,6 +1434,52 @@ func (c *CompaniesType) DeleteCompanyParticipation(companyID primitive.ObjectID,
return &updatedCompany, nil
}

// AnnounceAcceptedCompanies changes all companies with ACCEPTED status on the
// current event to ANNOUNCED. Returns the number of companies updated.
func (c *CompaniesType) AnnounceAcceptedCompanies() (int64, error) {
ctx := context.Background()

currentEvent, err := Events.GetCurrentEvent()
if err != nil {
return 0, err
}

filter := bson.M{
"participations": bson.M{
"$elemMatch": bson.M{
"event": currentEvent.ID,
"status": models.Accepted,
},
},
}

update := bson.M{
"$set": bson.M{
"participations.$[elem].status": models.Announced,
},
}

arrayFilters := options.ArrayFilters{
Filters: []interface{}{
bson.M{
"elem.event": currentEvent.ID,
"elem.status": models.Accepted,
},
},
}

opts := options.Update().SetArrayFilters(arrayFilters)

result, err := c.Collection.UpdateMany(ctx, filter, update, opts)
if err != nil {
return 0, err
}

ResetCurrentPublicCompanies()

return result.ModifiedCount, nil
}

// FindThread finds a thread in a company
func (c *CompaniesType) FindThread(threadID primitive.ObjectID) (*models.Company, error) {

Expand Down
14 changes: 14 additions & 0 deletions backend/src/router/company.go
Original file line number Diff line number Diff line change
Expand Up @@ -1454,3 +1454,17 @@ func syncCompanyGmailMessages(w http.ResponseWriter, r *http.Request) {
"total": len(data.Messages),
})
}

// announceAcceptedCompanies changes all companies with ACCEPTED participation
// on the current event to ANNOUNCED. Coordinator-only.
func announceAcceptedCompanies(w http.ResponseWriter, r *http.Request) {
count, err := mongodb.Companies.AnnounceAcceptedCompanies()
if err != nil {
http.Error(w, "Could not announce accepted companies: "+err.Error(), http.StatusExpectationFailed)
return
}

json.NewEncoder(w).Encode(map[string]interface{}{
"announced": count,
})
}
1 change: 1 addition & 0 deletions backend/src/router/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ func InitializeRouter() {
companyRouter.HandleFunc("/{id}", authCoordinator(deleteCompany)).Methods("DELETE")
// query companies by multiple member IDs
companyRouter.HandleFunc("/byMembers", authMember(getCompaniesByMembers)).Methods("POST")
companyRouter.HandleFunc("/announce", authCoordinator(announceAcceptedCompanies)).Methods("POST")
companyRouter.HandleFunc("/{id}/subscribe", authMember(subscribeToCompany)).Methods("PUT")
companyRouter.HandleFunc("/{id}/unsubscribe", authMember(unsubscribeToCompany)).Methods("PUT")
companyRouter.HandleFunc("/{id}/image/internal", authMember(setCompanyPrivateImage)).Methods("POST")
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<!-- <AuthLoader v-if="authStore.isInitializing" /> -->
<router-view />

<GlobalConfetti />
<ToastContainer />
<PiniaColadaDevtools />
</template>
Expand All @@ -14,6 +15,7 @@
import { PiniaColadaDevtools } from "@pinia/colada-devtools";
import { useAuthStore } from "@/stores/auth";
import ToastContainer from "@/components/ui/toast/ToastContainer.vue";
import GlobalConfetti from "@/components/GlobalConfetti.vue";

const authStore = useAuthStore();

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/companies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,6 @@ export const generateCompanyContract = (
responseType: "blob",
});
};

export const announceAcceptedCompanies = () =>
instance.post<{ announced: number }>("/companies/announce");
26 changes: 26 additions & 0 deletions frontend/src/components/GlobalConfetti.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<template>
<Teleport to="body">
<div
v-if="showConfetti"
class="fixed left-1/2 top-1/3 pointer-events-none z-[9999] -translate-x-1/2 -translate-y-1/2"
>
<ConfettiExplosion
:particle-count="500"
:particle-size="10"
:duration="2500"
:force="0.5"
:stage-height="height"
:stage-width="width"
/>
</div>
</Teleport>
</template>

<script setup lang="ts">
import ConfettiExplosion from "vue-confetti-explosion";
import { useConfetti } from "@/composables/useConfetti";
import { useWindowSize } from "@vueuse/core";

const { showConfetti } = useConfetti();
const { width, height } = useWindowSize();
</script>
34 changes: 5 additions & 29 deletions frontend/src/components/cards/WorkflowCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
import { computed, watch, ref, type Component } from "vue";
import { computed, watch, type Component } from "vue";
import {
humanReadableParticipationStatus,
participationNextValues,
participationStatusColor,
type ParticipationStatus,
} from "@/dto";
import type { RouteLocationRaw } from "vue-router";
import ConfettiExplosion from "vue-confetti-explosion";
import confettiAudio from "@/assets/audio/confetti.mp3";
import { useConfetti } from "@/composables/useConfetti";
import {
Tooltip,
TooltipContent,
Expand All @@ -42,19 +41,8 @@ const props = defineProps<{
to?: RouteLocationRaw;
}>();

// Track confetti state
const showConfetti = ref(false);

// Function to play confetti sound
const playConfettiSound = () => {
try {
const audio = new Audio(confettiAudio);
audio.volume = 0.5; // Set volume to 50%
audio.play().catch(console.error);
} catch (error) {
console.error("Error playing confetti sound:", error);
}
};
// Singleton confetti – only one explosion + sound across all cards
const { trigger: triggerConfetti } = useConfetti();

const selectedStatus = computed({
get: () => props.currentStatus || "SUGGESTED",
Expand All @@ -77,12 +65,7 @@ watch(
(newStatus, oldStatus) => {
if (oldStatus && newStatus && oldStatus !== newStatus) {
if (newStatus === "ACCEPTED" || newStatus === "ANNOUNCED") {
showConfetti.value = true;
playConfettiSound(); // Play the confetti sound
// Reset confetti after animation
setTimeout(() => {
showConfetti.value = false;
}, 4000);
triggerConfetti();
}
}
},
Expand All @@ -104,13 +87,6 @@ watch(
participationStatusColor[selectedStatus].ring,
]"
>
<div
v-if="showConfetti"
class="absolute left-1/2 top-1/2 pointer-events-none z-50 -translate-x-1/2 -translate-y-1/2"
>
<ConfettiExplosion />
</div>

<CardContent>
<div
v-if="!possibleStates.length"
Expand Down
89 changes: 87 additions & 2 deletions frontend/src/components/companies/MembersCompanies.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
<template>
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Companies</h1>
<CreateCompanyDialogTrigger />
<div class="flex items-center gap-2">
<AlertDialog v-if="isCoordinator" v-model:open="showAnnounceDialog">
<AlertDialogTrigger as-child>
<Button
size="sm"
variant="outline"
:disabled="announcing || acceptedCount === 0"
>
<Megaphone class="w-4 h-4 mr-1" />
Announce All ({{ acceptedCount }})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Announce All Accepted Companies</AlertDialogTitle>
<AlertDialogDescription>
This will change
<strong>{{ acceptedCount }}</strong> accepted
{{ acceptedCount === 1 ? "company" : "companies" }} to announced
for the current event. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel :disabled="announcing">Cancel</AlertDialogCancel>
<Button :disabled="announcing" @click="handleAnnounce">
{{ announcing ? "Announcing..." : "Announce All" }}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CreateCompanyDialogTrigger />
</div>
</div>

<div class="flex flex-wrap gap-3 mb-4 items-center">
Expand Down Expand Up @@ -94,7 +125,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import CompanyWorkflowCard from "../cards/CompanyWorkflowCard.vue";
import CreateCompanyDialogTrigger from "./CreateCompanyDialogTrigger.vue";
import { ref, computed, type ComputedRef } from "vue";
import { ChevronDown } from "lucide-vue-next";
import { ChevronDown, Megaphone } from "lucide-vue-next";
import { useParticipationFilter } from "@/composables/useParticipationFilter";
import type { ObjectID, ParticipationStatus } from "@/dto";
import ParticipationFilters from "@/components/ParticipationFilters.vue";
Expand All @@ -106,6 +137,21 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useQueryCache } from "@pinia/colada";
import { announceAcceptedCompanies } from "@/api/companies";
import { useToast } from "@/lib/toast";
import Button from "@/components/ui/button/Button.vue";
import { usePermissions } from "@/composables/usePermissions";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";

const props = defineProps<{
companies: Company[];
Expand All @@ -115,6 +161,45 @@ const props = defineProps<{
coordinationTeams?: CoordinationTeam[];
}>();

// Coordinator check
const { isCoordinatorOrAdmin: isCoordinator } = usePermissions();

// Count of accepted companies in current event
const acceptedCount = computed(() => {
return props.companies.filter((c) =>
c.participations.some(
(p) => p.event === props.eventId && p.status === "ACCEPTED",
),
).length;
});

// Announce all accepted companies
const showAnnounceDialog = ref(false);
const announcing = ref(false);
const queryCache = useQueryCache();
const { toast } = useToast();

async function handleAnnounce() {
announcing.value = true;
try {
const res = await announceAcceptedCompanies();
const count = res.data.announced;
toast.success({
title: "Companies announced",
description: `${count} ${count === 1 ? "company" : "companies"} changed from accepted to announced.`,
});
queryCache.invalidateQueries({ key: ["companies"] });
} catch (err) {
toast.error({
title: "Failed to announce companies",
description: err instanceof Error ? err.message : "An error occurred",
});
} finally {
announcing.value = false;
showAnnounceDialog.value = false;
}
}

// TODO shift me to top
const membersSorted = computed(() => {
const sorted = [...props.members]?.sort((a, b) =>
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/composables/useConfetti.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ref } from "vue";
import confettiAudio from "@/assets/audio/confetti.mp3";

const showConfetti = ref(false);
let confettiTimeout: ReturnType<typeof setTimeout> | null = null;

/**
* Singleton confetti composable.
*
* No matter how many cards change status at once, only a single confetti
* explosion + sound will play. Subsequent calls while an explosion is
* already active are silently ignored.
*/
export function useConfetti() {
function trigger() {
// Already showing – skip
if (showConfetti.value) return;

showConfetti.value = true;

try {
const audio = new Audio(confettiAudio);
audio.volume = 0.5;
audio.play().catch(console.error);
} catch (error) {
console.error("Error playing confetti sound:", error);
}

if (confettiTimeout) clearTimeout(confettiTimeout);
confettiTimeout = setTimeout(() => {
showConfetti.value = false;
confettiTimeout = null;
}, 3000);
}

return { showConfetti, trigger };
}
Loading