Skip to content

Commit 051f23c

Browse files
Campaign settings & creation rework (#670)
* new campaign settings page * local dev needs * remove shadow from inputs and buttons * new campaign settings page * change attachments icon to `Files` * handle slug check * Update slug-input.tsx * add dummy `SMTP_PORT` in build workflow
1 parent 732fad1 commit 051f23c

16 files changed

Lines changed: 667 additions & 51 deletions

File tree

.github/workflows/rust.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ jobs:
4545
echo "SMTP_USERNAME=test_username" >> backend/.env
4646
echo "SMTP_PASSWORD=test_password" >> backend/.env
4747
echo "SMTP_HOST=smtp.example.com" >> backend/.env
48+
echo "SMTP_PORT=587" >> backend/.env
4849
echo "CAMPAIGN_NAME_MAX_CHARS=50" >> backend/.env
4950
echo "CAMPAIGN_DESCRIPTION_MAX_ChARS=500" >> backend/.env
5051
echo "ROLE_NAME_MAX_CHARS=20" >> backend/.env

backend/server/src/handler/auth.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ pub async fn google_callback(
125125
.http_only(true) // Prevent JavaScript access
126126
.expires(Expiration::DateTime(OffsetDateTime::now_utc() + time::Duration::days(5))) // Set an expiration time of 5 days, TODO: read from env?
127127
.secure(!state.is_dev_env) // Send only over HTTPS, comment out for testing
128-
.domain("devsoc.cn")
128+
// .domain("devsoc.cn")
129129
.path("/"); // Available for all paths
130130

131131
let redirect_root = if state.is_dev_env {

frontend-nextjs/src/app/[lang]/campaign/[orgSlug]/[campaignSlug]/campaign-info.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
import { useQuery } from "@tanstack/react-query";
44
import { getCampaignBySlugs, getCampaignAttachments, getCampaignRoles } from "@/models/campaign";
55

6-
import { Calendar, ExternalLink, Mail, FileText, Video, Clock, Users, Briefcase, Info, Phone, SquareUserRound } from "lucide-react";
7-
import { dateToString } from "@/lib/utils";
6+
import { Calendar, ExternalLink, Mail, FileText, Video, Clock, Users, Briefcase, Info, Phone, Files } from "lucide-react";
7+
import { dateToString } from "@/lib/utils";
88
import { Button } from "@/components/ui/button";
99
import { notFound, useParams } from "next/navigation";
1010
import Link from "next/link";
1111

12-
export default function CampaignInfo({ orgSlug, campaignSlug, dict }: { orgSlug: string, campaignSlug: string, dict: any, lang:string }) {
12+
export default function CampaignInfo({ orgSlug, campaignSlug, dict }: { orgSlug: string, campaignSlug: string, dict: any, lang: string }) {
1313
const params = useParams();
1414
const lang = params.lang
1515
const { data: campaignData } = useQuery({
@@ -173,20 +173,20 @@ export default function CampaignInfo({ orgSlug, campaignSlug, dict }: { orgSlug:
173173
{/* Attachments */}
174174
<div className="p-6">
175175
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-1">
176-
<SquareUserRound className="w-5 h-5" />
176+
<Files className="w-5 h-5" />
177177
{dict.common.attachments}
178178
</h3>
179179
<div>
180180
{attachmentsData && attachmentsData.length > 0 && attachmentsData.map(attachment => (
181181
<div>
182-
<a
183-
href={attachment.download_url}
184-
target="_blank"
182+
<a
183+
href={attachment.download_url}
184+
target="_blank"
185185
rel="noopener noreferrer"
186186
className="text-blue-600 hover:text-blue-800 underline flex items-center gap-2"
187187
>
188188
<FileText className="w-4 h-4" />
189-
{attachment.file_name} ({(attachment.file_size / 1024).toFixed(2)} KB)
189+
{attachment.file_name} ({(attachment.file_size / 1024).toFixed(2)} KB)
190190
</a>
191191
</div>
192192
))}

frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/campaign-details.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,9 @@ export default function CampaignDetails({ campaignId, orgId, dict }: { campaignI
599599
editingMode ? (
600600
<Button variant="outline" onClick={saveUpdatedCampaignDetails} className="cursor-pointer"><Check className="w-4 h-4" /> {dict.dashboard.actions.save}</Button>
601601
) : (
602-
<Button variant="outline" onClick={toggleEditingMode} className="cursor-pointer"><Pencil className="w-4 h-4" /> {dict.dashboard.actions.edit}</Button>
602+
<Link href={`/dashboard/organisation/${orgId}/campaigns/${campaignId}/edit`}>
603+
<Button variant="outline" className="cursor-pointer"><Pencil className="w-4 h-4" /> {dict.dashboard.actions.edit}</Button>
604+
</Link>
603605
)
604606
}
605607
<Button variant="outline" className="cursor-pointer"><Trash className="w-4 h-4" /> {dict.dashboard.actions.delete}</Button>
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
"use client";
2+
3+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
4+
import { CampaignUpdate, createCampaignRole, getCampaign, getCampaignRoles, updateCampaign, getCampaignAttachments, uploadAttachments, deleteCampaignAttachment, CampaignRole, RoleDetails } from "@/models/campaign";
5+
import { getRatingCategories, createCategory, updateCategory, deleteCategory, RatingCategory } from "@/models/rating";
6+
import { Button } from "@/components/ui/button";
7+
import { Copy, Pencil, Trash, Share, BookOpenCheck, Check, Plus, FormIcon, CircleCheck, Upload, X, FileText, BarChart, ArrowLeft } from "lucide-react";
8+
import { ButtonGroup } from "@/components/ui/button-group";
9+
import { cn } from "@/lib/utils";
10+
import {
11+
Table,
12+
TableBody,
13+
TableCell,
14+
TableHead,
15+
TableHeader,
16+
TableRow,
17+
} from "@/components/ui/table"
18+
import Link from "next/link";
19+
import { getOrganisationUserRole } from "@/models/organisation";
20+
import { useState } from "react";
21+
import { Input } from "@/components/ui/input";
22+
import { Label } from "@/components/ui/label";
23+
import { Textarea } from "@/components/ui/textarea";
24+
import SlugInput from "@/components/slug-input";
25+
import { DatePicker } from "@/components/ui/date-picker";
26+
import { snowflakeGenerator } from "@/lib";
27+
import { deleteRole, RoleUpdate, updateRole } from "@/models/role";
28+
29+
export default function CampaignSettings({ campaignId, orgId, dict }: { campaignId: string, orgId: string, dict: any }) {
30+
const queryClient = useQueryClient();
31+
32+
const { data: campaign } = useQuery({
33+
queryKey: [`${campaignId}-campaign-details`],
34+
queryFn: () => getCampaign(campaignId),
35+
});
36+
37+
const { data: roles } = useQuery({
38+
queryKey: [`${campaignId}-campaign-roles`],
39+
queryFn: () => getCampaignRoles(campaignId),
40+
});
41+
42+
const { data: ratingCategories } = useQuery({
43+
queryKey: [`${campaignId}-rating-categories`],
44+
queryFn: () => getRatingCategories(campaignId),
45+
});
46+
47+
const [campaignName, setCampaignName] = useState(campaign?.name ?? "");
48+
const [campaignSlug, setCampaignSlug] = useState(campaign?.campaign_slug ?? "");
49+
const [campaignDescription, setCampaignDescription] = useState(campaign?.description ?? "");
50+
const [campaignStartsAt, setCampaignStartsAt] = useState(new Date(campaign?.starts_at ?? "").toISOString());
51+
const [campaignEndsAt, setCampaignEndsAt] = useState(new Date(campaign?.ends_at ?? "").toISOString());
52+
53+
const { mutateAsync: mutateUpdateCampaignDetails } = useMutation({
54+
mutationFn: (data: CampaignUpdate) => updateCampaign(campaignId, data),
55+
onSuccess: () => {
56+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-campaign-details`] });
57+
},
58+
onError: () => {
59+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-campaign-details`] });
60+
},
61+
})
62+
63+
const { mutateAsync: mutateAddRole } = useMutation({
64+
mutationFn: (data: RoleDetails) => createCampaignRole(campaignId, data),
65+
onSuccess: () => {
66+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-campaign-roles`] });
67+
},
68+
onError: () => {
69+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-campaign-roles`] });
70+
},
71+
})
72+
73+
const { mutateAsync: mutateUpdateRole } = useMutation({
74+
mutationFn: ({ roleId, data }: { roleId: string, data: RoleUpdate }) => updateRole(roleId, data),
75+
onSuccess: () => {
76+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-campaign-roles`] });
77+
},
78+
onError: () => {
79+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-campaign-roles`] });
80+
},
81+
});
82+
83+
const { mutateAsync: mutateDeleteRole } = useMutation({
84+
mutationFn: (roleId: string) => deleteRole(roleId),
85+
onSuccess: () => {
86+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-campaign-roles`] });
87+
},
88+
onError: () => {
89+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-campaign-roles`] });
90+
},
91+
});
92+
93+
const { mutateAsync: mutateAddRatingCategory } = useMutation({
94+
mutationFn: (name: string) => createCategory(name, campaignId),
95+
onSuccess: () => {
96+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-rating-categories`] });
97+
},
98+
onError: () => {
99+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-rating-categories`] });
100+
},
101+
})
102+
103+
const { mutateAsync: mutateUpdateRatingCategory } = useMutation({
104+
mutationFn: ({ name, categoryId }: { name: string, categoryId: string }) => updateCategory(name, campaignId, categoryId),
105+
onSuccess: () => {
106+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-rating-categories`] });
107+
},
108+
onError: () => {
109+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-rating-categories`] });
110+
},
111+
});
112+
113+
const { mutateAsync: mutateDeleteRatingCategory } = useMutation({
114+
mutationFn: (categoryId: string) => deleteCategory(campaignId, categoryId),
115+
onSuccess: () => {
116+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-rating-categories`] });
117+
},
118+
onError: () => {
119+
queryClient.invalidateQueries({ queryKey: [`${campaignId}-rating-categories`] });
120+
},
121+
});
122+
123+
const handleCampaignDetailsUpdate = (overrides?: Partial<CampaignUpdate>) => {
124+
mutateUpdateCampaignDetails({
125+
name: campaignName,
126+
slug: campaignSlug,
127+
description: campaignDescription,
128+
starts_at: campaignStartsAt,
129+
ends_at: campaignEndsAt,
130+
...overrides,
131+
});
132+
}
133+
134+
135+
return (
136+
<div className="flex flex-col gap-10 max-w-2xl">
137+
<div>
138+
<Link href={`/dashboard/organisation/${orgId}/campaigns/${campaignId}`}>
139+
<div className="flex items-center gap-1">
140+
<ArrowLeft className="w-4 h-4" />
141+
{dict.common.back}
142+
</div>
143+
</Link>
144+
<h1 className="text-2xl font-bold">{dict.dashboard.campaigns.settings.campaign_settings}</h1>
145+
<p className="text-lg font-medium">{campaign?.name}</p>
146+
</div>
147+
148+
{/* General Settings */}
149+
<div className="flex flex-col gap-5">
150+
<h2 className="text-lg font-semibold">{dict.dashboard.campaigns.settings.general_settings}</h2>
151+
<div className="flex flex-col gap-1">
152+
<Label htmlFor="campaign-name">{dict.common.name}</Label>
153+
<Input value={campaignName} onChange={(e) => setCampaignName(e.target.value)} onBlur={() => handleCampaignDetailsUpdate()} />
154+
</div>
155+
156+
<div className="flex flex-col gap-1">
157+
<Label htmlFor="campaign-slug">{dict.common.slug}</Label>
158+
<SlugInput orgId={orgId} name={campaignName} value={campaignSlug} currentSlug={campaign?.campaign_slug} onChange={(value) => setCampaignSlug(value)} onBlur={handleCampaignDetailsUpdate} dict={dict} />
159+
</div>
160+
161+
<div className="flex flex-col gap-1">
162+
<Label htmlFor="campaign-description">{dict.common.description}</Label>
163+
<Textarea className="min-h-[300px]" value={campaignDescription} onChange={(e) => setCampaignDescription(e.target.value)} onBlur={() => handleCampaignDetailsUpdate()} />
164+
</div>
165+
166+
<div className="flex flex-col gap-1">
167+
<DatePicker label={dict.common.starts_at} value={campaignStartsAt} onChange={(value) => { setCampaignStartsAt(value); handleCampaignDetailsUpdate({ starts_at: value }) }} />
168+
</div>
169+
170+
<div className="flex flex-col gap-1">
171+
<DatePicker label={dict.common.ends_at} value={campaignEndsAt} onChange={(value) => { setCampaignEndsAt(value); handleCampaignDetailsUpdate({ ends_at: value }) }} />
172+
</div>
173+
</div>
174+
175+
{/* Role Settings */}
176+
<div className="flex flex-col gap-5">
177+
<div className="flex items-center justify-between">
178+
<h2 className="text-lg font-semibold">{dict.dashboard.campaigns.settings.role_settings}</h2>
179+
<Button
180+
variant="outline"
181+
size="sm"
182+
onClick={() => {
183+
const newRoleId = snowflakeGenerator.generate().toString();
184+
const newRole: RoleDetails = {
185+
id: newRoleId,
186+
campaign_id: campaignId,
187+
name: `New Role ${newRoleId}`,
188+
description: "",
189+
min_available: 1,
190+
max_available: 2,
191+
finalised: false,
192+
};
193+
mutateAddRole(newRole);
194+
}}
195+
>
196+
<Plus className="w-4 h-4 mr-1" />
197+
{dict.common.add}
198+
</Button>
199+
</div>
200+
{roles?.map((r) =>
201+
<RoleCard key={r.id} role={r} updateRole={mutateUpdateRole} deleteRole={mutateDeleteRole} dict={dict} />
202+
)}
203+
</div>
204+
205+
{/* Rating Settings */}
206+
<div className="flex flex-col gap-2">
207+
<div className="flex items-center justify-between">
208+
<h2 className="text-lg font-semibold">{dict.dashboard.campaigns.settings.rating_category_settings}</h2>
209+
<Button
210+
variant="outline"
211+
size="sm"
212+
onClick={() => {
213+
const newCategoryId = snowflakeGenerator.generate().toString();
214+
mutateAddRatingCategory(`New Category ${newCategoryId}`);
215+
}}
216+
>
217+
<Plus className="w-4 h-4 mr-1" />
218+
{dict.common.add}
219+
</Button>
220+
</div>
221+
{ratingCategories?.map((r) =>
222+
<RatingCategoryCard key={r.id} category={r} updateCategory={mutateUpdateRatingCategory} deleteCategory={mutateDeleteRatingCategory} />
223+
)}
224+
</div>
225+
226+
{/* Attachment Settings */}
227+
<div className="flex flex-col gap-2">
228+
<h2 className="text-lg font-semibold">{dict.common.attachments}</h2>
229+
<p>**In development**</p>
230+
</div>
231+
</div>
232+
);
233+
}
234+
235+
function RoleCard({ role, updateRole, deleteRole, dict }: { role: RoleDetails, updateRole: ({ roleId, data }: { roleId: string, data: RoleUpdate }) => void, deleteRole: (roleId: string) => void, dict: any }) {
236+
const [name, setName] = useState(role.name);
237+
const [minAvailable, setMinAvailable] = useState(role.min_available);
238+
const [maxAvailable, setMaxAvailable] = useState(role.max_available);
239+
const [description, setDescription] = useState(role.description ?? "");
240+
241+
const handleUpdate = () => {
242+
updateRole({
243+
roleId: role.id,
244+
data: {
245+
name: name,
246+
min_available: minAvailable,
247+
max_available: maxAvailable,
248+
description: description,
249+
finalised: true,
250+
},
251+
});
252+
}
253+
254+
return (
255+
<div className="border p-2 rounded-md">
256+
<div className="flex gap-1 items-center">
257+
<div className="flex-1">
258+
<Label className="text-xs text-muted-foreground">{dict.common.name}</Label>
259+
<Input value={name} onChange={(e) => setName(e.target.value)} onBlur={handleUpdate} />
260+
</div>
261+
<div className="w-24">
262+
<Label className="text-xs text-muted-foreground">{dict.common.min}</Label>
263+
<Input type="number" value={minAvailable} onChange={(e) => setMinAvailable(parseInt(e.target.value) || 0)} onBlur={handleUpdate} />
264+
</div>
265+
<div className="w-24">
266+
<Label className="text-xs text-muted-foreground">{dict.common.max}</Label>
267+
<Input type="number" value={maxAvailable} onChange={(e) => setMaxAvailable(parseInt(e.target.value) || 0)} onBlur={handleUpdate} />
268+
</div>
269+
<Button variant="ghost" size="icon" className="mt-4" onClick={() => deleteRole(role.id)}>
270+
<Trash className="w-4 h-4 text-destructive" />
271+
</Button>
272+
</div>
273+
<div className="mt-2">
274+
<Label className="text-xs text-muted-foreground">{dict.common.description}</Label>
275+
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} onBlur={handleUpdate} />
276+
</div>
277+
</div>
278+
)
279+
}
280+
281+
function RatingCategoryCard({ category, updateCategory, deleteCategory }: { category: RatingCategory, updateCategory: ({ name, categoryId }: { name: string, categoryId: string }) => void, deleteCategory: (categoryId: string) => void }) {
282+
const [name, setName] = useState(category.name);
283+
284+
const handleUpdate = () => {
285+
updateCategory({
286+
name: name,
287+
categoryId: category.id,
288+
});
289+
}
290+
291+
return (
292+
<div>
293+
<div className="flex gap-1 items-center">
294+
<div className="flex-1">
295+
<Input value={name} onChange={(e) => setName(e.target.value)} onBlur={handleUpdate} />
296+
</div>
297+
<Button variant="ghost" size="icon" onClick={() => deleteCategory(category.id)}>
298+
<Trash className="w-4 h-4 text-destructive" />
299+
</Button>
300+
</div>
301+
</div>
302+
)
303+
}

0 commit comments

Comments
 (0)