mempercanti style animasi home

This commit is contained in:
2026-05-19 08:49:40 +07:00
parent cbb8136cdd
commit 5b40053903
6 changed files with 462 additions and 40 deletions
+4
View File
@@ -3,4 +3,8 @@ import App from "./App.vue"
import router from "./router"
import "./assets/main.css"
if (localStorage.getItem("secondtech_theme") === "dark") {
document.documentElement.classList.add("dark")
}
createApp(App).use(router).mount("#app")
+41 -32
View File
@@ -2,15 +2,32 @@
<section class="bg-gradient-to-br from-brand-50 via-white to-slate-100">
<div class="container-page grid min-h-[560px] items-center gap-10 py-16 lg:grid-cols-2">
<div>
<span class="rounded-full bg-brand-100 px-4 py-2 text-sm font-bold text-brand-700">
Marketplace Barang Bekas Teknologi
</span>
<h1 class="mt-6 text-4xl font-extrabold tracking-tight text-slate-950 sm:text-5xl lg:text-6xl">
Jual beli perangkat Teknologi bekas.
</h1>
<p class="mt-6 max-w-xl text-lg leading-8 text-slate-600">
Temukan laptop bekas, PC, monitor, keyboard, router, switch, access point, dan server bekas untuk berbagai kebutuhan.
</p>
<ShinyText
text="SecondTech"
className="text-1xl font-extrabold tracking-tight sm:text-1xl lg:text-2xl"
:color="'#1c4ed9'"
:shineColor="'#e0e7ff'"
:spread="45"
:speed="1"
:delay="2"
:direction="'left'"
:pauseOnHover="true"
:yoyo="true"
/>
<div>
<TextType
as="h2"
className="mt-6 text-3xl font-extrabold tracking-tight text-zinc-950 sm:text-6xl lg:text-5xl"
:text="['Menjual Barang Bekas, yang kamu butuhkan, di satu tempat.']"
:typingSpeed="75"
:pauseDuration="1500"
:showCursor="false"
:textColors="['#0f1729']"
cursorCharacter="|"
/>
</div>
<p class="mt-6 max-w-xl text-lg leading-8 text-slate-600">Temukan laptop bekas, PC, monitor, keyboard, router, switch, access point, dan server bekas untuk berbagai kebutuhan.</p>
<div class="mt-8 flex flex-wrap gap-3">
<RouterLink to="/jual" class="btn-primary">Jual Barang Sekarang</RouterLink>
<RouterLink to="/marketplace" class="btn-secondary">Lihat Marketplace</RouterLink>
@@ -19,11 +36,7 @@
<div class="relative">
<div class="absolute -inset-4 rounded-[2rem] bg-brand-100 blur-2xl"></div>
<img
class="relative aspect-[4/3] w-full rounded-[2rem] object-cover shadow-2xl"
src="https://images.unsplash.com/photo-1516321318423-f06f85e504b3?q=80&w=1200&auto=format&fit=crop"
alt="Tech marketplace"
/>
<img class="relative aspect-[4/3] w-full rounded-[2rem] object-cover shadow-2xl" src="https://images.unsplash.com/photo-1516321318423-f06f85e504b3?q=80&w=1200&auto=format&fit=crop" alt="Tech marketplace" />
</div>
</div>
</section>
@@ -57,35 +70,31 @@
</div>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<ProductCard
v-for="product in latestProducts"
:key="product.id"
:product="product"
:saved="saved.includes(product.id)"
@toggle-save="toggleSave"
/>
<ProductCard v-for="product in latestProducts" :key="product.id" :product="product" :saved="saved.includes(product.id)" @toggle-save="toggleSave" />
</div>
</div>
</section>
</template>
<script setup>
import { computed, ref } from "vue"
import { Cpu } from "lucide-vue-next"
import ProductCard from "../components/ProductCard.vue"
import { products, categories } from "../data/products"
import { getSavedProducts, setSavedProducts } from "../utils"
import { computed, ref } from 'vue';
import { Cpu } from 'lucide-vue-next';
import ProductCard from '../components/ProductCard.vue';
import { products, categories } from '../data/products';
import { getSavedProducts, setSavedProducts } from '../utils';
import ShinyText from '../vuebits/ShinyText/ShinyText.vue';
import TextType from '../vuebits/TextType/TextType.vue';
const saved = ref(getSavedProducts())
const categoryList = categories.filter((c) => c !== "Semua").slice(0, 6)
const latestProducts = computed(() => products.slice(0, 4))
const saved = ref(getSavedProducts());
const categoryList = categories.filter((c) => c !== 'Semua').slice(0, 6);
const latestProducts = computed(() => products.slice(0, 4));
function toggleSave(id) {
if (saved.value.includes(id)) {
saved.value = saved.value.filter((item) => item !== id)
saved.value = saved.value.filter((item) => item !== id);
} else {
saved.value.push(id)
saved.value.push(id);
}
setSavedProducts(saved.value)
setSavedProducts(saved.value);
}
</script>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { Motion, useAnimationFrame, useMotionValue, useTransform } from 'motion-v';
import { computed, ref, watch } from 'vue';
interface ShinyTextProps {
text: string;
disabled?: boolean;
speed?: number;
className?: string;
color?: string;
shineColor?: string;
spread?: number;
yoyo?: boolean;
pauseOnHover?: boolean;
direction?: 'left' | 'right';
delay?: number;
}
const props = withDefaults(defineProps<ShinyTextProps>(), {
disabled: false,
speed: 2,
className: '',
color: '#b5b5b5',
shineColor: '#ffffff',
spread: 120,
yoyo: false,
pauseOnHover: false,
direction: 'left',
delay: 0
});
const isPaused = ref(false);
const progress = useMotionValue(0);
const elapsedRef = ref(0);
const lastTimeRef = ref<number | null>(null);
const directionRef = ref(props.direction === 'left' ? 1 : -1);
const animationDuration = computed(() => props.speed * 1000);
const delayDuration = computed(() => props.delay * 1000);
useAnimationFrame(time => {
if (props.disabled || isPaused.value) {
lastTimeRef.value = null;
return;
}
if (lastTimeRef.value === null) {
lastTimeRef.value = time;
return;
}
const deltaTime = time - lastTimeRef.value;
lastTimeRef.value = time;
elapsedRef.value += deltaTime;
// Animation goes from 0 to 100
if (props.yoyo) {
const cycleDuration = animationDuration.value + delayDuration.value;
const fullCycle = cycleDuration * 2;
const cycleTime = elapsedRef.value % fullCycle;
if (cycleTime < animationDuration.value) {
// Forward animation: 0 -> 100
const p = (cycleTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else if (cycleTime < cycleDuration) {
// Delay at end
progress.set(directionRef.value === 1 ? 100 : 0);
} else if (cycleTime < cycleDuration + animationDuration.value) {
// Reverse animation: 100 -> 0
const reverseTime = cycleTime - cycleDuration;
const p = 100 - (reverseTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else {
// Delay at start
progress.set(directionRef.value === 1 ? 0 : 100);
}
} else {
const cycleDuration = animationDuration.value + delayDuration.value;
const cycleTime = elapsedRef.value % cycleDuration;
if (cycleTime < animationDuration.value) {
// Animation phase: 0 -> 100
const p = (cycleTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else {
// Delay phase - hold at end (shine off-screen)
progress.set(directionRef.value === 1 ? 100 : 0);
}
}
});
watch(
() => props.direction,
() => {
directionRef.value = props.direction === 'left' ? 1 : -1;
elapsedRef.value = 0;
progress.set(0);
},
{
immediate: true
}
);
const backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`);
const handleMouseEnter = () => {
if (props.pauseOnHover) isPaused.value = true;
};
const handleMouseLeave = () => {
if (props.pauseOnHover) isPaused.value = false;
};
const gradientStyle = computed(() => ({
backgroundImage: `linear-gradient(${props.spread}deg, ${props.color} 0%, ${props.color} 35%, ${props.shineColor} 50%, ${props.color} 65%, ${props.color} 100%)`,
backgroundSize: '200% auto',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}));
</script>
<template>
<Motion
tag="span"
:class="['inline-block', className]"
:style="{ ...gradientStyle, backgroundPosition }"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
{{ text }}
</Motion>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed, useTemplateRef } from 'vue';
import { gsap } from 'gsap';
interface TextTypeProps {
className?: string;
showCursor?: boolean;
hideCursorWhileTyping?: boolean;
cursorCharacter?: string;
cursorBlinkDuration?: number;
cursorClassName?: string;
text: string | string[];
as?: string;
typingSpeed?: number;
initialDelay?: number;
pauseDuration?: number;
deletingSpeed?: number;
loop?: boolean;
textColors?: string[];
variableSpeed?: { min: number; max: number };
onSentenceComplete?: (sentence: string, index: number) => void;
startOnVisible?: boolean;
reverseMode?: boolean;
}
const props = withDefaults(defineProps<TextTypeProps>(), {
as: 'div',
typingSpeed: 50,
initialDelay: 0,
pauseDuration: 2000,
deletingSpeed: 30,
loop: true,
className: '',
showCursor: true,
hideCursorWhileTyping: false,
cursorCharacter: '|',
cursorBlinkDuration: 0.5,
textColors: () => [],
startOnVisible: false,
reverseMode: false
});
const displayedText = ref('');
const currentCharIndex = ref(0);
const isDeleting = ref(false);
const currentTextIndex = ref(0);
const isVisible = ref(!props.startOnVisible);
const cursorRef = useTemplateRef('cursorRef');
const containerRef = useTemplateRef('containerRef');
const textArray = computed(() => (Array.isArray(props.text) ? props.text : [props.text]));
const getRandomSpeed = () => {
if (!props.variableSpeed) return props.typingSpeed;
const { min, max } = props.variableSpeed;
return Math.random() * (max - min) + min;
};
const getCurrentTextColor = () => {
if (!props.textColors.length) return '#ffffff';
return props.textColors[currentTextIndex.value % props.textColors.length];
};
let timeout: ReturnType<typeof setTimeout> | null = null;
const clearTimeoutIfNeeded = () => {
if (timeout) clearTimeout(timeout);
};
const executeTypingAnimation = () => {
const currentText = textArray.value[currentTextIndex.value];
const processedText = props.reverseMode ? currentText.split('').reverse().join('') : currentText;
if (isDeleting.value) {
if (displayedText.value === '') {
isDeleting.value = false;
if (currentTextIndex.value === textArray.value.length - 1 && !props.loop) return;
props.onSentenceComplete?.(textArray.value[currentTextIndex.value], currentTextIndex.value);
currentTextIndex.value = (currentTextIndex.value + 1) % textArray.value.length;
currentCharIndex.value = 0;
timeout = setTimeout(() => {}, props.pauseDuration);
} else {
timeout = setTimeout(() => {
displayedText.value = displayedText.value.slice(0, -1);
}, props.deletingSpeed);
}
} else {
if (currentCharIndex.value < processedText.length) {
timeout = setTimeout(
() => {
displayedText.value += processedText[currentCharIndex.value];
currentCharIndex.value += 1;
},
props.variableSpeed ? getRandomSpeed() : props.typingSpeed
);
} else if (textArray.value.length > 1) {
timeout = setTimeout(() => {
isDeleting.value = true;
}, props.pauseDuration);
}
}
};
watch(
[displayedText, currentCharIndex, isDeleting, isVisible],
() => {
if (!isVisible.value) return;
clearTimeoutIfNeeded();
if (currentCharIndex.value === 0 && !isDeleting.value && displayedText.value === '') {
timeout = setTimeout(() => {
executeTypingAnimation();
}, props.initialDelay);
} else {
executeTypingAnimation();
}
},
{ immediate: true }
);
onMounted(() => {
if (props.showCursor && cursorRef.value) {
gsap.set(cursorRef.value, { opacity: 1 });
gsap.to(cursorRef.value, {
opacity: 0,
duration: props.cursorBlinkDuration,
repeat: -1,
yoyo: true,
ease: 'power2.inOut'
});
}
if (props.startOnVisible && containerRef.value) {
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) isVisible.value = true;
});
},
{ threshold: 0.1 }
);
if (containerRef.value instanceof Element) {
observer.observe(containerRef.value);
}
onBeforeUnmount(() => observer.disconnect());
}
});
onBeforeUnmount(() => {
clearTimeoutIfNeeded();
});
</script>
<template>
<component
:is="as"
ref="containerRef"
:class="`inline-block whitespace-pre-wrap tracking-tight ${className}`"
v-bind="$attrs"
>
<span class="inline" :style="{ color: getCurrentTextColor() }">
{{ displayedText }}
</span>
<span
v-if="showCursor"
ref="cursorRef"
:class="`ml-1 inline-block opacity-100 ${
hideCursorWhileTyping && (currentCharIndex < textArray[currentTextIndex].length || isDeleting) ? 'hidden' : ''
} ${cursorClassName}`"
>
{{ cursorCharacter }}
</span>
</component>
</template>