PageSourceSearch

https://www.cardiffstudents.com/stylesheet/october2012/homepage-scroll-section.js?v=482

js cardiffstudents.com collected 2026-10-02 07:34:50 UTC 43,535 bytes, 941 lines download raw bytes

1/**--------------------------------------------------------------------------------------
2 *                                                                                      *
3 * the page this script is used on MUST have the carousel-button.js script linked       *
4 *                                                                                      *
5 * --------------------------------------------------------------------------------------
6 */
7
8//variables controlling the news section
9var featuredNewsBool = false;
10var newsId = 0;
11
12//bool for alternating colours of bgs/labels
13var pink = true;
14
15
16/**
17 * Alternate Colours
18 * 
19 * Outputs blue or pink depending on the previous colour.
20 * 
21 * @return    string array    [bgColour, labelColour]
22 */
23function alternateColours() {
24    if (pink) {
25        pink = !pink;
26        return ['#F4E8ED', "pink"];
27    } else {
28        pink = !pink;
29        return ['#DDEDEF', "teal"];
30    }
31}
32
33async function scrollSection(containerId, params = {}) {
34    pink = false;
35
36    if (Object.keys(params).length === 0 || typeof containerId !== "string") {
37        return;
38    }
39    
40    // header
41    let headerParams = params.headerParams ?? {};
42    
43    let headerCode = ``;
44    if (Object.keys(headerParams).length > 0) {
45        headerCode = `<div class="scroll-section-header flex justify-between items-center lg:gap-5 ga
45p-2 flex-row lg:mb-6 px-4">
46            ${createTitle(headerParams["title"], headerParams["colour"])}
47            ${createMoreButton(headerParams["buttonText"], headerParams["buttonLink"], headerParams["colour"])}
48        </div>`
49    }
50    
51    // card style
52    let cardType = params.sectionType ?? "";
53
54    // data source
55    let jsonData = params.jsonData ?? [];
56    let apiLinks = params.apiLinks ?? [];
57    let sourceData = (jsonData.length !== 0 ? jsonData : await getCardData(apiLinks, cardType, []));
58
59    const exclude = params.exclude ?? [];
60    if (exclude.length > 0) {
61        sourceData = sourceData.filter(item => {
62            if (!item.types) return true;
63            return !item.types.some(t => exclude.includes(t));
64        });
65    }
66
67    if (sourceData.length === 0) {
68        if (apiLinks.length !== 0) {
69            let placeholder = "";
70
71            switch (cardType) {
72                case "advert":
73                    placeholder = "student offers";
74                    break;
75                case "blogs":
76                    placeholder = "blog posts";
77                    break;
78                default:
79                    placeholder = cardType;
80            }
81
82            $("#"+containerId).append(headerCode);
83            $("#"+containerId).append(`<p class="mt-0 pt-0 main-blurb">There are no ${placeholder} right now - check again later!</p>`);
84        }
85
86        return;
87    }
88
89    // Optional click handler for cards (e.g. opening a modal instead of navigating)
90    let onCardClick = params.onCardClick ?? null;
91
92    let cardCode = sourceData.map(item => cardTemplate(item, cardType, containerId)).join('');
93
94    //arrow variables
95    let arrowContainerName = containerId + "FeedContainer";
96    let scrollContainerName = containerId + "Grid";
97    let startArrowDirection = "left";
98    let endArrowDirection = "right";
99    let scrollDistance = 344 * 2;
100
101    if (cardType === "full" || cardType === "slide") {
102        scrollDistance = "auto";
103    }
104
105    if (cardType === "news") {
106        startArrowDirection = "up";
107        endArrowDirection = "down";
108        scrollDistance = 195;
109    }
110
111    let gridStyle = `mt-4 mb-4 lg:py-4 px-4 mx-auto`;
112    let wrap = params.wrap ?? false;
113
114    if (cardType === "news" && window.innerWidth >= 1024) {
115        gridStyle = `grid items-center overflow-y-auto h-[${175 * 3 + 20 * 2}px] lg:py-0`;
116    } else if (cardType === "advert" || wrap) {
117        gridStyle += ` flex flex-wrap gap-4 justify-center`;
118    } else if (cardType === "slide") {
119        gridStyle = "flex flex-nowrap overflow-x-auto lg:!py-2 lg:!px-8 lg:!mx-0 items-stretch";
120    } else {
121        gridStyle += ` flex flex-nowrap overflow-x-auto`;
122    }
123
124    // Freshers card types: remove horizontal scroller class on mobile
125    let scrollerClass = window.innerWidth >= 1024 ? 'csu-content-scroller' : '';
126
127    // Build section with placeholder container for arrows
128    let scrollSection = `
129        <div id="${containerId}GridContainer" class="w-full flex items-center flex-row relative ${cardType === "news" && window.innerWidth >= 1024 ? "lg:flex-col" : ""}">
130            
131            ${!wrap ? `<div id="${arrowContainerName}Left" ${cardType === "news" ? 'class="w-full lg:flex justify-center hidden"' : ""}></div>` : ""}
132            
133            <div id="${containerId}Grid" class="${scrollerClass} 
134                snap-x  ${gridStyle} ${cardType === "slide" || cardType === "full" ? "w-full": "w-fit"} 
135                pb-2 ${cardType === "advert" || cardType === "slide" ? "lg:gap-8 gap-4" :"gap-5"}">
136                    ${cardCode}
137            </div>
138            
139            ${!wrap ? `<div id="${arrowContainerName}Right" ${cardType === "news" ? 'class="w-full lg:flex justify-center hidden"' : ""}></div>` : ""}
140        </div>
141        ${!wrap ? `<div id="${arrowContainerName}Mobile" class="flex justify-end gap-2 mt-3 lg:hidden px-1"></div>` : ""}`;
142    
143    // News section rules
144    if (cardType === "news" && window.innerWidth >= 1024) {
145        scrollSection = `
146            <div class="lg:gap-7 grid row-start-2 lg:grid-cols-3 mt-4 lg:mt-6 mb-4 lg:py-4">
147                <div id="newsFeedfeatured" class="hidden lg:block col-end-3 col-start-1">
148                    <a class="hover:no-underline" href="${sourceData[0]["link"]}">
149                        <div class="rounded-2xl font-union-semibold filter transition ease-in-out duration-300" 
150                             style="background-image: url('${sourceData[0]["fullImg"]}');
151                             background-repeat: no-repeat;background-size: cover;
152                             height: 570px;width: auto;background-position-x: center;background-position-y: center;">
153                            <div class=" bg-union-pink lg:w-fit min-h-[100px] flex items-center py-7 mr-7 pl-7 pr-12 relative rounded-r-full top-[350px]">
154                                <span class="font-union-semibold font-union text-xl lg:text-4xl text-white">
155                                    ${titleDecider(sourceData[0]["title"])}
156                                </span>
157                            </div>
158                        </div>
159                    </a>
160                </div>
161
162                <div class="col-start-3 flex flex-row lg:flex-col items-center relative lg:h-[570px]">
163                    ${scrollSection}
164                </div>
165            </div>`;
166    }
167
168    let wrapper = `
169        <div class="tilegrid m-auto max-w-screen-2xl lg:mb-5 lg:mt-5">
170            ${headerCode}
171            ${scrollSection}
172        </div>`;
173
174    $("#" + containerId).append(wrapper);
175
176    featuredNewsBool = false;
177
178    requestAnimationFrame(() => {
179        requestAnimationFrame(() => {
180            createBothArrows2({ 
181                wrap, 
182                cardType, 
183                arrowContainerName, 
184                scrollContainerName, 
185                scrollDistance, 
186                startArrowDirection, 
187                endArrowDirection, 
188                containerId,
189            });
190
191            // Bind click handler for cards that use onCardClick instead of <a> links
192            if (onCardClick) {
193                $(`#${containerId}Grid`).find('[data-card-id]').on('click', function () {
194                    const cardId = decodeURIComponent($(this).attr('data-card-id'));
195                    const item = sourceData.find(d => (d.id || '') === cardId);
196                    if (item) onCardClick(item);
197                });
198            }
199        });
200    });
201}
202
203
204// Delay arrow creation until container is visible
205function createBothArrows2({ 
206    wrap, 
207    cardType, 
208    arrowContainerName, 
209    scrollContainerName, 
210    scrollDistance, 
211    startArrowDirection, 
212    endArrowDirection, 
213    containerId,
214}) {
215    const gridSelector = "#" + containerId + "Grid"
216
217    // Helper: wait until element is visible and has layout.
218    // Fast path: rAF polling for the first ~5s covers elements that are already
219    // visible or become visible quickly. Slow path: if the budget exhausts (e.g.
220    // the element is inside a collapsed accordion the user hasn't opened yet),
221    // hand off to a ResizeObserver on the parent so the callback still fires
222    // whenever the accordion opens — no matter how long that takes.
223    function waitUntilVisible(selector, callback) {
224        const el = document.querySelector(selector);
225        if (!el) return;
226
227        let frames = 0;
228        function check() {
229            if (el.offsetWidth > 0 && el.offsetHeight > 0) {
230                callback();
231            } else if (frames++ < 300) {
232                requestAnimationFrame(check);
233            } else if ('ResizeObserver' in window) {
234                const observer = new ResizeObserver(() => {
235                    if (el.offsetWidth > 0 && el.offsetHeight > 0) {
236                        observer.disconnect();
237                        callback();
238                    }
239                });
240                observer.observe(el.parentElement || el);
241            }
242        }
243
244        check();
245    }
246
247    // Wait for the grid to be visible and ready
248    waitUntilVisible(gridSelector, () => {
249        const el = document.querySelector(gridSelector);
250        const isWide = window.innerWidth >= 1024;
251
252        function createDesktopArrows() {
253            $("#" + arrowContainerName + "Left").append(
254                createButton(
255                    startArrowDirection,
256                    "arrow-" + startArrowDirection + "-" + containerId,
257                    arrowContainerName,
258                    scrollContainerName,
259                    scrollDistance
260                )
261            );
262            $("#" + arrowContainerName + "Right").append(
263                createButton(
264                    endArrowDirection,
265                    "arrow-" + endArrowDirection + "-" + containerId,
266                    arrowContainerName,
267                    scrollContainerName,
268                    scrollDistance
269                )
270            );
271        }
272
273        const needsArrows = !wrap && isWide && cardType !== "news";
274
275        if (!wrap && cardType === "news" && isWide) {
276            createDesktopArrows();
277        } else if (needsArrows) {
278            if (isOverflownHorizontal(el)) {
279                createDesktopArrows();
280            } else if ('ResizeObserver' in window) {
281                // On slow devices, layout may not be finalised when the initial
282                // overflow check runs. Watch for the grid growing and retry.
283                const observer = new ResizeObserver(() => {
284                    if (isOverflownHorizontal(el)) {
285                        observer.disconnect();
286                        createDesktopArrows();
287                    }
288                });
289                observer.observe(el);
290                setTimeout(() => observer.disconnect(), 5000);
291            }
292        }
293
294        // Mobile arrows — always created for non-wrap, non-news horizontal sections
295        if (!wrap) {
296            createMobileArrows(containerId, scrollContainerName);
297        }
298    });
299}
300
301
302/**
303 * Card Template
304 * 
305 * Generates a card whose style depends on the card's type.
306 * 
307 * @param    dictionary     item    dictionary of the card data
308 * @param    string         type    the type of the card template
309 * @param    string    containerId     ID of the tiledgrid containing the cards
310 * 
311 * @return   string    HTML string containing the generated card code
312 */
313function cardTemplate(item, type, containerId) {
314    let bgColour = "";
315    let labelColour = "";
316    let hoverBgColour = "";
317    let bgImage = (item["bgImage"] ? item["bgImage"].replace(/\(/g, '\\(').replace(/\)/g, '\\)') : "");
318    let img = (item["img"] ? item["img"] : "");
319    let alt = (item["alt"] ? item["alt"] : "");
320    let desc = (item["desc"] ? processLanguage(item["desc"]) : "");
321    let cardSize = "lg:max-w-[320px] lg:h-[400px] lg:min-w-[320px] max-w-[240px] min-w-[240px] h-[300px]";
322    let link = (item["link"] ? item["link"] : "");
323    let HTMLcontents = "";
324    let title = processLanguage(item["title"]);
325    let cardStyle = `justify-between snap-center `;
326    let divStyle = "";
327    let imgStyle = ` h-28 mx-auto px-12 lg:max-h-40 lg:h-auto lg:px-12 w-full lg:max-w-72`;
328    let linkNewTab = false;
329    let output = "";
330    
331    // blogs, music, events, and mobile news all use the same card template
332    if ((type === "news" && window.innerWidth < 1024) || (type === "music" || type === "blogs")) {
333        type = "events";
334    }
335    
336    if (type === "officers") {
337        type = "background";
338    }
339
340    // ── Freshers event card ──────────────────────────────────────────────
341    // Expects normalised event objects from the freshers microsite.
342    // Uses data-card-id for click handling via onCardClick callback.
343    if (type === "freshers-event") {
344        return freshersEventCard(item);
345    }
346
347    // ── Background flip card ─────────────────────────────────────────────
348    // Variant of the background card: same front, back content from item.back.
349    // Requires .flip-card CSS to be available on the page.
350    if (type === "background-flip") {
351        return backgroundFlipCard(item);
352    }
353
354    switch (type) {
355        case "graphic":
356            [bgColour, labelColour] = alternateColours();
357            cardStyle = `justify-center snap-center `;
358            HTMLcontents = `<p class="text-xl lg:text-2xl font-union-black font-union-bold pt-4 mx-8 text-center text-union-${labelColour} h-1/4 lg:h-auto">${title}</p>`;
359            break;
360            
361        case "descriptive":
362            HTMLcontents = `<div class="flex flex-col justify-end px-5 py-3 space-y-2 lg:h-[11rem] h-[10rem] w-full">
363                                <div class="font-union-bold text-2xl text-white leading-6">${title}</div>
364                                <p class="border-t-[1.5px] pt-2 lg:text-lg text-base font-union !text-white">${desc}</p>
365                            </div>`;
366            cardStyle = `justify-end snap-center `;
367        	divStyle = `gradient-t-to-b-tiles-black h-full flex items-end`;
368            break;
369            
370        case "background":
371            let linkCode = ``;
372            
373            if (link.constructor === Array) {
374                let text = link[0];
375                let url = link[1];
376                link = url.trim();
377
378                if (url.includes('@') || text.includes('@')) {
379                    linkCode = `
380                        <svg class="fill-white h-6 mr-2" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M64 112c-8.8 0-16 7.2-16 16v22.1L220.5 291.7c20.7 17 50.4 17 71.1 0L464 150.1V128c0-8.8-7.2-16-16-16H64zM48 212.2V384c0 8.8 7.2 16 16 16H448c8.8 0 16-7.2 16-16V212.2L322 328.8c-38.4 31.5-93.7 31.5-132 0L48 212.2zM0 128C0 92.7 28.7 64 64 64H448c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128z"></path></svg>
381                    `;
382                }
383                
384                linkCode += `
385                        <p class="text-union-pink font-union-semibold text-lg text-ellipsis overflow-hidden">${processLanguage(text)}</p>
386                    `;
387            } else {
388                linkCode = `
389                    <p class="text-union-pink font-union-semibold text-lg text-ellipsis overflow-hidden">${processLanguage(link)}</p>
390                `;
391                link = ``;
392            }
393             
394            HTMLcontents = `<div class="flex flex-col h-fit lg:min-h-32 justify-end px-5 py-3 w-60 lg:w-80 relative bottom-0 overflow-hidden">
395                                <p class="font-union-bold text-2xl text-white w-full leading-6">${title}</p>
396                                ${item["desc"] !== undefined ?'<p class="text-white font-union leading-snug text-xl w-full">'+desc+'</p>' : ""}
397                                <div class="border-t-[1.5px] pt-2 mt-2 flex flex-nowrap text-ellipsis overflow-hidden w-full leading-6">
398                                    ${linkCode}
399                                </div>
400                            </div>`;
401            cardStyle = `justify-end snap-center `;
402        	divStyle = `gradient-t-to-b-tiles-black h-full flex items-end`;
403            break;
404
405        case "strand":
406            [bgColour, labelColour] = alternateColours();
407            cardStyle = `justify-end snap-center `;
408            HTMLcontents = `<p class="bg-union-${labelColour} mb-4 lg:mb-8 text-white font-union-bold text-xl lg:text-[27px] flex rounded-r-full mr-4">
409                                <span class="my-auto lg:ml-5 mx-3 py-3 lg:py-8 font-union-bold">${title}</span>
410                            </p>`;
411            break;
412            
413        case "steps":
414            let hasTitle = (title === "" ? false : true);
415            divStyle = (hasTitle ? "py-8" : "py-8 lg:pt-16 lg:space-y-4");
416            [bgColour, labelColour] = alternateColours();
417            
418            titleCode = `
419                    <p class="leading-tight flex font-union-black justify-center mx-5 text-xl lg:text-2xl text-center text-union-${labelColour} uppercase">
420                        ${title}
421                    </p>`
422            HTMLcontents = `<div class="mb-2 flex flex-col h-fit ${hasTitle ? "lg:h-44" : ""} mt-3 justify-center lg:justify-end space-y-3">
423                            ${hasTitle ? titleCode : ""}
424                            <p class="text-base font-union-bold h-fit ${hasTitle ? "lg:h-24" : ""} px-5 pb-5 text-center text-union-${labelColour}">
425                                ${desc}
426                            </p>
427                        </div>`
428            break;
429            
430        case "full":
431            cardSize = `md:h-[56.25vh] xl:h-[70vh] min-w-full h-80`;
432            cardStyle=`snap-center`;
433            divStyle = `flex flex-col flex-1`;
434            HTMLcontents = `<div class="h-fit relative mt-auto p-5 lg:p-8">
435                                <div class="text-xl lg:text-5xl text-white font-union-bold">
436                                    ${title}
437                                </div>
438                                <div class="description text-white border-t-[1.5px] pt-2">
439                                    ${desc}
440                                </div>
441                            </div>`;
442        	divStyle = `gradient-t-to-b-tiles-black h-full flex items-end`;
443            break;
444            
445        case "slide":
446            cardSize = `min-w-full`;
447            cardStyle =`snap-center  bg-white`;
448            divStyle = `flex lg:my-auto p-4 flex-col md:flex-row gap-4 h-full`;
449            imgStyle = `lg:w-[400px] lg:h-[200px] w-full h-[200px] mt-0 bg-center rounded-md lg:ml-8 lg:m-4 mx-auto bg-cover self-center`;
450            HTMLcontents = `<div class="${img ? "lg:pl-2" : "lg:pl-6"} w-fit lg:pr-4 lg:my-auto">
451                                <p class="text-xl font-union-semibold leading-7 lg:text-3xl ${link !== "" ? "text-union-pink" : ""}">${title}</p>
452                                <p class="font-union-medium <!--lg:text-xl text-base--> lg:text-[24px] text-[17px] leading-tight mt-2 text-black">${desc}</p>
453                            </div>`;
454            break;
455        
456        case "advert":
457            img = item["img"].replace(/\(/g, '\\(').replace(/\)/g, '\\)');
458            imgStyle = `w-full`;
459            [bgColour, labelColour] = alternateColours();
460            bgColour = "white"
461            desc = processLanguage(item["description"]);
462            linkNewTab = true;
463            cardSize = `lg:min-w-80 lg:max-w-80 max-w-[288px] min-w-[288px]`;
464            HTMLcontents =  `<div class="mb-2 flex flex-col h-fit mt-3 justify-center lg:justify-end space-y-3">
465                            <p class="leading-tight flex font-union-black justify-center mx-5 text-xl lg:text-2xl text-center text-union-${labelColour} uppercase">
466                                ${title}
467                            </p>
468                            <div class="advert-link lg:text-lg text-md font-union-medium h-fit px-5 pb-5 text-center">
469                                ${desc}
470                            </div>
471                        </div>`
472            break;
473            
474        case "events":
475            cardStyle = `justify-end snap-center `;
476            divStyle = "p-2";
477            let bottomText = "";
478            const eventTitleAdditionalClasses = "tracking-normal lg:!leading-6 !leading-5 lg:!mb-4 !mb-1 !m-0 js-card-h2";
479
480            let location = item['location'];
481
482            if (item['location'] !== undefined && typeof item['location'] !== 'string') {
483                location = (getCurrentLanguage() === 'Eng' ? item['location'][0] : item['location'][1]);
484            }
485
486            if (item['location'] != null || item['date'] != null) {
487                bottomText = `<div class="lg:flex lg:flex-row flex-col lg:items-end lg:flex-nowrap space-y-1 justify-between w-full gap-3">
488                                ${location != null ? '<p class="js-card-p lg:!leading-[1.15rem] !leading-4 font-union font-bold text-base lg:flex-1">' + location + '</p>' : ""}
489                                ${item['date'] != null ? '<p class="js-card-p lg:!leading-[1.15] flex items-end font-union font-bold text-base lg:text-lg text-white">' + item['date'] + '</p>' : ""}
490                              </div>`
491            }
492            
493            HTMLcontents = `<div class="bg-black/60 lg:h-[8.5rem] h-[7.5rem] p-3 w-full text-white font-union-bold justify-between lg:flex flex-col rounded-xl mt-auto overflow-hidden">
494                                <div class="font-union-bold text-lg lg:text-2xl text-white w-full overflow-hidden">
495                                ${titleDecider(title, eventTitleAdditionalClasses)}
496                                </div>
497                               ${bottomText}
498                            </div>`
499            break;
500            
501        case "news":
502            if (item.tags.includes('featured') && featuredNewsBool) {
503                break;
504            }
505        
506            if (pink) {
507                bgColour = 'bg-pale-pink';
508                hoverBgColour = "#e8adca";
509            }
510            else{
511                bgColour = 'bg-pale-teal';
512                hoverBgColour = "#94cad9";
513            }
514            
515            pink = !pink;
516
517            const defaultHex = bgColour === 'bg-pale-pink' ? '#F3DFEA' : '#C4DFE3';
518            output = `<a id="${item['id']}" class="substory-card grid grid-cols-2 filter transition ease-in-out duration-300 rounded-lg ${bgColour}" href="${link}" data-hover-bg="${hoverBgColour}" data-default-bg="${defaultHex}">
519                        <div class = "${type}-card overflow-hidden rounded-l-lg">
520                            <div class="substory-image transition ease-in-out duration-300" style="background-image: url('${bgImage}');
521                                background-repeat: no-repeat;background-size: cover;
522                                height: 175px;width: auto;background-position-x: center;background-position-y: center;">
523                            </div>
524                        </div> 
525                        <div class=" newsTitle flex items-center px-5  rounded-r-lg transition ease-in-out duration-300  text-black">
526                            <span class="text-xl fade font-union font-union-semibold text-black">${titleDecider(title)}</span>
527                        </div>
528                    </a>
529                    `;
530            break;
531            
532        default:
533            break; 
534    }
535    
536    //output for non-news cards
537    if (type !== "news") {
538        let imgContainer = `${img.length > 0 ? "<img src="+img+" loading='lazy' class='"+imgStyle+"' alt='"+alt+"'>" :""}`
539        
540        if (type === "slide" && img.length > 0) {
541            imgContainer = `<div class="${imgStyle} bg-[url('${img}')]" role="img" aria-label="${alt}"></div>`
542        }
543        
544        output = `<div class="js-card ${type}-card ${cardSize} bg-white rounded-xl flex flex-col ${cardStyle} ${link.length > 0 ? "grow" : ""} mx-auto ${bgColour.length > 0 ? "bg-["+bgColour+"]" : ""} ${bgImage.length > 0 ? 'bg-[url('+bgImage+')]' : ""} bg-center bg-cover">
545                    <div class="${divStyle}">
546                        ${imgContainer}
547                        ${HTMLcontents}
548                    </div>
549                </div>`;
550                
551        if (link.length > 0) {
552            return `<a class="hover:no-underline flex ${type === "slide" || type === "full" ? "min-w-full" : "" }" href="${link}" ${linkNewTab ? 'target="_blank"' : ""}>
553                        ${output}
554                    </a>`
555        }
556    }
557
558    return output;
559}
560
561
562/* ── FRESHERS EVENT CARD ───────────────────────────────────────────────────
563   Renders a freshers microsite event card.
564   Expects a normalised event object with: id, name, type, subLabel, date,
565   startHour, venue, price, image, statuses, soldOut.
566   Uses data-card-id for click binding via onCardClick.
567   ──────────────────────────────────────────────────────────────────────── */
568
569const FRESHERS_PLACEHOLDER_IMG = 'https://www.cardiffstudents.com/assets/site_resources/placeholder-csu-image.jpg';
570
571function freshersEventCard(ev) {
572  // These helper functions are defined in the freshers JS
573  const tc       = typeClass(ev.type);
574  const colour   = typeColour(ev.type);
575  const imgSrc   = ev.image || FRESHERS_PLACEHOLDER_IMG;
576  const dateObj   = ev.date ? new Date(ev.date + 'T00:00:00') : null;
577  const dateMonth = dateObj ? dateObj.toLocaleString('en-GB', { month: 'short' }).toUpperCase() : '';
578  const dateDay   = dateObj ? dateObj.getDate() : '';
579  // Tours etc. are one real multi-day event, but the calendar view needs them
580  // split into a block per day -- ev.endDate carries the original span so the
581  // card (and the modal, which already does this) can show it as one range
582  // instead of looking like a duplicate one-day event.
583  const isMultiDay = !!(ev.endDate && ev.endDate !== ev.date);
584  const endDateObj = isMultiDay ? new Date(ev.endDate + 'T00:00:00') : null;
585  const endMonth   = endDateObj ? endDateObj.toLocaleString('en-GB', { month: 'short' }).toUpperCase() : '';
586  const endDay     = endDateObj ? endDateObj.getDate() : '';
587  const nonAvail = (ev.statuses || []).filter(s => s !== 'available');
588  const isCym = typeof getCurrentLanguage === 'function' && getCurrentLanguage() === 'Cym';
589  const badges   = nonAvail.map(s => {
590    if (s.startsWith('ticket-so:')) {
591      const ticketType = s.slice(10);
592      const label = isCym ? `${ticketType} - Gwerthu Allan` : `${ticketType} - Sold Out`;
593      return `<span class="text-[0.6rem] font-union-semibold tracking-widest uppercase px-2 py-0.5 rounded-full text-white" style="background:rgba(0,0,0,0.65)">${label}</span>`;
594    }
595    const a = typeof AVAILABILITY !== 'undefined' ? AVAILABILITY[s] : null;
596    return a ? `<span class="text-[0.6rem] font-union-semibold tracking-widest uppercase px-2 py-0.5 rounded-full text-white" style="background:${a.bg}">${isCym ? a.cym : a.eng}</span>` : '';
597  }).join('');
598  const locationCode = (ev.venue === "" ? "" : `
599    <div class="flex items-center gap-1.5">
600        <img src="/pageassets/sandbox/welcome/freshers-microsite/map-pin.svg" class="h-4 rounded-none flex-shrink-0">
601        <span class="line-clamp-1">${ev.venue}</span>
602    </div>
603`);
604  // Live music (fixr/universe) events sell tickets externally, so a missing price
605  // just means no ticket-price= tag was set on the event yet -- don't default to "Free".
606  const priceDisplay = ev.yoloPricePending ? '' : (ev.price || (ev.type === 'livemusic' ? '' : 'Free'));
607  const isCymCard = typeof getCurrentLanguage === 'function' && getCurrentLanguage() === 'Cym';
608  let btnLabel = isCymCard ? 'Cael Tocynnau' : 'Get Tickets';
609
610  if (ev.soldOut) {
611    btnLabel = isCymCard ? 'Gwerthu Allan' : 'Sold Out';
612  } else {
613    if (nonAvail.includes('limited')) {
614        btnLabel = 'Limited';
615    } else if (typeof ev.tickets === "undefined") {
616        btnLabel = isCymCard ? 'Mwy o wybodaeth' : 'More info';
617    }
618  }
619
620  return `
621    <div class="event-card ${tc}${ev.soldOut ? ' sold-out' : ''} rounded-2xl overflow-hidden cursor-pointer bg-white transition-all hover:-translate-y-1 flex flex-col min-w-[270px] max-w-[270px] w-[270px] snap-center" data-card-id="${encodeURIComponent(ev.id)}">
622        <div class="relative h-48 flex items-center justify-center overflow-hidden">
623            <img src="${imgSrc}" class="w-full h-full object-cover" loading="lazy" alt="${ev.name}">
624            ${badges ? `<div class="absolute top-2.5 right-2 z-10 flex flex-col items-end gap-1">${badges}</div>` : ''}
625        </div>
626
627        <div class="bg-white flex flex-col gap-2 p-4 rounded-b-2xl">
628            <span class="text-[0.65rem] font-union-semibold tracking-widest px-2.5 py-0.5 rounded-full text-white w-fit" style="background:${colour}">${ev.subLabel || typeLabel(ev.type)}</span>
629            
630            <div class="flex flex-1 bg-white gap-2 overflow-hidden relative">
631                <div class="flex flex-col items-center border-black border h-fit px-1 py-0.5 rounded-lg">
632                    <span class="text-[0.6rem] font-union-semibold tracking-widest text-black">${dateMonth}</span>
633                    <span class="text-xl font-union-bold text-black leading-none">${dateDay}</span>
634                    ${isMultiDay ? `
635                    <span class="text-xs font-union-bold text-black leading-none">-</span>
636                    <span class="text-[0.6rem] font-union-semibold tracking-widest text-black">${endMonth}</span>
637                    <span class="text-xl font-union-bold text-black leading-none">${endDay}</span>` : ''}
638                </div>
639                
640                <div class="flex flex-col gap-2 w-full">
641                    <div class="min-h-[3.2rem] font-union font-union-bold text-black text-xl line-clamp-2 leading-tight">${ev.name}</div>
642
643                    <div class="flex w-full metadata flex gap-3 text-sm text-black items-center font-union justify-between">
644                        ${locationCode}
645
646                        ${priceDisplay ? `<div class="flex items-center gap-1.5 min-w-[4.1rem]">
647                            <img src="/pageassets/sandbox/welcome/freshers-microsite/ticket.svg" class="h-4 rounded-none flex-shrink-0">
648                            ${priceDisplay === 'Free' && isCym ? 'Am Ddim' : priceDisplay}
649                        </div>` : ''}
650                    </div>
651                </div>
652                        
653            </div>
654                
655            <div class="flex justify-between items-center">
656                <button type="button" class="block card-book-btn oval-button oval-union-pink text-sm transition-colors duration-200 w-full py-2">${btnLabel}</button>
657            </div>
658        </div>
659    </div>
660  `;
661}
662
663
664/* ── BACKGROUND FLIP CARD ─────────────────────────────────────────────────
665   Background card variant with a 3D CSS flip: same front as the background
666   card (image, title, optional desc, link text), back content from item.back.
667   Requires .flip-card / .flip-card-content / .flip-front / .flip-back CSS.
668   ──────────────────────────────────────────────────────────────────────── */
669
670function backgroundFlipCard(item) {
671  const bgImage = item['bgImage'] ? item['bgImage'].replace(/\(/g, '\\(').replace(/\)/g, '\\)') : '';
672  const title   = processLanguage(item['title']);
673  const desc    = item['desc'] ? processLanguage(item['desc']) : '';
674  const back    = item['back'] ? processLanguage(item['back']) : '';
675  let link      = item['link'] || '';
676
677  let linkCode = '';
678  if (link.constructor === Array) {
679    const text = link[0];
680    const url  = link[1];
681    link = url.trim();
682    if (url.includes('@') || text.includes('@')) {
683      linkCode = `<svg class="fill-white h-6 mr-2" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path d="M64 112c-8.8 0-16 7.2-16 16v22.1L220.5 291.7c20.7 17 50.4 17 71.1 0L464 150.1V128c0-8.8-7.2-16-16-16H64zM48 212.2V384c0 8.8 7.2 16 16 16H448c8.8 0 16-7.2 16-16V212.2L322 328.8c-38.4 31.5-93.7 31.5-132 0L48 212.2zM0 128C0 92.7 28.7 64 64 64H448c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128z"/></svg>`;
684    }
685    linkCode += `<p class="text-union-pink font-union-semibold text-lg text-ellipsis overflow-hidden">${processLanguage(text)}</p>`;
686  } else if (link.length > 0) {
687    linkCode = `<p class="text-union-pink font-union-semibold text-lg text-ellipsis overflow-hidden">${processLanguage(link)}</p>`;
688    link = '';
689  }
690
691  const card = `<div class="js-card background-flip-card lg:max-w-[320px] lg:h-[400px] lg:min-w-[320px] max-w-[240px] min-w-[240px] h-[300px] snap-center mx-auto">
692    <div class="flip-card h-full">
693      <div class="flip-card-content">
694        <div class="flip-front ${bgImage ? 'bg-[url(' + bgImage + ')]' : ''} bg-cover bg-center bg-no-repeat">
695          <div class="gradient-t-to-b-tiles-black h-full w-full flex items-end">
696            <div class="flex flex-col h-fit lg:min-h-32 justify-end px-5 py-3 w-60 lg:w-80 relative bottom-0 overflow-hidden">
697              <p class="font-union-bold text-2xl text-white w-full leading-6">${title}</p>
698              ${desc ? `<p class="text-white font-union leading-snug text-xl w-full">${desc}</p>` : ''}
699              ${linkCode ? `<div class="border-t-[1.5px] pt-2 mt-2 flex flex-nowrap text-ellipsis overflow-hidden w-full leading-6">${linkCode}</div>` : ''}
700            </div>
701          </div>
702        </div>
703        <div class="flip-back break-words p-5 overflow-y-auto">
704          <div class="font-union text-base lg:text-lg leading-snug">${back}</div>
705        </div>
706      </div>
707    </div>
708  </div>`;
709
710  if (link.length > 0) {
711    return `<a class="hover:no-underline flex" href="${link}">${card}</a>`;
712  }
713  return card;
714}
715
716
717/**
718 * Create Title
719 */
720function createTitle(titleText, colour){
721    if (typeof titleText === "undefined") {
722        return "";
723    }
724    
725    if (typeof colour === "undefined") {
726        colour = "pink";
727    }
728    
729    return `<h1 class="font-union-bold text-union-${colour} text-3xl lg:text-7xl">${processLanguage(titleText)}</h1>`
730}
731
732/**
733 * Create More Button
734 */
735function createMoreButton(buttonText, buttonLink, colour){
736    if (typeof buttonText === "undefined" || typeof buttonLink === "undefined") {
737        return "";
738    }
739    
740    if (typeof colour === "undefined") {
741        colour = "pink";
742    }
743    
744    return ` <a href="${buttonLink}"
745                class="header-button lg:border-4 border-2 lg:py-3 py-1 font-union-bold hover:no-underline lg:text-3xl rounded-full text-center text-union-${colour} union-${colour}-button px-4 lg:px-10">${processLanguage(buttonText)}
746            </a>`
747}
748
749
750/**
751 * Get Card Data
752 */
753async function getCardData(links, sectionType, dataArray) {
754    try {
755        const promises = links.map(async (link) => {
756            const request = new Request(link[0]);
757            const response = await fetch(request);
758            const data = await response.json();
759            const newData = [];
760            
761            for (let i = 0; i < data.length; i++) {
762                let formattedData = formatData(data[i], sectionType);
763                if (formattedData !== null) {
764                    newData.push(formattedData);
765                }
766                
767                if (i === link[1]) {
768                    break;
769                }
770            }
771            
772            return newData;
773        });
774
775        const results = await Promise.all(promises);
776
777        const newDataArray = results.flat();
778        
779        if (newDataArray.length === 0 || !newDataArray[0].hasOwnProperty('date')) {
780            return newDataArray;
781        }
782
783        const sortedDataArray = sortDataArray(newDataArray);
784        
785        return sortedDataArray;
786    } catch (error) {
787        console.error(error);
788        throw error;
789    }
790}
791
792
793/**
794 * Format Data
795 */
796function formatData (dataItem, sectionType) {
797    var basicFields = {
798        title:      (dataItem['Title'].indexOf(' | ') !== -1 ? dataItem['Title'].split(' | ') : dataItem['Title']),
799        bgImage:    dataItem["ImageUrl"].replace(/\\?\?(.*)/, "")+"?thumbnail_width=320&amp;thumbnail_height=400&amp;resize_type=CropToFit",
800        link:       dataItem["Url"],
801    };
802    
803    let location = dataItem['Location'];
804    
805    if ((dataItem['Location'] !== null || dataItem['Location'] !== undefined) && dataItem['Location']?.includes(" | ")) {
806        location = dataItem['Location'].split(' | ');
807    }
808    
809    var totalFields = {};
810    
811    if (sectionType === "events") {
812        let extraFields = {
813            date:       dataItem["StartDate"].replace(/([T])\w(.*)/, "").split("-").reverse().join("-"),
814            location:   location,
815            types:      dataItem["Types"] ?? [],
816        };
817        return totalFields = {...basicFields, ...extraFields};
818    }
819    else if (sectionType === "music") {
820        let tempLocation = location;
821        if (tempLocation === "The Great Hall"){
822            tempLocation = "Great Hall";
823        }
824        let extraFields = {
825            date:       dataItem["StartDate"].replace(/([T])\w(.*)/, "").split("-").reverse().join("-"),
826            location:   tempLocation,
827        };
828        
829        return totalFields = {...basicFields, ...extraFields};
830    }
831    else if (sectionType === "news"){
832        if (dataItem['Tags'].includes("featured")){
833            if (!featuredNewsBool) {
834                let extraFields = {
835                    fullImg: dataItem["ImageUrl"].replace(/\\?\?(.*)/, ""),
836                    tags:   dataItem["Tags"]
837                }
838                featuredNewsBool = true;
839                return totalFields = {...basicFields, ...extraFields};
840            }
841            else{
842                return null;
843            }
844        }
845        else {
846            let extraFields = {
847                    tags:   "",
848                    id:   "news"+newsId
849                }
850                newsId++;
851                return totalFields = {...basicFields, ...extraFields};
852        }
853    }  else if (sectionType === "flipcard") {
854        let extraFields = {
855            date:           dataItem["StartDate"].replace(/([T])\w(.*)/, "").split("-").reverse().join("-"),
856            endDate:        dataItem["EndDate"].replace(/([T])\w(.*)/, "").split("-").reverse().join("-"),
857            startTime:      dataItem["StartDate"].split("T")[1].substring(0, 5),
858            endTime:        dataItem["EndDate"].split("T")[1].substring(0, 5),
859            location:       location,
860            types:          dataItem["Types"],
861            description:    dataItem["Description"],
862            hasTickets:     dataItem["HasTickets"],
863            tickets:        dataItem["Tickets"],
864            img:            dataItem["ImageUrl"].replace(/\\?\?(.*)/, ""),
865            longDesc:       dataItem["Body"],
866        };
867        
868        return totalFields = {...basicFields, ...extraFields};
869    } else if (sectionType === "advert") {
870        let desc = (dataItem["Description"] ? dataItem["Description"] : dataItem["Body"])
871        let extraFields = {
872            description:    desc,
873            img:    dataItem["ImageUrl"].replace(/\\?\?(.*)/, ""),
874        };
875        
876        return totalFields = {...basicFields, ...extraFields};
877    }
878    else {
879        return basicFields;
880    }
881}
882
883
884/**
885 * Sort Data Array
886 */
887function sortDataArray(dataArray) {
888    dataArray.sort(function(a, b) {
889        const dateA = parseCustomDate(a.date);
890        const dateB = parseCustomDate(b.date);
891        return dateA - dateB;
892    });
893
894    return dataArray;
895}
896
897
898function parseCustomDate(dateStr) {
899    const [day, month, year] = dateStr.split('-');
900    return new Date(`${year}-${month}-${day}`).getTime();
901}
902
903
904/**
905 * Title Decider
906 */
907function titleDecider(title, typeTitleAdditionalClasses = "") {
908    const titleClass = `${typeTitleAdditionalClasses} h-full overflow-hidden text-ellipsis font-union-semibold js-card-h2`;
909    let newTitle = `<h2 class="${titleClass}">${processLanguage(title)}</h2>`;
910
911    return newTitle;
912}
913
914
915// Delegated hover handlers for news cards — replaces inline onMouseOver/onMouseOut
916$(document).on('mouseenter', '.substory-card', function () {
917    $(this).css('background-color', $(this).data('hover-bg'));
918}).on('mouseleave', '.substory-card', function () {
919    $(this).css('background-color', $(this).data('default-bg'));
920});
921
922
923/**
924 * Stop Loading
925 */
926function stopLoading(){
927    $('#loadingBarEventsMobile').addClass('hidden');
928    $('#loadingBarEvents').addClass('hidden');
929}
930
931
932/* ── FLIP CARD TOUCH SUPPORT ─────────────────────────────────────────────
933   CSS :hover doesn't fire reliably on touch devices. On touch, we toggle a
934   .flipped class instead. The CSS counterpart disables :hover on touch and
935   mirrors the transform onto .flipped — see flip card stylesheet.
936   ──────────────────────────────────────────────────────────────────────── */
937$(document).on('click', '.flip-card', function (e) {
938    if (!window.matchMedia('(hover: none) and (pointer: coarse)').matches) return;
939    if ($(e.target).closest('a[href]').length) return;
940    $(this).find('.flip-card-content').toggleClass('card-is-flipped');
941});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.