placementt-core 1.300.751 → 1.300.752

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,13 @@
1
- import {EmailSectionConfig, EmailSectionRenderProps} from "./typeDefinitions";
1
+ import {where} from "firebase/firestore";
2
+ import FirebaseQuery from "./firebase/firebaseQuery";
3
+ import {convertDate} from "./firebase/util";
4
+ import {EmailSectionConfig, EmailSectionNames, EmailSectionRenderProps, ExternalEvent, ExternalEventAttendee, ExternalEventRequest, ProviderContactData} from "./typeDefinitions";
2
5
 
3
6
  const PRIMARY_COLOUR = "#397DF2";
4
7
 
5
8
  type SectionEvent = {
6
9
  title: string,
10
+ description?: string,
7
11
  startDate: string,
8
12
  endDate?: string,
9
13
  activityType: string,
@@ -41,16 +45,129 @@ function sectionStyles(color: string) {
41
45
  return {
42
46
  container: `margin: 16px 0; font-family: nunito, sans-serif;`,
43
47
  title: `font-size: 16px; font-weight: bold; color: #333; margin: 0 0 8px 0; font-family: nunito, sans-serif;`,
44
- table: `width: 100%; border-collapse: collapse; margin: 8px 0; font-family: nunito, sans-serif; font-size: 13px;`,
45
- th: `background-color: ${color}; color: white; text-align: left; padding: 8px 12px; font-family: nunito, sans-serif; font-size: 13px;`,
46
- td: `padding: 8px 12px; border-bottom: 1px solid #f0f0f0; font-family: nunito, sans-serif; font-size: 13px; color: #333;`,
47
- tdAlt: `padding: 8px 12px; border-bottom: 1px solid #f0f0f0; background-color: #fafafa; font-family: nunito, sans-serif; font-size: 13px; color: #333;`,
48
48
  footer: `font-size: 12px; color: #666; margin: 8px 0 0 0; font-family: nunito, sans-serif;`,
49
49
  link: `color: ${color}; text-decoration: none;`,
50
50
  emptyText: `font-family: nunito, sans-serif; color: #666; font-size: 13px; margin: 4px 0;`,
51
51
  };
52
52
  }
53
53
 
54
+ export const emailCampaignSectionDataFetcher = async (sectionName: EmailSectionNames, data: {[key: string]: unknown}): Promise<string> => {
55
+ const firebaseQuery = new FirebaseQuery();
56
+ const oId = data.oId as string;
57
+ const primaryColor = data.primaryColor as string | undefined;
58
+ const today = convertDate(new Date(), "dbstring") as string;
59
+
60
+ switch (sectionName) {
61
+ case "upcomingEventsSection": {
62
+ const eventsRaw = await firebaseQuery.getDocsWhere(
63
+ ["externalEvents"],
64
+ [where("oId", "==", oId), where("startDate", ">=", today)]
65
+ ) as {[key: string]: ExternalEvent};
66
+
67
+ const events: SectionEvent[] = Object.entries(eventsRaw).map(([id, e]) => ({
68
+ id,
69
+ title: e.name,
70
+ description: e.description,
71
+ startDate: convertDate(e.startDate, "visual") as string || e.startDate || "",
72
+ endDate: e.endDate ? convertDate(e.endDate, "visual") as string : undefined,
73
+ activityType: (e as any).activityType || e.activityId || "other",
74
+ location: e.locality,
75
+ image: (e as any).image,
76
+ }));
77
+
78
+ return upcomingEventsSection.render({placeholder: false, events: events as any, oId, primaryColor});
79
+ }
80
+ case "attendingEventsSection": {
81
+ const providerContactId = data.providerContactId as string;
82
+
83
+ const attendeesRaw = await firebaseQuery.getDocsWhere(
84
+ ["externalEventAttendees"],
85
+ [where("providerContactId", "==", providerContactId), where("oId", "==", oId)]
86
+ ) as {[key: string]: ExternalEventAttendee};
87
+
88
+ const events: SectionEvent[] = (await Promise.all(
89
+ Object.values(attendeesRaw).map(async (attendee) => {
90
+ const event = await firebaseQuery.getDocData(["externalEvents", attendee.eventId]) as ExternalEvent | null;
91
+ if (!event?.startDate || (event.endDate && event.endDate < today)) return null;
92
+ return {
93
+ title: event.name,
94
+ description: event.description,
95
+ startDate: convertDate(event.startDate, "visual") as string || event.startDate,
96
+ endDate: event.endDate ? convertDate(event.endDate, "visual") as string : undefined,
97
+ activityType: (event as any).activityType || event.activityId || "other",
98
+ location: event.locality,
99
+ } as SectionEvent;
100
+ })
101
+ )).filter(Boolean) as SectionEvent[];
102
+
103
+ return attendingEventsSection.render({placeholder: false, events: events as any, primaryColor});
104
+ }
105
+ case "sharedOpportunitiesSection": {
106
+ const providerContactId = data.providerContactId as string;
107
+
108
+ const oppsRaw = await firebaseQuery.getDocsWhere(
109
+ ["externalEventRequests"],
110
+ [where("providerContactId", "==", providerContactId)]
111
+ ) as {[key: string]: ExternalEventRequest};
112
+
113
+ const opportunities: SectionOpportunity[] = Object.entries(oppsRaw).map(([id, r]) => ({
114
+ id,
115
+ title: r.title || "",
116
+ description: r.description,
117
+ type: (r as any).activityType,
118
+ created: r.created,
119
+ }));
120
+
121
+ return sharedOpportunitiesSection.render({placeholder: false, opportunities: opportunities as any, oId, primaryColor});
122
+ }
123
+ case "employerDatabaseSummarySection": {
124
+ const contactsRaw = await firebaseQuery.getDocsWhere(
125
+ ["providerContacts"],
126
+ [where(`savedBy.${oId}.exists`, "==", true)]
127
+ ) as {[key: string]: ProviderContactData};
128
+
129
+ const contacts = Object.values(contactsRaw);
130
+ const sectors = new Set(contacts.map((c) => c.sector).filter(Boolean));
131
+
132
+ const consentedActivities: {[activity: string]: number} = {};
133
+ contacts.forEach((c) => {
134
+ const saved = c.savedBy?.[oId];
135
+ if (saved?.activities) {
136
+ saved.activities.forEach((activity) => {
137
+ consentedActivities[activity] = (consentedActivities[activity] || 0) + 1;
138
+ });
139
+ }
140
+ });
141
+
142
+ const stats: SectionEmployerStats = {
143
+ totalContacts: contacts.length,
144
+ sectors: sectors.size,
145
+ consentedActivities,
146
+ };
147
+
148
+ return employerDatabaseSummarySection.render({placeholder: false, stats, primaryColor});
149
+ }
150
+ case "sharedOpportunitiesForSchoolSection": {
151
+ const oppsRaw = await firebaseQuery.getDocsWhere(
152
+ ["externalEventRequests"],
153
+ [where("schools", "array-contains", oId)]
154
+ ) as {[key: string]: ExternalEventRequest};
155
+
156
+ const opportunities: SectionOpportunity[] = Object.entries(oppsRaw).map(([id, r]) => ({
157
+ id,
158
+ title: r.title || "",
159
+ description: r.description,
160
+ type: (r as any).activityType,
161
+ created: r.created,
162
+ }));
163
+
164
+ return sharedOpportunitiesForSchoolSection.render({placeholder: false, opportunities: opportunities as any, primaryColor});
165
+ }
166
+ default:
167
+ return "";
168
+ }
169
+ }
170
+
54
171
  export const upcomingEventsSection: EmailSectionConfig = {
55
172
  id: "upcomingEvents",
56
173
  title: "Upcoming Events",
@@ -60,11 +177,12 @@ export const upcomingEventsSection: EmailSectionConfig = {
60
177
  render: ({placeholder, events, oId, primaryColor: pc}: EmailSectionRenderProps) => {
61
178
  const color = (pc as string) || PRIMARY_COLOUR;
62
179
  const s = sectionStyles(color);
180
+ const eventOId = oId as string | undefined;
63
181
 
64
182
  const displayEvents: SectionEvent[] = placeholder ? [
65
- {title: "Mock Interviews Day", startDate: "15 May 2025", activityType: "mockInterviews", location: "School Main Hall"},
66
- {title: "Annual Careers Fair", startDate: "22 May 2025", endDate: "23 May 2025", activityType: "workExperience", location: "Town Hall"},
67
- {title: "Mentoring Programme", startDate: "5 June 2025", activityType: "mentoring"},
183
+ {title: "Mock Interviews Day", startDate: "15 May 2025", activityType: "mockInterviews", location: "School Main Hall", description: "Join us for a day of mock interview practice with our Year 11 students."},
184
+ {title: "Annual Careers Fair", startDate: "22 May 2025", endDate: "23 May 2025", activityType: "workExperience", location: "Town Hall", description: "A two-day careers fair featuring employers from across the region."},
185
+ {title: "Mentoring Programme", startDate: "5 June 2025", activityType: "mentoring", description: "Support students with one-to-one mentoring sessions over the summer term."},
68
186
  {title: "Workplace Visit Day", startDate: "12 June 2025", activityType: "workplaceVisits", location: "City Centre"},
69
187
  ] : ((events as SectionEvent[]) || []);
70
188
 
@@ -88,17 +206,20 @@ export const upcomingEventsSection: EmailSectionConfig = {
88
206
  const dateStr = event.endDate && event.endDate !== event.startDate
89
207
  ? `${event.startDate} – ${event.endDate}`
90
208
  : event.startDate;
91
- const eventLink = oId ? `https://careerthread.co.uk/publicEvents/${oId}` : "#";
209
+ const eventLink = eventOId ? `https://careerthread.co.uk/publicEvents/${eventOId}` : "#";
92
210
  const imgStyle = event.image
93
- ? `height:140px;background-image:url('${event.image}');background-size:cover;background-position:center;`
94
- : `height:140px;background-color:${bgColor};`;
211
+ ? `height:120px;background-image:url('${event.image}');background-size:cover;background-position:center;`
212
+ : `height:120px;background-color:${bgColor};`;
95
213
 
96
214
  return `
97
215
  <div style="border-radius:12px;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,0.12);background:#ffffff;">
98
216
  <div style="${imgStyle}"></div>
99
217
  <div style="padding:12px 14px 14px;">
218
+ <p style="font-size:11px;font-weight:bold;color:${color};text-transform:uppercase;letter-spacing:0.5px;margin:0 0 4px 0;font-family:nunito,sans-serif;">${activityTypeLabel(event.activityType)}</p>
100
219
  <p style="font-weight:bold;font-size:14px;margin:0 0 3px 0;font-family:nunito,sans-serif;color:#333;line-height:1.3;">${event.title}</p>
101
- <p style="font-size:12px;color:#888;margin:0 0 12px 0;font-family:nunito,sans-serif;">${dateStr}</p>
220
+ <p style="font-size:12px;color:#888;margin:0 0 4px 0;font-family:nunito,sans-serif;">${dateStr}</p>
221
+ ${event.location ? `<p style="font-size:12px;color:#888;margin:0 0 4px 0;font-family:nunito,sans-serif;">${event.location}</p>` : ""}
222
+ ${event.description ? `<p style="font-size:12px;color:#666;margin:0 0 10px 0;font-family:nunito,sans-serif;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${event.description}</p>` : `<div style="margin-bottom:10px;"></div>`}
102
223
  <a href="${eventLink}" style="display:block;background-color:${color};color:#ffffff;text-align:center;padding:9px 12px;border-radius:6px;text-decoration:none;font-size:13px;font-family:nunito,sans-serif;font-weight:bold;">View Event</a>
103
224
  </div>
104
225
  </div>`;
@@ -125,7 +246,7 @@ export const upcomingEventsSection: EmailSectionConfig = {
125
246
  <table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
126
247
  ${cardRows.join("")}
127
248
  </table>
128
- ${oId ? `<p style="${s.footer}"><a href="https://careerthread.co.uk/publicEvents/${oId}" style="${s.link}">View all events and sign up →</a></p>` : ""}
249
+ ${eventOId ? `<p style="${s.footer}"><a href="https://careerthread.co.uk/publicEvents/${eventOId}" style="${s.link}">View all events and sign up →</a></p>` : ""}
129
250
  </div>`;
130
251
  },
131
252
  };
@@ -141,8 +262,8 @@ export const attendingEventsSection: EmailSectionConfig = {
141
262
  const s = sectionStyles(color);
142
263
 
143
264
  const displayEvents: SectionEvent[] = placeholder ? [
144
- {title: "Mock Interviews Day", startDate: "15 May 2025", activityType: "mockInterviews"},
145
- {title: "Annual Careers Fair", startDate: "22 May 2025", activityType: "workExperience"},
265
+ {title: "Mock Interviews Day", startDate: "15 May 2025", activityType: "mockInterviews", description: "Join us for a day of mock interview practice with our Year 11 students."},
266
+ {title: "Annual Careers Fair", startDate: "22 May 2025", activityType: "workExperience", description: "A two-day careers fair featuring employers from across the region."},
146
267
  ] : ((events as SectionEvent[]) || []);
147
268
 
148
269
  if (!placeholder && !displayEvents.length) {
@@ -153,25 +274,40 @@ export const attendingEventsSection: EmailSectionConfig = {
153
274
  </div>`;
154
275
  }
155
276
 
156
- const rows = displayEvents.map((event, i) => `
157
- <tr>
158
- <td style="${i % 2 === 1 ? s.tdAlt : s.td}"><strong>${event.title}</strong></td>
159
- <td style="${i % 2 === 1 ? s.tdAlt : s.td}">${event.startDate}</td>
160
- <td style="${i % 2 === 1 ? s.tdAlt : s.td}">${activityTypeLabel(event.activityType)}</td>
161
- </tr>`).join("");
277
+ const makeCard = (event: SectionEvent) => {
278
+ const dateStr = event.endDate && event.endDate !== event.startDate
279
+ ? `${event.startDate} – ${event.endDate}`
280
+ : event.startDate;
281
+ return `
282
+ <div style="border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,0.12);background:#ffffff;padding:16px;">
283
+ <p style="font-size:11px;font-weight:bold;color:${color};text-transform:uppercase;letter-spacing:0.5px;margin:0 0 6px 0;font-family:nunito,sans-serif;">${activityTypeLabel(event.activityType)}</p>
284
+ <p style="font-weight:bold;font-size:14px;margin:0 0 4px 0;font-family:nunito,sans-serif;color:#333;line-height:1.3;">${event.title}</p>
285
+ <p style="font-size:12px;color:#888;margin:0 0 4px 0;font-family:nunito,sans-serif;">${dateStr}</p>
286
+ ${event.location ? `<p style="font-size:12px;color:#888;margin:0 0 4px 0;font-family:nunito,sans-serif;">${event.location}</p>` : ""}
287
+ ${event.description ? `<p style="font-size:12px;color:#666;margin:0;font-family:nunito,sans-serif;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${event.description}</p>` : ""}
288
+ </div>`;
289
+ };
290
+
291
+ const cardRows: string[] = [];
292
+ for (let i = 0; i < displayEvents.length; i += 2) {
293
+ const second = displayEvents[i + 1];
294
+ cardRows.push(`
295
+ <tr>
296
+ <td class="att-col" style="width:50%;vertical-align:top;padding:0 6px 16px 0;">
297
+ ${makeCard(displayEvents[i])}
298
+ </td>
299
+ <td class="att-col" style="width:50%;vertical-align:top;padding:0 0 16px 6px;">
300
+ ${second ? makeCard(second) : ""}
301
+ </td>
302
+ </tr>`);
303
+ }
162
304
 
163
305
  return `
164
306
  <div style="${s.container}">
307
+ <style>@media(max-width:600px){.att-col{width:100%!important;display:block!important;padding:0 0 12px 0!important;}}</style>
165
308
  <p style="${s.title}">Your Upcoming Events</p>
166
- <table style="${s.table}">
167
- <thead>
168
- <tr>
169
- <th style="${s.th}">Event</th>
170
- <th style="${s.th}">Date</th>
171
- <th style="${s.th}">Type</th>
172
- </tr>
173
- </thead>
174
- <tbody>${rows}</tbody>
309
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
310
+ ${cardRows.join("")}
175
311
  </table>
176
312
  </div>`;
177
313
  },
@@ -186,6 +322,7 @@ export const sharedOpportunitiesSection: EmailSectionConfig = {
186
322
  render: ({placeholder, opportunities, oId, primaryColor: pc}: EmailSectionRenderProps) => {
187
323
  const color = (pc as string) || PRIMARY_COLOUR;
188
324
  const s = sectionStyles(color);
325
+ const eventOId = oId as string | undefined;
189
326
 
190
327
  const displayOps: SectionOpportunity[] = placeholder ? [
191
328
  {title: "Mock Interview Sessions", description: "Offering 10 mock interview slots for Year 11 students", type: "mockInterviews"},
@@ -198,7 +335,7 @@ export const sharedOpportunitiesSection: EmailSectionConfig = {
198
335
  <div style="${s.container}">
199
336
  <p style="${s.title}">Share an Opportunity</p>
200
337
  <p style="${s.emptyText}">We would love to hear from you! You can share work experience placements, mock interview sessions, workplace visits, and more with our students.</p>
201
- ${oId ? `<p style="font-family:nunito,sans-serif;margin:12px 0 0 0;"><a href="https://careerthread.co.uk/requestEvent/${oId}" style="background-color:${color};color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;font-family:nunito,sans-serif;">Submit an Opportunity</a></p>` : ""}
338
+ ${eventOId ? `<p style="font-family:nunito,sans-serif;margin:12px 0 0 0;"><a href="https://careerthread.co.uk/requestEvent/${eventOId}" style="background-color:${color};color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;font-family:nunito,sans-serif;">Submit an Opportunity</a></p>` : ""}
202
339
  </div>`;
203
340
  }
204
341
 
@@ -206,7 +343,7 @@ export const sharedOpportunitiesSection: EmailSectionConfig = {
206
343
  <div style="border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,0.12);background:#ffffff;padding:16px;">
207
344
  <p style="font-size:11px;font-weight:bold;color:${color};text-transform:uppercase;letter-spacing:0.5px;margin:0 0 6px 0;font-family:nunito,sans-serif;">${op.type ? activityTypeLabel(op.type) : "Opportunity"}</p>
208
345
  <p style="font-weight:bold;font-size:14px;margin:0 0 6px 0;font-family:nunito,sans-serif;color:#333;line-height:1.3;">${op.title}</p>
209
- <p style="font-size:12px;color:#888;margin:0;font-family:nunito,sans-serif;line-height:1.5;">${op.description || ""}</p>
346
+ ${op.description ? `<p style="font-size:12px;color:#888;margin:0;font-family:nunito,sans-serif;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${op.description}</p>` : ""}
210
347
  </div>`;
211
348
 
212
349
  const cardRows: string[] = [];
@@ -231,7 +368,7 @@ export const sharedOpportunitiesSection: EmailSectionConfig = {
231
368
  <table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
232
369
  ${cardRows.join("")}
233
370
  </table>
234
- ${oId ? `<p style="${s.footer}"><a href="https://careerthread.co.uk/requestEvent/${oId}" style="${s.link}">Submit another opportunity →</a></p>` : ""}
371
+ ${eventOId ? `<p style="${s.footer}"><a href="https://careerthread.co.uk/requestEvent/${eventOId}" style="${s.link}">Submit another opportunity →</a></p>` : ""}
235
372
  </div>`;
236
373
  },
237
374
  };
@@ -265,38 +402,53 @@ export const employerDatabaseSummarySection: EmailSectionConfig = {
265
402
  other: "Other",
266
403
  };
267
404
 
268
- const consentRows = Object.entries(displayStats.consentedActivities).map(([activity, count], i) => `
269
- <tr>
270
- <td style="${i % 2 === 1 ? s.tdAlt : s.td}">${activityLabels[activity] || activity}</td>
271
- <td style="${i % 2 === 1 ? s.tdAlt : s.td}">${count} employer${count !== 1 ? "s" : ""}</td>
272
- </tr>`).join("");
405
+ const activityEntries = Object.entries(displayStats.consentedActivities);
406
+ const activityCardRows: string[] = [];
407
+ for (let i = 0; i < activityEntries.length; i += 2) {
408
+ const [actA, countA] = activityEntries[i];
409
+ const second = activityEntries[i + 1];
410
+ activityCardRows.push(`
411
+ <tr>
412
+ <td class="act-col" style="width:50%;vertical-align:top;padding:0 6px 12px 0;">
413
+ <div style="border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,0.10);background:#ffffff;padding:14px 16px;text-align:center;">
414
+ <div style="font-size:22px;font-weight:bold;color:${color};font-family:nunito,sans-serif;">${countA}</div>
415
+ <div style="font-size:12px;color:#555;font-family:nunito,sans-serif;margin-top:4px;">${activityLabels[actA] || actA}</div>
416
+ </div>
417
+ </td>
418
+ <td class="act-col" style="width:50%;vertical-align:top;padding:0 0 12px 6px;">
419
+ ${second ? `
420
+ <div style="border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,0.10);background:#ffffff;padding:14px 16px;text-align:center;">
421
+ <div style="font-size:22px;font-weight:bold;color:${color};font-family:nunito,sans-serif;">${second[1]}</div>
422
+ <div style="font-size:12px;color:#555;font-family:nunito,sans-serif;margin-top:4px;">${activityLabels[second[0]] || second[0]}</div>
423
+ </div>` : ""}
424
+ </td>
425
+ </tr>`);
426
+ }
273
427
 
274
428
  return `
275
429
  <div style="${s.container}">
430
+ <style>@media(max-width:600px){.act-col{width:100%!important;display:block!important;padding:0 0 8px 0!important;}}</style>
276
431
  <p style="${s.title}">Your Employer Database</p>
277
- <table style="width: 100%; border-collapse: collapse; margin: 8px 0;">
432
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;margin:8px 0;">
278
433
  <tr>
279
- <td style="padding: 16px; background-color: ${color}; color: white; text-align: center; border-radius: 4px; width: 48%;">
280
- <div style="font-size: 28px; font-weight: bold; font-family: nunito, sans-serif;">${displayStats.totalContacts}</div>
281
- <div style="font-size: 12px; font-family: nunito, sans-serif; opacity: 0.9; margin-top: 4px;">Total Contacts</div>
434
+ <td class="act-col" style="width:50%;vertical-align:top;padding:0 6px 12px 0;">
435
+ <div style="padding:16px;background-color:${color};color:white;text-align:center;border-radius:10px;">
436
+ <div style="font-size:28px;font-weight:bold;font-family:nunito,sans-serif;">${displayStats.totalContacts}</div>
437
+ <div style="font-size:12px;font-family:nunito,sans-serif;opacity:0.9;margin-top:4px;">Total Contacts</div>
438
+ </div>
282
439
  </td>
283
- <td style="width: 4%;"></td>
284
- <td style="padding: 16px; background-color: ${color}; color: white; text-align: center; border-radius: 4px; width: 48%;">
285
- <div style="font-size: 28px; font-weight: bold; font-family: nunito, sans-serif;">${displayStats.sectors}</div>
286
- <div style="font-size: 12px; font-family: nunito, sans-serif; opacity: 0.9; margin-top: 4px;">Sectors Represented</div>
440
+ <td class="act-col" style="width:50%;vertical-align:top;padding:0 0 12px 6px;">
441
+ <div style="padding:16px;background-color:${color};color:white;text-align:center;border-radius:10px;">
442
+ <div style="font-size:28px;font-weight:bold;font-family:nunito,sans-serif;">${displayStats.sectors}</div>
443
+ <div style="font-size:12px;font-family:nunito,sans-serif;opacity:0.9;margin-top:4px;">Sectors Represented</div>
444
+ </div>
287
445
  </td>
288
446
  </tr>
289
447
  </table>
290
- ${consentRows ? `
291
- <p style="font-size: 14px; font-weight: bold; font-family: nunito, sans-serif; margin: 16px 0 4px 0; color: #333;">Consented to Activities</p>
292
- <table style="${s.table}">
293
- <thead>
294
- <tr>
295
- <th style="${s.th}">Activity</th>
296
- <th style="${s.th}">Consented Employers</th>
297
- </tr>
298
- </thead>
299
- <tbody>${consentRows}</tbody>
448
+ ${activityCardRows.length ? `
449
+ <p style="font-size:14px;font-weight:bold;font-family:nunito,sans-serif;margin:8px 0 4px 0;color:#333;">Consented to Activities</p>
450
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
451
+ ${activityCardRows.join("")}
300
452
  </table>` : ""}
301
453
  </div>`;
302
454
  },
@@ -305,7 +457,7 @@ export const employerDatabaseSummarySection: EmailSectionConfig = {
305
457
  export const sharedOpportunitiesForSchoolSection: EmailSectionConfig = {
306
458
  id: "sharedOpportunitiesForSchool",
307
459
  title: "Opportunities Shared With Your School",
308
- description: "A table of all employer opportunities that have been shared with this school.",
460
+ description: "A grid of employer opportunities that have been shared with this school.",
309
461
  emailInput: [{type: "heading-one", children: [{text: "Employer Opportunities"}]}, {type: "paragraph", children: [{text: "See below the opportunities that employers have shared with your students. Click on an opportunity to see more."}]}],
310
462
  forTemplates: ["schoolsEmailCampaign"],
311
463
  render: ({placeholder, opportunities, primaryColor: pc}: EmailSectionRenderProps) => {
@@ -313,9 +465,9 @@ export const sharedOpportunitiesForSchoolSection: EmailSectionConfig = {
313
465
  const s = sectionStyles(color);
314
466
 
315
467
  const displayOps: SectionOpportunity[] = placeholder ? [
316
- {title: "Mock Interview Day", description: "Financial services firm offering interview practice", type: "mockInterviews"},
317
- {title: "Finance Workplace Visit", description: "Tour of a London bank for up to 25 students", type: "workplaceVisits"},
318
- {title: "Year 10 Work Experience", description: "1-week placement at a local engineering firm", type: "workExperience"},
468
+ {title: "Mock Interview Day", description: "Financial services firm offering interview practice for Year 11 students ahead of their GCSEs.", type: "mockInterviews"},
469
+ {title: "Finance Workplace Visit", description: "Tour of a London bank for up to 25 students, with Q&A from senior staff.", type: "workplaceVisits"},
470
+ {title: "Year 10 Work Experience", description: "1-week placement at a local engineering firm working alongside qualified engineers.", type: "workExperience"},
319
471
  ] : ((opportunities as SectionOpportunity[]) || []);
320
472
 
321
473
  if (!placeholder && !displayOps.length) {
@@ -326,25 +478,33 @@ export const sharedOpportunitiesForSchoolSection: EmailSectionConfig = {
326
478
  </div>`;
327
479
  }
328
480
 
329
- const rows = displayOps.map((op, i) => `
330
- <tr>
331
- <td style="${i % 2 === 1 ? s.tdAlt : s.td}"><strong>${op.title}</strong></td>
332
- <td style="${i % 2 === 1 ? s.tdAlt : s.td}">${op.description || ""}</td>
333
- <td style="${i % 2 === 1 ? s.tdAlt : s.td}">${op.type ? activityTypeLabel(op.type) : ""}</td>
334
- </tr>`).join("");
481
+ const makeCard = (op: SectionOpportunity) => `
482
+ <div style="border-radius:12px;box-shadow:0 2px 10px rgba(0,0,0,0.12);background:#ffffff;padding:16px;">
483
+ <p style="font-size:11px;font-weight:bold;color:${color};text-transform:uppercase;letter-spacing:0.5px;margin:0 0 6px 0;font-family:nunito,sans-serif;">${op.type ? activityTypeLabel(op.type) : "Opportunity"}</p>
484
+ <p style="font-weight:bold;font-size:14px;margin:0 0 6px 0;font-family:nunito,sans-serif;color:#333;line-height:1.3;">${op.title}</p>
485
+ ${op.description ? `<p style="font-size:12px;color:#888;margin:0;font-family:nunito,sans-serif;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${op.description}</p>` : ""}
486
+ </div>`;
487
+
488
+ const cardRows: string[] = [];
489
+ for (let i = 0; i < displayOps.length; i += 2) {
490
+ const second = displayOps[i + 1];
491
+ cardRows.push(`
492
+ <tr>
493
+ <td class="sch-opp-col" style="width:50%;vertical-align:top;padding:0 6px 16px 0;">
494
+ ${makeCard(displayOps[i])}
495
+ </td>
496
+ <td class="sch-opp-col" style="width:50%;vertical-align:top;padding:0 0 16px 6px;">
497
+ ${second ? makeCard(second) : ""}
498
+ </td>
499
+ </tr>`);
500
+ }
335
501
 
336
502
  return `
337
503
  <div style="${s.container}">
504
+ <style>@media(max-width:600px){.sch-opp-col{width:100%!important;display:block!important;padding:0 0 12px 0!important;}}</style>
338
505
  <p style="${s.title}">Opportunities Shared With Your School</p>
339
- <table style="${s.table}">
340
- <thead>
341
- <tr>
342
- <th style="${s.th}">Opportunity</th>
343
- <th style="${s.th}">Description</th>
344
- <th style="${s.th}">Type</th>
345
- </tr>
346
- </thead>
347
- <tbody>${rows}</tbody>
506
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
507
+ ${cardRows.join("")}
348
508
  </table>
349
509
  </div>`;
350
510
  },
@@ -757,6 +757,7 @@ export type InstituteData = {
757
757
  publicEventPage?: boolean,
758
758
  discount?: string,
759
759
  package: string,
760
+ marketingEmails: string[],
760
761
  careersHubLinks?: {[oId: string]: {
761
762
  exists: boolean,
762
763
  invited: string,
@@ -1319,6 +1320,8 @@ export type EmailSectionConfig = {
1319
1320
  render: (props: EmailSectionRenderProps) => string,
1320
1321
  }
1321
1322
 
1323
+ export type EmailSectionNames = "upcomingEventsSection"|"attendingEventsSection"|"sharedOpportunitiesSection"|"employerDatabaseSummarySection"|"sharedOpportunitiesForSchoolSection"
1324
+
1322
1325
  export type EmailTemplateConfig = {
1323
1326
  description: string,
1324
1327
  params: string[],
@@ -1326,7 +1329,7 @@ export type EmailTemplateConfig = {
1326
1329
  secondaryButton?: {text: string, link: string},
1327
1330
  optionalButtons?: {text: string, link: string}[],
1328
1331
  type: "workflow"|"feedback"|"event"|"campaign"|"database",
1329
- sections?: EmailSectionConfig[],
1332
+ sections?: EmailSectionNames[],
1330
1333
  }
1331
1334
 
1332
1335
  export type EmailCampaign = {
package/src/util.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import {where, WhereFilterOp} from "firebase/firestore";
2
2
  import FirebaseQuery from "./firebase/firebaseQuery";
3
- import {AlumniConversation, CohortData, DataViewerSelectionBackend, DataViewerSelectionWeb, EmailTemplateConfig, ExternalEvent, ExternalEventAttendee, ExternalEventCareersSchool, ExternalEventStudent, FlagCodes, InstituteData, ProviderContactData, StudentPlacementData, UserData, WorkflowStage, YearScoreData} from "./typeDefinitions";
3
+ import {AlumniConversation, CohortData, DataViewerSelectionBackend, DataViewerSelectionWeb, EmailSectionNames, EmailTemplateConfig, ExternalEvent, ExternalEventAttendee, ExternalEventCareersSchool, ExternalEventStudent, FlagCodes, InstituteData, ProviderContactData, StudentPlacementData, UserData, WorkflowStage, YearScoreData} from "./typeDefinitions";
4
4
  import {capitalise, convertDate, getDateDiff} from "./firebase/util";
5
5
  import {Descendant} from "slate";
6
6
  import {PRIMARY_COLOUR} from "./constants";
@@ -204,7 +204,7 @@ export const getMostRecentAlumniMessage = (conversation: AlumniConversation) =>
204
204
  };
205
205
 
206
206
 
207
- export function buildEmailHTML({preheader, title, salutation, body, primaryColor=PRIMARY_COLOUR, primaryButton, secondaryButton, primaryImage="https://firebasestorage.googleapis.com/v0/b/placementt-dfa17.appspot.com/o/public%2FCareerThreadLogoForEmail.png?alt=media&token=046b5555-cd79-4fc9-bc89-90395e3fa351v", designatedStaffEmail, organisationName, params, data, sectionData}:{preheader: string, title: string, salutation?: string, body: string|Descendant[], primaryColor?: string, primaryButton?: {title: string, url: string}, secondaryButton?: {title: string, url: string}, primaryImage?: string, designatedStaffEmail?: string, organisationName?: string, params?: EmailTemplateConfig, data?: {[key: string]: string|undefined}, sectionData?: {[sectionId: string]: {[key: string]: unknown}}}) {
207
+ export async function buildEmailHTML({preheader, title, salutation, body, primaryColor=PRIMARY_COLOUR, primaryButton, secondaryButton, primaryImage="https://firebasestorage.googleapis.com/v0/b/placementt-dfa17.appspot.com/o/public%2FCareerThreadLogoForEmail.png?alt=media&token=046b5555-cd79-4fc9-bc89-90395e3fa351v", designatedStaffEmail, organisationName, params, data, sectionData, emailSectionRenderer}:{preheader: string, title: string, salutation?: string, body: string|Descendant[], primaryColor?: string, primaryButton?: {title: string, url: string}, secondaryButton?: {title: string, url: string}, primaryImage?: string, designatedStaffEmail?: string, organisationName?: string, params?: EmailTemplateConfig, data?: {[key: string]: string|undefined}, sectionData?: {[sectionId: string]: {[key: string]: unknown}}, emailSectionRenderer?: (sectionName: EmailSectionNames, data: {[key: string]: unknown}) => Promise<string>}) {
208
208
  const serialiseSlate = (nodes: Descendant[]) => {
209
209
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
210
210
  return nodes.map((n:any) => {
@@ -254,7 +254,7 @@ export function buildEmailHTML({preheader, title, salutation, body, primaryColor
254
254
  const makeSecondaryButtonHTML = (url: string, title: string) =>
255
255
  `<div style="text-align:center;padding:10px 0;"><a href="${url}" style="display:inline-block;font-weight:normal;color:#aeaeae;text-decoration:none;font-family:nunito,sans-serif;">${title}</a></div>`;
256
256
 
257
- const getEmailContentFromTemplate = (template: string|Descendant[], params: EmailTemplateConfig, data: {[key: string]: string|undefined}) => {
257
+ const getEmailContentFromTemplate = async (template: string|Descendant[], params: EmailTemplateConfig, data: {[key: string]: string|undefined}) => {
258
258
  let finalBody = typeof template === "string" ? template : serialiseSlate(template);
259
259
 
260
260
  const allParams = [...params.params, ...Object.keys(data)];
@@ -289,17 +289,23 @@ export function buildEmailHTML({preheader, title, salutation, body, primaryColor
289
289
 
290
290
  // Resolve sections
291
291
  if (params.sections) {
292
- const resolveSection = (sectionId: string) => {
293
- const section = params.sections!.find((s) => s.id === sectionId);
294
- if (!section) return "";
295
- const renderProps = sectionData?.[sectionId]
296
- ? {primaryColor, ...sectionData[sectionId]}
297
- : {placeholder: true, primaryColor};
298
- return section.render(renderProps);
299
- };
292
+ const sectionIds = new Set<string>();
293
+ let m: RegExpExecArray | null;
294
+ const divRe = /<div[^>]*>\s*\{\{section:([^}]+)\}\}\s*<\/div>/g;
295
+ const inlineRe = /\{\{section:([^}]+)\}\}/g;
296
+ while ((m = divRe.exec(finalBody)) !== null) sectionIds.add(m[1]);
297
+ while ((m = inlineRe.exec(finalBody)) !== null) sectionIds.add(m[1]);
298
+
299
+ const resolvedMap: {[id: string]: string} = {};
300
+ await Promise.all([...sectionIds].map(async (id) => {
301
+ if (emailSectionRenderer) {
302
+ resolvedMap[id] = await emailSectionRenderer(id as EmailSectionNames, {...data, primaryColor});
303
+ }
304
+ }));
305
+
300
306
  finalBody = finalBody
301
- .replace(/<div[^>]*>\s*\{\{section:([^}]+)\}\}\s*<\/div>/g, (_, id) => resolveSection(id))
302
- .replace(/\{\{section:([^}]+)\}\}/g, (_, id) => resolveSection(id));
307
+ .replace(/<div[^>]*>\s*\{\{section:([^}]+)\}\}\s*<\/div>/g, (_, id) => resolvedMap[id] ?? "")
308
+ .replace(/\{\{section:([^}]+)\}\}/g, (_, id) => resolvedMap[id] ?? "");
303
309
  }
304
310
 
305
311
  return finalBody;
@@ -310,7 +316,7 @@ export function buildEmailHTML({preheader, title, salutation, body, primaryColor
310
316
  let formattedSecondaryButton = secondaryButton;
311
317
 
312
318
  if (params && data) {
313
- formattedBody = getEmailContentFromTemplate(body, params, data);
319
+ formattedBody = await getEmailContentFromTemplate(body, params, data);
314
320
  }
315
321
 
316
322
  const emailHeadAndStylesHTML = `