feat: Added agency-web-developer skill
- New skill for building static websites from copy + design system - HTML semantic with accessibility + SEO best practices - CSS layout with Bootstrap or custom support - JS interactivity with jQuery + GSAP for smooth animations - 3 reference files: html_semantics.md, css_layout.md, js_interactivity.md - Updated README with new skill (13 total) and references (23 total) - Workflow updated: agency-ux-copy → agency-web-developer → agency-seo No overlap with existing skills: - agency-visual-generator: generates images (PNG/webp) for social/YouTube - agency-publisher: publishes content via webhook - agency-web-developer: builds complete static websites (HTML/CSS/JS)
This commit is contained in:
parent
6ac766172c
commit
b79b28034a
5 changed files with 2153 additions and 5 deletions
730
agency-web-developer/references/js_interactivity.md
Normal file
730
agency-web-developer/references/js_interactivity.md
Normal file
|
|
@ -0,0 +1,730 @@
|
|||
# JavaScript Interactivity — jQuery + GSAP
|
||||
|
||||
Guida per aggiungere interattività e animazioni fluide ai siti web.
|
||||
|
||||
---
|
||||
|
||||
## Setup Librerie
|
||||
|
||||
### CDN Links
|
||||
|
||||
```html
|
||||
<head>
|
||||
<!-- jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
|
||||
<!-- GSAP -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
|
||||
<!-- GSAP ScrollTrigger (opzionale, per scroll animations) -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Main JS -->
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## jQuery Patterns
|
||||
|
||||
### Document Ready
|
||||
|
||||
```javascript
|
||||
$(document).ready(function() {
|
||||
// Tutto il codice va qui dentro
|
||||
// o usa la shorthand:
|
||||
});
|
||||
|
||||
// Shorthand (equivalente)
|
||||
$(function() {
|
||||
// Codice qui
|
||||
});
|
||||
```
|
||||
|
||||
### Mobile Menu Toggle
|
||||
|
||||
```javascript
|
||||
$(function() {
|
||||
const $menuToggle = $('.mobile-menu-toggle');
|
||||
const $navMenu = $('.nav-menu');
|
||||
const $body = $('body');
|
||||
|
||||
$menuToggle.on('click', function() {
|
||||
$navMenu.toggleClass('is-open');
|
||||
$menuToggle.toggleClass('is-active');
|
||||
$body.toggleClass('menu-open');
|
||||
|
||||
// Update ARIA
|
||||
const expanded = $menuToggle.attr('aria-expanded') === 'true';
|
||||
$menuToggle.attr('aria-expanded', !expanded);
|
||||
});
|
||||
|
||||
// Close menu on link click (mobile)
|
||||
$navMenu.find('a').on('click', function() {
|
||||
if ($(window).width() < 768) {
|
||||
$navMenu.removeClass('is-open');
|
||||
$menuToggle.removeClass('is-active');
|
||||
$body.removeClass('menu-open');
|
||||
$menuToggle.attr('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu on outside click
|
||||
$(document).on('click', function(e) {
|
||||
if (!$(e.target).closest('.nav-menu, .mobile-menu-toggle').length) {
|
||||
$navMenu.removeClass('is-open');
|
||||
$menuToggle.removeClass('is-active');
|
||||
$body.removeClass('menu-open');
|
||||
$menuToggle.attr('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Smooth Scroll
|
||||
|
||||
```javascript
|
||||
$(function() {
|
||||
$('a[href^="#"]').on('click', function(e) {
|
||||
const targetId = this.getAttribute('href');
|
||||
|
||||
// Ignora link vuoti o non-anchor
|
||||
if (targetId === '#' || !targetId.startsWith('#')) return;
|
||||
|
||||
const $target = $(targetId);
|
||||
|
||||
if ($target.length) {
|
||||
e.preventDefault();
|
||||
|
||||
const offsetTop = $target.offset().top;
|
||||
const headerHeight = $('header').outerHeight() || 0;
|
||||
const scrollPosition = offsetTop - headerHeight - 20;
|
||||
|
||||
$('html, body').stop().animate({
|
||||
scrollTop: scrollPosition
|
||||
}, 800, 'easeInOutQuad');
|
||||
|
||||
// Update URL without jumping
|
||||
history.pushState(null, null, targetId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Easing function (se non usi jQuery easing plugin)
|
||||
$.easing.easeInOutQuad = function(x, t, b, c, d) {
|
||||
t /= d / 2;
|
||||
if (t < 1) return c / 2 * t * t + b;
|
||||
t--;
|
||||
return -c / 2 * (t * (t - 2) - 1) + b;
|
||||
};
|
||||
```
|
||||
|
||||
### FAQ Accordion
|
||||
|
||||
```javascript
|
||||
$(function() {
|
||||
const $faqQuestions = $('.faq-question');
|
||||
|
||||
$faqQuestions.on('click', function() {
|
||||
const $question = $(this);
|
||||
const $answer = $question.next('.faq-answer');
|
||||
const $item = $question.closest('.faq-item');
|
||||
const isActive = $question.hasClass('is-active');
|
||||
|
||||
// Close all other items (accordion style)
|
||||
$faqQuestions.not(this).removeClass('is-active');
|
||||
$('.faq-answer').not($answer).slideUp(300);
|
||||
$('.faq-item').not($item).removeClass('is-active');
|
||||
|
||||
// Toggle current item
|
||||
$question.toggleClass('is-active');
|
||||
$answer.slideToggle(300);
|
||||
$item.toggleClass('is-active');
|
||||
|
||||
// Update ARIA
|
||||
const expanded = $question.attr('aria-expanded') === 'true';
|
||||
$question.attr('aria-expanded', !expanded);
|
||||
});
|
||||
|
||||
// Keyboard accessibility
|
||||
$faqQuestions.on('keydown', function(e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
$(this).click();
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Form Validation
|
||||
|
||||
```javascript
|
||||
$(function() {
|
||||
const $form = $('.contact-form');
|
||||
const $submitBtn = $form.find('button[type="submit"]');
|
||||
|
||||
$form.on('submit', function(e) {
|
||||
let isValid = true;
|
||||
|
||||
// Validate required fields
|
||||
$form.find('[required]').each(function() {
|
||||
const $field = $(this);
|
||||
const value = $field.val().trim();
|
||||
const $error = $field.siblings('.form-error');
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
$field.addClass('is-invalid');
|
||||
if ($error.length) {
|
||||
$error.text('Questo campo è obbligatorio').show();
|
||||
} else {
|
||||
$field.after('<span class="form-error">Questo campo è obbligatorio</span>');
|
||||
}
|
||||
} else {
|
||||
$field.removeClass('is-invalid');
|
||||
$error.hide();
|
||||
|
||||
// Email validation
|
||||
if ($field.attr('type') === 'email') {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
isValid = false;
|
||||
$field.addClass('is-invalid');
|
||||
if ($error.length) {
|
||||
$error.text('Inserisci un\'email valida').show();
|
||||
} else {
|
||||
$field.after('<span class="form-error">Inserisci un\'email valida</span>');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
e.preventDefault();
|
||||
// Scroll to first error
|
||||
const $firstError = $form.find('.is-invalid').first();
|
||||
if ($firstError.length) {
|
||||
$('html, body').animate({
|
||||
scrollTop: $firstError.offset().top - 100
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clear error on input
|
||||
$form.find('input, textarea').on('input', function() {
|
||||
$(this).removeClass('is-invalid');
|
||||
$(this).siblings('.form-error').hide();
|
||||
});
|
||||
|
||||
// Form submission success (AJAX example with Formspree)
|
||||
$form.on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
$submitBtn.prop('disabled', true).text('Invio in corso...');
|
||||
|
||||
$.ajax({
|
||||
url: $form.attr('action'),
|
||||
method: 'POST',
|
||||
data: $form.serialize(),
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
.done(function(response) {
|
||||
$form.trigger('reset');
|
||||
$submitBtn.text('Messaggio Inviato!');
|
||||
|
||||
// Show success message
|
||||
$form.after('<div class="form-success">Grazie! Ti contatteremo presto.</div>');
|
||||
|
||||
setTimeout(function() {
|
||||
$submitBtn.prop('disabled', false).text('Invia Messaggio');
|
||||
$('.form-success').fadeOut();
|
||||
}, 3000);
|
||||
})
|
||||
.fail(function() {
|
||||
$submitBtn.prop('disabled', false).text('Riprova');
|
||||
$form.after('<div class="form-error">Errore nell\'invio. Riprova più tardi.</div>');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Back to Top Button
|
||||
|
||||
```javascript
|
||||
$(function() {
|
||||
const $backToTop = $('.back-to-top');
|
||||
|
||||
// Show/hide on scroll
|
||||
$(window).on('scroll', function() {
|
||||
if ($(window).scrollTop() > 300) {
|
||||
$backToTop.addClass('is-visible');
|
||||
} else {
|
||||
$backToTop.removeClass('is-visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Smooth scroll to top
|
||||
$backToTop.on('click', function(e) {
|
||||
e.preventDefault();
|
||||
$('html, body').animate({
|
||||
scrollTop: 0
|
||||
}, 800);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- HTML for back to top -->
|
||||
<a href="#top" class="back-to-top" aria-label="Torna su">
|
||||
<svg><!-- icon --></svg>
|
||||
</a>
|
||||
```
|
||||
|
||||
```css
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.back-to-top.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.back-to-top:hover {
|
||||
background: var(--color-primary-dark);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
```
|
||||
|
||||
### Image Lazy Loading
|
||||
|
||||
```javascript
|
||||
$(function() {
|
||||
const $lazyImages = $('img[loading="lazy"]');
|
||||
|
||||
// Native lazy loading support check
|
||||
if ('loading' in HTMLImageElement.prototype) {
|
||||
// Browser supports native lazy loading
|
||||
$lazyImages.each(function() {
|
||||
const src = $(this).data('src');
|
||||
if (src) {
|
||||
$(this).attr('src', src);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback for browsers without support
|
||||
const imageObserver = new IntersectionObserver(function(entries, observer) {
|
||||
entries.forEach(function(entry) {
|
||||
if (entry.isIntersecting) {
|
||||
const $img = $(entry.target);
|
||||
const src = $img.data('src');
|
||||
if (src) {
|
||||
$img.attr('src', src);
|
||||
$img.on('load', function() {
|
||||
$img.addClass('is-loaded');
|
||||
});
|
||||
}
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$lazyImages.each(function() {
|
||||
imageObserver.observe(this);
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GSAP Animations
|
||||
|
||||
### Basic Animations
|
||||
|
||||
```javascript
|
||||
$(function() {
|
||||
// Fade in element
|
||||
gsap.from('.hero h1', {
|
||||
duration: 1,
|
||||
opacity: 0,
|
||||
y: 30,
|
||||
ease: 'power3.out'
|
||||
});
|
||||
|
||||
// Stagger animation
|
||||
gsap.from('.service-card', {
|
||||
duration: 0.8,
|
||||
opacity: 0,
|
||||
y: 40,
|
||||
stagger: 0.15,
|
||||
ease: 'power3.out',
|
||||
delay: 0.3
|
||||
});
|
||||
|
||||
// Multiple properties
|
||||
gsap.from('.hero .btn', {
|
||||
duration: 1,
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
scale: 0.95,
|
||||
delay: 0.5,
|
||||
ease: 'back.out(1.7)'
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Timeline
|
||||
|
||||
```javascript
|
||||
$(function() {
|
||||
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
|
||||
|
||||
tl.from('.hero h1', {
|
||||
duration: 1,
|
||||
opacity: 0,
|
||||
y: 50
|
||||
})
|
||||
.from('.hero-sub', {
|
||||
duration: 0.8,
|
||||
opacity: 0,
|
||||
y: 30
|
||||
}, '-=0.5')
|
||||
.from('.hero .btn', {
|
||||
duration: 0.6,
|
||||
opacity: 0,
|
||||
y: 20
|
||||
}, '-=0.4')
|
||||
.from('.logo-wall', {
|
||||
duration: 1,
|
||||
opacity: 0
|
||||
}, '-=0.3');
|
||||
});
|
||||
```
|
||||
|
||||
### ScrollTrigger Animations
|
||||
|
||||
```javascript
|
||||
// Register ScrollTrigger
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
$(function() {
|
||||
// Fade in on scroll
|
||||
gsap.utils.toArray('.fade-on-scroll').forEach(function(elem) {
|
||||
gsap.to(elem, {
|
||||
scrollTrigger: {
|
||||
trigger: elem,
|
||||
start: 'top 85%',
|
||||
toggleActions: 'play none none none'
|
||||
},
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 1,
|
||||
ease: 'power3.out'
|
||||
});
|
||||
});
|
||||
|
||||
// Slide in from left
|
||||
gsap.utils.toArray('.slide-in-left').forEach(function(elem) {
|
||||
gsap.from(elem, {
|
||||
scrollTrigger: {
|
||||
trigger: elem,
|
||||
start: 'top 80%',
|
||||
toggleActions: 'play none none none'
|
||||
},
|
||||
opacity: 0,
|
||||
x: -50,
|
||||
duration: 1,
|
||||
ease: 'power3.out'
|
||||
});
|
||||
});
|
||||
|
||||
// Slide in from right
|
||||
gsap.utils.toArray('.slide-in-right').forEach(function(elem) {
|
||||
gsap.from(elem, {
|
||||
scrollTrigger: {
|
||||
trigger: elem,
|
||||
start: 'top 80%',
|
||||
toggleActions: 'play none none none'
|
||||
},
|
||||
opacity: 0,
|
||||
x: 50,
|
||||
duration: 1,
|
||||
ease: 'power3.out'
|
||||
});
|
||||
});
|
||||
|
||||
// Scale up
|
||||
gsap.utils.toArray('.scale-up').forEach(function(elem) {
|
||||
gsap.from(elem, {
|
||||
scrollTrigger: {
|
||||
trigger: elem,
|
||||
start: 'top 85%',
|
||||
toggleActions: 'play none none none'
|
||||
},
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
duration: 1,
|
||||
ease: 'back.out(1.7)'
|
||||
});
|
||||
});
|
||||
|
||||
// Parallax effect
|
||||
gsap.to('.parallax-bg', {
|
||||
scrollTrigger: {
|
||||
trigger: '.hero',
|
||||
start: 'top top',
|
||||
end: 'bottom top',
|
||||
scrub: true
|
||||
},
|
||||
y: 100,
|
||||
ease: 'none'
|
||||
});
|
||||
|
||||
// Pin section (sticky)
|
||||
gsap.to('.sticky-section', {
|
||||
scrollTrigger: {
|
||||
trigger: '.sticky-section',
|
||||
start: 'top top',
|
||||
end: '+=100%',
|
||||
pin: true,
|
||||
scrub: true
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Hover Animations
|
||||
|
||||
```javascript
|
||||
$(function() {
|
||||
// Card hover with GSAP
|
||||
const $cards = $('.service-card');
|
||||
|
||||
$cards.each(function() {
|
||||
const $card = $(this);
|
||||
|
||||
$card.on('mouseenter', function() {
|
||||
gsap.to($card, {
|
||||
duration: 0.3,
|
||||
y: -8,
|
||||
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.15)',
|
||||
ease: 'power2.out'
|
||||
});
|
||||
});
|
||||
|
||||
$card.on('mouseleave', function() {
|
||||
gsap.to($card, {
|
||||
duration: 0.3,
|
||||
y: 0,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
ease: 'power2.out'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Button hover
|
||||
const $buttons = $('.btn');
|
||||
|
||||
$buttons.each(function() {
|
||||
const $btn = $(this);
|
||||
|
||||
$btn.on('mouseenter', function() {
|
||||
gsap.to($btn, {
|
||||
duration: 0.2,
|
||||
scale: 1.05,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
});
|
||||
|
||||
$btn.on('mouseleave', function() {
|
||||
gsap.to($btn, {
|
||||
duration: 0.2,
|
||||
scale: 1,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Complex Animation Sequence
|
||||
|
||||
```javascript
|
||||
$(function() {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: '.feature-section',
|
||||
start: 'top 70%',
|
||||
toggleActions: 'play none none none'
|
||||
}
|
||||
});
|
||||
|
||||
tl.from('.feature-section h2', {
|
||||
duration: 0.8,
|
||||
opacity: 0,
|
||||
y: 30,
|
||||
ease: 'power3.out'
|
||||
})
|
||||
.from('.feature-section p', {
|
||||
duration: 0.6,
|
||||
opacity: 0,
|
||||
y: 20
|
||||
}, '-=0.4')
|
||||
.from('.feature-grid .feature-card', {
|
||||
duration: 0.6,
|
||||
opacity: 0,
|
||||
y: 40,
|
||||
stagger: 0.1,
|
||||
ease: 'back.out(1.7)'
|
||||
}, '-=0.3')
|
||||
.from('.feature-section .btn', {
|
||||
duration: 0.5,
|
||||
opacity: 0,
|
||||
scale: 0.9
|
||||
}, '-=0.3');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Best Practices
|
||||
|
||||
### Reduce Motion
|
||||
|
||||
```javascript
|
||||
// Check for reduced motion preference
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
// Disable GSAP animations
|
||||
gsap.globalTimeline.timeScale(0);
|
||||
|
||||
// Or set duration to 0
|
||||
gsap.defaults({ duration: 0 });
|
||||
}
|
||||
```
|
||||
|
||||
### Debounce Scroll Events
|
||||
|
||||
```javascript
|
||||
// Debounce function
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
$(window).on('scroll', debounce(function() {
|
||||
// Scroll logic here
|
||||
}, 100));
|
||||
```
|
||||
|
||||
### Use CSS for Simple Animations
|
||||
|
||||
```css
|
||||
/* Prefer CSS transitions for simple hover effects */
|
||||
.btn {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Use GSAP only for complex animations */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Respect User Preferences
|
||||
|
||||
```javascript
|
||||
// Check reduced motion
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
// Initialize animations
|
||||
initAnimations();
|
||||
}
|
||||
|
||||
function initAnimations() {
|
||||
// GSAP animations here
|
||||
}
|
||||
```
|
||||
|
||||
### Focus Management
|
||||
|
||||
```javascript
|
||||
// Trap focus in mobile menu
|
||||
function trapFocus($element) {
|
||||
const $focusable = $element.find('a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])');
|
||||
const $first = $focusable.first();
|
||||
const $last = $focusable.last();
|
||||
|
||||
$element.on('keydown', function(e) {
|
||||
if (e.key === 'Tab') {
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === $first[0]) {
|
||||
e.preventDefault();
|
||||
$last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === $last[0]) {
|
||||
e.preventDefault();
|
||||
$first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist Interattività
|
||||
|
||||
- [ ] Mobile menu toggle funzionante
|
||||
- [ ] Smooth scroll per anchor links
|
||||
- [ ] FAQ accordion accessibile
|
||||
- [ ] Form validation con feedback
|
||||
- [ ] Back to top button (se pagine lunghe)
|
||||
- [ ] Lazy loading immagini
|
||||
- [ ] GSAP animations (hero, scroll)
|
||||
- [ ] Hover effects su cards/buttons
|
||||
- [ ] Respect reduced motion preference
|
||||
- [ ] Keyboard navigation testata
|
||||
- [ ] No console errors
|
||||
- [ ] Performance ottimizzata (debounce, CSS where possible)
|
||||
|
||||
---
|
||||
|
||||
_References per agency-web-developer skill_
|
||||
Loading…
Add table
Add a link
Reference in a new issue