Concept43

The souvenir brand built for tourists, claimed by locals

A retail space in Maspalomas where local Canarian craft, gourmet products, and a branded clothing line share the same roof. The starting point: a name and an idea… We built the brand from there.

35100

In the Canary Islands, the number “35100” is the postal code for Maspalomas, one of Gran Canaria's most visited areas. Now, it's also the name of a new shop where Canarian craft, gourmet products, and a branded clothing line come together at a level most souvenir brands don't bother reaching. The audience splits between tourists looking for something worth bringing home and locals who take pride in where they're from. Both needed a reason to buy. The brand was built to give them one.

35100 branded T-shirts, front and back
end. * * Runs every carousel whose prefix is listed in CAROUSELS below * (currently "industries" and "services"). For a prefix, e.g. "services", * it hooks into these V4 global classes (edit their styles in Elementor, not here): * .services-carousel wrapper (one per carousel, can be reused on any page) * .services-track scrolling row that holds the cards * .services-arrow-prev previous button * .services-arrow-next next button * .services-dots container holding two sample dots (one active, one not) * .services-dot dot style * .services-dot-active active dot style * * To add a new carousel: build it with the same structure and class names * using a new prefix, then add that prefix to CAROUSELS. * * Logo walls (MARQUEES below) scroll on their own from right to left, loop * forever and can be dragged/swiped. For a prefix, e.g. "logos": * .logos-marquee the visible window (overflow hidden, holds the edge fades) * .logos-track the row of logos that moves; every child is one logo * Add/remove/reorder logos in Elementor; the script adapts on its own. * Visitors with "reduce motion" turned on get a still row they can drag. * * The script copies the sample dot so there is one dot per position. * Keep both sample dots in Elementor: they are the template, and they make * Elementor print the dot class CSS on the page. * * Infinite loop: the cards are copied once before and once after the real set. * When scrolling stops inside a copy, the track jumps (invisibly) to the * matching real card, so it never runs out in either direction. * * Autoplay (AUTOPLAY below): moves one card forward every few seconds. * The countdown restarts whenever the carousel stops moving, so visitors who * use the arrows, dots or swipe always get the full pause before it moves on. * It pauses while the mouse is over the carousel, while a finger is on it, * while it has keyboard focus, while it is off screen or the tab is hidden. * Visitors with "reduce motion" turned on don't get autoplay. */ (function () { // prefix: loop on/off (false = carousel with a start and an end) var CAROUSELS = { industries: true, services: true }; // prefix: seconds between automatic moves (leave a prefix out = no autoplay) var AUTOPLAY = { industries: 2.5, services: 2.5 }; // Logo walls (auto-scrolling, draggable, no navigation). // prefix: seconds each logo takes to move one slot (higher = slower) var MARQUEES = { logos: 4 }; var DISABLED_OPACITY = '0.35'; var DRAG_THRESHOLD = 6; // px the mouse must move before a click becomes a drag var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; var hasScrollEnd = 'onscrollend' in window; function mod(a, n) { return ((a % n) + n) % n; } function init(root, p, LOOP, AUTO) { if (root.dataset.carouselReady) return; var track = root.querySelector('.' + p + '-track'); if (!track) return; root.dataset.carouselReady = '1'; var DOT = p + '-dot'; var ACTIVE = p + '-dot-active'; var prev = root.querySelector('.' + p + '-arrow-prev'); var next = root.querySelector('.' + p + '-arrow-next'); var dotsWrap = root.querySelector('.' + p + '-dots'); var originals = Array.prototype.slice.call(track.children); var n = originals.length; var clones = []; var looping = false; var dots = []; var ticking = false; var settleTimer = null; var target = null; // where a smooth scroll is heading var template = null; var pointerDown = false; // mouse drag state var dragging = false; var suppressClick = false; var startX = 0; var startScroll = 0; var autoTimer = null; // autoplay countdown var autoMovedAt = 0; // when the last automatic move started var hovering = false; var touching = false; var focused = false; var onScreen = true; if (!n) return; if (dotsWrap) { var sample = dotsWrap.querySelector('.' + DOT + ':not(.' + ACTIVE + ')') || dotsWrap.querySelector('.' + DOT); if (sample) { template = sample.cloneNode(false); template.classList.remove(ACTIVE); ['data-id', 'data-interaction-id', 'id'].forEach(function (a) { template.removeAttribute(a); }); } } /* ---------- measurements ---------- */ function step() { return n > 1 ? originals[1].offsetLeft - originals[0].offsetLeft : track.clientWidth; } function base() { // scroll position at which the first real card is aligned to the start return originals[0].offsetLeft - track.firstElementChild.offsetLeft; } function maxScroll() { return Math.max(0, track.scrollWidth - track.clientWidth); } function overflows() { var last = originals[n - 1]; return last.offsetLeft + last.offsetWidth - originals[0].offsetLeft > track.clientWidth + 2; } function pageCount() { if (looping) return n; var m = maxScroll(); return m <= 2 ? 1 : Math.ceil(m / step() - 0.05) + 1; } function position() { // card-sized position relative to the first real card (can be negative / beyond n) var x = target !== null ? target : track.scrollLeft; return Math.round((x - base()) / step()); } function current() { if (looping) return mod(position(), n); if (track.scrollLeft >= maxScroll() - 2) return pageCount() - 1; return Math.round(track.scrollLeft / step()); } /* ---------- movement ---------- */ function jump(left) { track.style.scrollBehavior = 'auto'; track.scrollLeft = left; track.style.scrollBehavior = ''; } function smooth(left) { left = Math.max(0, Math.min(left, maxScroll())); clearTimeout(settleTimer); if (reduceMotion || Math.abs(left - track.scrollLeft) < 1) { jump(left); settle(); return; } target = left; track.scrollTo({ left: left, behavior: 'smooth' }); settleTimer = setTimeout(function () { settle(true); }, 1000); // safety net in case scrollend never fires } function goBy(delta) { if (looping) { smooth(base() + (position() + delta) * step()); } else { goTo(current() + delta); } } function goTo(i) { if (looping) { // pick the copy of card i closest to where we are, so we always take the short way var s = step(), w = n * s, x = track.scrollLeft; var left = base() + i * s; [left - w, left + w].forEach(function (c) { if (Math.abs(c - x) < Math.abs(left - x)) left = c; }); smooth(left); return; } var last = pageCount() - 1; i = Math.max(0, Math.min(i, last)); smooth(i === last ? maxScroll() : i * step()); } // After scrolling stops: if we're sitting on a copy, jump to the real card. function settle(force) { if (pointerDown) return; // still dragging with the mouse // a stray scrollend can arrive before an arrow/drag glide has arrived if (force !== true && target !== null && Math.abs(track.scrollLeft - target) > 1) return; target = null; track.style.scrollSnapType = ''; if (looping) { var s = step(), w = n * s, rel = track.scrollLeft - base(); if (rel < -s / 2) jump(track.scrollLeft + w); else if (rel > w - s / 2) jump(track.scrollLeft - w); } update(); schedule(); } /* ---------- autoplay ---------- */ function canAutoplay() { return AUTO > 0 && !reduceMotion && !hovering && !touching && !focused && !pointerDown && onScreen && !document.hidden && pageCount() > 1; } // (Re)start the countdown; called every time the carousel comes to rest. // After an automatic move the time spent gliding counts towards the wait, // so moves start exactly AUTO apart. After a visitor action: a full wait. function schedule() { clearTimeout(autoTimer); autoTimer = null; if (!canAutoplay()) return; var since = Date.now() - autoMovedAt; if (since >= AUTO) since = 0; autoTimer = setTimeout(function () { autoTimer = null; if (!canAutoplay()) return; if (target !== null) { autoMovedAt = 0; schedule(); return; } // still gliding, wait a full round autoMovedAt = Date.now(); if (!looping && current() >= pageCount() - 1) goTo(0); // no loop: rewind at the end else goBy(1); }, Math.max(AUTO - since, 300)); } /* ---------- loop copies ---------- */ function makeClone(el) { var c = el.cloneNode(true); c.setAttribute('aria-hidden', 'true'); c.setAttribute('data-carousel-clone', ''); c.removeAttribute('id'); [c].concat(Array.prototype.slice.call(c.querySelectorAll('a, button, [tabindex], [id]'))).forEach(function (e) { if (e.matches('a, button, [tabindex]')) e.setAttribute('tabindex', '-1'); if (e !== c) e.removeAttribute('id'); }); return c; } function addClones() { var before = document.createDocumentFragment(); var after = document.createDocumentFragment(); originals.forEach(function (el) { var b = makeClone(el), a = makeClone(el); before.appendChild(b); after.appendChild(a); clones.push(b, a); }); track.insertBefore(before, originals[0]); track.appendChild(after); looping = true; } function removeClones() { clones.forEach(function (c) { c.remove(); }); clones = []; looping = false; } /* ---------- UI state ---------- */ function setArrow(btn, disabled) { if (!btn) return; btn.style.opacity = disabled ? DISABLED_OPACITY : ''; btn.style.cursor = disabled ? 'default' : ''; btn.setAttribute('aria-disabled', disabled ? 'true' : 'false'); } function update() { ticking = false; var count = pageCount(); var idx = current(); var single = count <= 1; if (prev) prev.style.visibility = single ? 'hidden' : ''; if (next) next.style.visibility = single ? 'hidden' : ''; if (dotsWrap) dotsWrap.style.visibility = single ? 'hidden' : ''; if (!dragging) track.style.cursor = single ? '' : 'grab'; setArrow(prev, !looping && idx <= 0); setArrow(next, !looping && idx >= count - 1); dots.forEach(function (d, i) { d.classList.toggle(ACTIVE, i === idx); if (i === idx) d.setAttribute('aria-current', 'true'); else d.removeAttribute('aria-current'); }); } function buildDots() { if (!dotsWrap) return; var count = pageCount(); if (dots.length === count) return; dotsWrap.innerHTML = ''; dots = []; for (var i = 0; i < count; i++) { var d; if (template) { d = template.cloneNode(false); } else { d = document.createElement('button'); d.className = DOT; } if (d.tagName === 'BUTTON') d.type = 'button'; d.setAttribute('aria-label', 'Go to slide ' + (i + 1)); d.addEventListener('click', goTo.bind(null, i)); dotsWrap.appendChild(d); dots.push(d); } } // Runs on load and whenever the track changes size (breakpoints, rotation…) function refresh() { var idx = looping ? current() : 0; var shouldLoop = LOOP && n > 1 && overflows(); if (shouldLoop && !looping) addClones(); if (!shouldLoop && looping) removeClones(); if (looping) jump(base() + idx * step()); target = null; buildDots(); update(); schedule(); } /* ---------- events ---------- */ if (prev) { prev.setAttribute('aria-label', 'Previous'); prev.addEventListener('click', function () { goBy(-1); }); } if (next) { next.setAttribute('aria-label', 'Next'); next.addEventListener('click', function () { goBy(1); }); } track.addEventListener('scroll', function () { if (!ticking) { ticking = true; requestAnimationFrame(update); } if (!hasScrollEnd) { clearTimeout(settleTimer); settleTimer = setTimeout(function () { settle(); }, 150); } }, { passive: true }); if (hasScrollEnd) track.addEventListener('scrollend', function () { settle(); }); /* ---------- autoplay pauses ---------- */ if (AUTO > 0) { // mouse only: on touch screens a tap would count as "hovering" and never end root.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { hovering = true; schedule(); } }); root.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') { hovering = false; schedule(); } }); // touch events keep firing during a native swipe (pointer events get cancelled) // any visitor action (click, tap, key, trackpad scroll) earns a full wait afterwards ['pointerdown', 'keydown', 'wheel'].forEach(function (type) { root.addEventListener(type, function () { autoMovedAt = 0; }, { capture: true, passive: true }); }); root.addEventListener('touchstart', function () { autoMovedAt = 0; touching = true; schedule(); }, { passive: true }); var touchEnd = function (e) { if (e.touches.length) return; touching = false; schedule(); }; root.addEventListener('touchend', touchEnd, { passive: true }); root.addEventListener('touchcancel', touchEnd, { passive: true }); // keyboard users: pause while tabbing through the carousel (a mouse click // on an arrow can leave focus behind, so only count keyboard focus) var checkFocus = function () { var a = document.activeElement; var f = false; try { f = !!a && root.contains(a) && a.matches(':focus-visible'); } catch (err) { f = !!a && root.contains(a); } if (f !== focused) { focused = f; schedule(); } }; root.addEventListener('focusin', checkFocus); root.addEventListener('focusout', function () { setTimeout(checkFocus, 0); }); if (window.IntersectionObserver) { new IntersectionObserver(function (entries) { onScreen = entries[0].isIntersecting; schedule(); }).observe(root); } document.addEventListener('visibilitychange', schedule); } /* ---------- mouse drag (touch already swipes natively) ---------- */ track.addEventListener('dragstart', function (e) { e.preventDefault(); }); // no ghost images/links track.addEventListener('pointerdown', function (e) { if (e.pointerType !== 'mouse' || e.button !== 0 || pageCount() <= 1) return; pointerDown = true; dragging = false; startX = e.clientX; startScroll = track.scrollLeft; }); track.addEventListener('pointermove', function (e) { if (!pointerDown) return; var dx = e.clientX - startX; if (!dragging) { if (Math.abs(dx) < DRAG_THRESHOLD) return; dragging = true; clearTimeout(settleTimer); target = null; track.setPointerCapture(e.pointerId); track.style.scrollSnapType = 'none'; track.style.scrollBehavior = 'auto'; track.style.userSelect = 'none'; track.style.cursor = 'grabbing'; if (window.getSelection) window.getSelection().removeAllRanges(); } track.scrollLeft = startScroll - dx; }); function endDrag(e) { if (!pointerDown) return; pointerDown = false; if (!dragging) return; dragging = false; suppressClick = true; // a drag shouldn't open the card's link setTimeout(function () { suppressClick = false; }, 0); if (track.hasPointerCapture(e.pointerId)) track.releasePointerCapture(e.pointerId); track.style.scrollBehavior = ''; track.style.userSelect = ''; track.style.cursor = 'grab'; // Land on a card: any drag past ~10% of a card moves in the drag direction var s = step(); var rel = (track.scrollLeft - (looping ? base() : 0)) / s; var forward = e.clientX < startX; var pos = forward ? Math.ceil(rel - 0.1) : Math.floor(rel + 0.1); if (looping) smooth(base() + pos * s); else goTo(pos); } track.addEventListener('pointerup', endDrag); track.addEventListener('pointercancel', endDrag); track.addEventListener('lostpointercapture', endDrag); track.addEventListener('click', function (e) { if (!suppressClick) return; e.preventDefault(); e.stopPropagation(); suppressClick = false; }, true); var lastWidth = -1; function onResize() { if (track.clientWidth === lastWidth) return; lastWidth = track.clientWidth; refresh(); } if (window.ResizeObserver) new ResizeObserver(onResize).observe(track); else window.addEventListener('resize', onResize); onResize(); } /* ================= Logo wall (marquee) ================= */ function initMarquee(root, p, secondsPerItem) { if (root.dataset.marqueeReady) return; var track = root.querySelector('.' + p + '-track'); if (!track) return; var originals = Array.prototype.slice.call(track.children); var n = originals.length; if (!n) return; root.dataset.marqueeReady = '1'; var clones = []; var x = 0; // current offset in px (always between -setW and 0) var v = 0; // current velocity in px/s (negative = moving left) var setW = 0; // width of one full set of logos var speed = 0; // cruising speed in px/s var last = 0; var running = false; var inView = true; var pointerDown = false, dragging = false, suppressClick = false; var startX = 0, startOffset = 0, lastMoveX = 0, lastMoveT = 0, dragV = 0; track.style.touchAction = 'pan-y'; // vertical page scroll stays native on touch track.querySelectorAll('img').forEach(function (img) { img.setAttribute('draggable', 'false'); img.loading = 'eager'; // logos off to the side must be ready before they slide in }); if (track.tagName === 'IMG') track.setAttribute('draggable', 'false'); function wrap(val) { if (!setW) return 0; val = val % setW; return val > 0 ? val - setW : val; } function render() { track.style.transform = 'translate3d(' + x + 'px,0,0)'; } function measure() { var ratio = setW ? x / setW : 0; clones.forEach(function (c) { c.remove(); }); clones = []; function addSet() { originals.forEach(function (el) { var c = el.cloneNode(true); c.setAttribute('aria-hidden', 'true'); c.setAttribute('data-carousel-clone', ''); c.removeAttribute('id'); c.querySelectorAll('[id]').forEach(function (e) { e.removeAttribute('id'); }); c.querySelectorAll('a, button').forEach(function (e) { e.setAttribute('tabindex', '-1'); }); if (c.matches('a, button')) c.setAttribute('tabindex', '-1'); track.appendChild(c); clones.push(c); }); } // one full set = distance from the first logo to its first copy // (works with logos of any width and any gap) addSet(); setW = clones[0].offsetLeft - originals[0].offsetLeft; speed = setW / n / secondsPerItem; if (setW <= 0) { setW = 0; return; } // add more copies if needed so the row never runs out while it loops var sets = Math.max(1, Math.ceil(root.clientWidth / setW)); for (var s = 1; s < sets; s++) addSet(); x = wrap(ratio * setW); render(); } function frame(t) { if (!running) return; var dt = last ? Math.min((t - last) / 1000, 0.05) : 0; last = t; if (!dragging) { var cruise = reduceMotion ? 0 : -speed; v += (cruise - v) * (1 - Math.exp(-dt * 4)); // ease back to cruising after a flick x = wrap(x + v * dt); render(); } requestAnimationFrame(frame); } function start() { if (running || !inView) return; running = true; last = 0; requestAnimationFrame(frame); } function stop() { running = false; } /* drag / swipe (mouse and touch) */ track.addEventListener('dragstart', function (e) { e.preventDefault(); }); track.addEventListener('pointerdown', function (e) { if (e.pointerType === 'mouse' && e.button !== 0) return; pointerDown = true; dragging = false; startX = lastMoveX = e.clientX; startOffset = x; lastMoveT = performance.now(); dragV = 0; }); track.addEventListener('pointermove', function (e) { if (!pointerDown) return; var dx = e.clientX - startX; if (!dragging) { if (Math.abs(dx) < DRAG_THRESHOLD) return; dragging = true; track.setPointerCapture(e.pointerId); track.style.cursor = 'grabbing'; if (window.getSelection) window.getSelection().removeAllRanges(); } var now = performance.now(); var dtm = (now - lastMoveT) / 1000; if (dtm > 0) dragV = 0.8 * ((e.clientX - lastMoveX) / dtm) + 0.2 * dragV; lastMoveX = e.clientX; lastMoveT = now; x = wrap(startOffset + dx); render(); }); function endDrag(e) { if (!pointerDown) return; pointerDown = false; if (!dragging) return; dragging = false; suppressClick = true; setTimeout(function () { suppressClick = false; }, 0); if (track.hasPointerCapture(e.pointerId)) track.releasePointerCapture(e.pointerId); track.style.cursor = ''; // keep the flick's momentum, unless the pointer was held still before release v = performance.now() - lastMoveT > 100 ? 0 : Math.max(-2500, Math.min(2500, dragV)); } track.addEventListener('pointerup', endDrag); track.addEventListener('pointercancel', endDrag); track.addEventListener('lostpointercapture', endDrag); track.addEventListener('click', function (e) { if (!suppressClick) return; e.preventDefault(); e.stopPropagation(); suppressClick = false; }, true); /* only animate while the wall is on screen */ if (window.IntersectionObserver) { new IntersectionObserver(function (entries) { inView = entries[0].isIntersecting; if (inView) start(); else stop(); }).observe(root); } var lastWidth = -1; function onResize() { if (root.clientWidth === lastWidth) return; lastWidth = root.clientWidth; measure(); } if (window.ResizeObserver) new ResizeObserver(onResize).observe(root); else window.addEventListener('resize', onResize); onResize(); // logo widths depend on the images, so measure again once each one has loaded originals.forEach(function (el) { var imgs = el.tagName === 'IMG' ? [el] : Array.prototype.slice.call(el.querySelectorAll('img')); imgs.forEach(function (img) { if (!img.complete) img.addEventListener('load', measure, { once: true }); }); }); v = reduceMotion ? 0 : -speed; start(); } function initAll() { Object.keys(CAROUSELS).forEach(function (p) { document.querySelectorAll('.' + p + '-carousel').forEach(function (root) { init(root, p, CAROUSELS[p], (AUTOPLAY[p] || 0) * 1000); }); }); Object.keys(MARQUEES).forEach(function (p) { document.querySelectorAll('.' + p + '-marquee').forEach(function (root) { initMarquee(root, p, MARQUEES[p]); }); }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll); else initAll(); })();