Add marketing UI components: hero sections, glass card, scroll reveal, pricing card
Create 9 reusable marketing components: - HeroSection: wrapper with dark grid background and two-column layout - NetworkHero: animated SVG network visualization with floating nodes - VpsHero, DedicatedHero, WebHostingHero, GameServerHero: themed variants - GlassCard: glass morphism card for feature sections - ScrollReveal: intersection observer wrapper for scroll animations - PricingCard: glass morphism pricing card with features and CTA Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
website/resources/ts/Components/Marketing/DedicatedHero.vue
Normal file
26
website/resources/ts/Components/Marketing/DedicatedHero.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import NetworkHero from './NetworkHero.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="position: relative;">
|
||||||
|
<NetworkHero :node-count="6" primary-color="#1d4ed8" secondary-color="#3b82f6" />
|
||||||
|
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(29, 78, 216, 0.15);
|
||||||
|
border: 1px solid rgba(29, 78, 216, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-server-2" size="32" color="#1d4ed8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
26
website/resources/ts/Components/Marketing/GameServerHero.vue
Normal file
26
website/resources/ts/Components/Marketing/GameServerHero.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import NetworkHero from './NetworkHero.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="position: relative;">
|
||||||
|
<NetworkHero :node-count="9" primary-color="#10b981" secondary-color="#3b82f6" />
|
||||||
|
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-device-gamepad-2" size="32" color="#10b981" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
website/resources/ts/Components/Marketing/GlassCard.vue
Normal file
21
website/resources/ts/Components/Marketing/GlassCard.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
interface Props {
|
||||||
|
hover?: boolean
|
||||||
|
padding?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
hover: true,
|
||||||
|
padding: '24px',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="glass-card"
|
||||||
|
:class="{ 'feature-card-hover': hover }"
|
||||||
|
:style="{ padding }"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
29
website/resources/ts/Components/Marketing/HeroSection.vue
Normal file
29
website/resources/ts/Components/Marketing/HeroSection.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
interface Props {
|
||||||
|
minHeight?: string
|
||||||
|
showGrid?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
minHeight: '80vh',
|
||||||
|
showGrid: true,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="hero-dark-grid"
|
||||||
|
:style="{ minHeight, display: 'flex', alignItems: 'center', position: 'relative', paddingBlock: '120px 80px' }"
|
||||||
|
>
|
||||||
|
<VContainer style="position: relative; z-index: 1;">
|
||||||
|
<VRow align="center">
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<slot name="content" />
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6" class="d-none d-md-block">
|
||||||
|
<slot name="visual" />
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VContainer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
124
website/resources/ts/Components/Marketing/NetworkHero.vue
Normal file
124
website/resources/ts/Components/Marketing/NetworkHero.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nodeCount?: number
|
||||||
|
primaryColor?: string
|
||||||
|
secondaryColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
nodeCount: 8,
|
||||||
|
primaryColor: '#3b82f6',
|
||||||
|
secondaryColor: '#06b6d4',
|
||||||
|
})
|
||||||
|
|
||||||
|
const svgRef = ref<SVGSVGElement | null>(null)
|
||||||
|
|
||||||
|
interface Node {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
r: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
color: string
|
||||||
|
pulseDelay: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = ref<Node[]>([])
|
||||||
|
let animFrame = 0
|
||||||
|
|
||||||
|
function initNodes(): void {
|
||||||
|
nodes.value = Array.from({ length: props.nodeCount }, (_, i) => ({
|
||||||
|
x: 50 + Math.random() * 300,
|
||||||
|
y: 30 + Math.random() * 240,
|
||||||
|
r: 4 + Math.random() * 4,
|
||||||
|
vx: (Math.random() - 0.5) * 0.3,
|
||||||
|
vy: (Math.random() - 0.5) * 0.3,
|
||||||
|
color: i % 3 === 0 ? props.secondaryColor : props.primaryColor,
|
||||||
|
pulseDelay: Math.random() * 3,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate(): void {
|
||||||
|
nodes.value.forEach(node => {
|
||||||
|
node.x += node.vx
|
||||||
|
node.y += node.vy
|
||||||
|
if (node.x < 30 || node.x > 370) node.vx *= -1
|
||||||
|
if (node.y < 20 || node.y > 280) node.vy *= -1
|
||||||
|
})
|
||||||
|
animFrame = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initNodes()
|
||||||
|
animate()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancelAnimationFrame(animFrame)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getDistance(a: Node, b: Node): number {
|
||||||
|
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
ref="svgRef"
|
||||||
|
viewBox="0 0 400 300"
|
||||||
|
class="network-hero"
|
||||||
|
style="width: 100%; height: auto; max-height: 400px;"
|
||||||
|
>
|
||||||
|
<!-- Connection lines -->
|
||||||
|
<template v-for="(node, i) in nodes" :key="'line-' + i">
|
||||||
|
<line
|
||||||
|
v-for="(other, j) in nodes.slice(i + 1)"
|
||||||
|
:key="'conn-' + i + '-' + j"
|
||||||
|
:x1="node.x"
|
||||||
|
:y1="node.y"
|
||||||
|
:x2="other.x"
|
||||||
|
:y2="other.y"
|
||||||
|
:stroke="node.color"
|
||||||
|
:stroke-opacity="Math.max(0, 1 - getDistance(node, other) / 150) * 0.3"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Nodes -->
|
||||||
|
<g v-for="(node, i) in nodes" :key="'node-' + i">
|
||||||
|
<!-- Pulse ring -->
|
||||||
|
<circle
|
||||||
|
:cx="node.x"
|
||||||
|
:cy="node.y"
|
||||||
|
:r="node.r * 2.5"
|
||||||
|
fill="none"
|
||||||
|
:stroke="node.color"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.15"
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="r"
|
||||||
|
:values="`${node.r * 1.5};${node.r * 3};${node.r * 1.5}`"
|
||||||
|
:dur="`${2 + node.pulseDelay}s`"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0.2;0.05;0.2"
|
||||||
|
:dur="`${2 + node.pulseDelay}s`"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
<!-- Node -->
|
||||||
|
<circle
|
||||||
|
:cx="node.x"
|
||||||
|
:cy="node.y"
|
||||||
|
:r="node.r"
|
||||||
|
:fill="node.color"
|
||||||
|
:opacity="0.8"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
108
website/resources/ts/Components/Marketing/PricingCard.vue
Normal file
108
website/resources/ts/Components/Marketing/PricingCard.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
interface PricingFeature {
|
||||||
|
text: string
|
||||||
|
included: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string
|
||||||
|
price: string
|
||||||
|
period?: string
|
||||||
|
description?: string
|
||||||
|
features: PricingFeature[]
|
||||||
|
highlighted?: boolean
|
||||||
|
badge?: string
|
||||||
|
ctaLabel?: string
|
||||||
|
ctaHref?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
period: '/mo',
|
||||||
|
description: undefined,
|
||||||
|
highlighted: false,
|
||||||
|
badge: undefined,
|
||||||
|
ctaLabel: 'Get Started',
|
||||||
|
ctaHref: undefined,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="glass-card pricing-card"
|
||||||
|
:class="{ 'pricing-card--highlighted': highlighted }"
|
||||||
|
style="padding: 32px; height: 100%; display: flex; flex-direction: column;"
|
||||||
|
>
|
||||||
|
<!-- Badge -->
|
||||||
|
<VChip
|
||||||
|
v-if="badge"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
class="mb-4 align-self-start"
|
||||||
|
>
|
||||||
|
{{ badge }}
|
||||||
|
</VChip>
|
||||||
|
|
||||||
|
<!-- Plan name -->
|
||||||
|
<h3 class="text-h6 font-weight-bold mb-2" style="color: #fff;">{{ name }}</h3>
|
||||||
|
<p v-if="description" class="text-body-2 mb-4" style="color: rgba(255,255,255,0.6);">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Price -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<span class="text-h3 font-weight-bold" style="color: #fff;">{{ price }}</span>
|
||||||
|
<span class="text-body-2" style="color: rgba(255,255,255,0.5);">{{ period }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<div class="flex-grow-1 mb-6">
|
||||||
|
<div
|
||||||
|
v-for="(feature, i) in features"
|
||||||
|
:key="i"
|
||||||
|
class="d-flex align-center mb-3"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
:icon="feature.included ? 'tabler-check' : 'tabler-x'"
|
||||||
|
:color="feature.included ? '#10b981' : undefined"
|
||||||
|
:style="{ opacity: feature.included ? 1 : 0.3 }"
|
||||||
|
size="18"
|
||||||
|
class="me-3"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="text-body-2"
|
||||||
|
:style="{ color: feature.included ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.35)' }"
|
||||||
|
>
|
||||||
|
{{ feature.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<a v-if="ctaHref" :href="ctaHref" class="text-decoration-none">
|
||||||
|
<VBtn
|
||||||
|
block
|
||||||
|
:variant="highlighted ? 'flat' : 'outlined'"
|
||||||
|
:color="highlighted ? 'primary' : undefined"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
{{ ctaLabel }}
|
||||||
|
</VBtn>
|
||||||
|
</a>
|
||||||
|
<VBtn
|
||||||
|
v-else
|
||||||
|
block
|
||||||
|
:variant="highlighted ? 'flat' : 'outlined'"
|
||||||
|
:color="highlighted ? 'primary' : undefined"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
{{ ctaLabel }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.pricing-card--highlighted {
|
||||||
|
border-color: rgba(59, 130, 246, 0.4) !important;
|
||||||
|
box-shadow: 0 0 40px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
53
website/resources/ts/Components/Marketing/ScrollReveal.vue
Normal file
53
website/resources/ts/Components/Marketing/ScrollReveal.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
threshold?: number
|
||||||
|
rootMargin?: string
|
||||||
|
once?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
threshold: 0.1,
|
||||||
|
rootMargin: '0px 0px -50px 0px',
|
||||||
|
once: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const elementRef = ref<HTMLElement | null>(null)
|
||||||
|
const isRevealed = ref(false)
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!elementRef.value) return
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
isRevealed.value = true
|
||||||
|
if (props.once && observer) {
|
||||||
|
observer.unobserve(entry.target)
|
||||||
|
}
|
||||||
|
} else if (!props.once) {
|
||||||
|
isRevealed.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ threshold: props.threshold, rootMargin: props.rootMargin }
|
||||||
|
)
|
||||||
|
observer.observe(elementRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="elementRef"
|
||||||
|
class="scroll-reveal"
|
||||||
|
:class="{ revealed: isRevealed }"
|
||||||
|
>
|
||||||
|
<slot :revealed="isRevealed" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
27
website/resources/ts/Components/Marketing/VpsHero.vue
Normal file
27
website/resources/ts/Components/Marketing/VpsHero.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import NetworkHero from './NetworkHero.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="position: relative;">
|
||||||
|
<NetworkHero :node-count="10" primary-color="#3b82f6" secondary-color="#06b6d4" />
|
||||||
|
<!-- Central server icon overlay -->
|
||||||
|
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-server" size="32" color="#3b82f6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
26
website/resources/ts/Components/Marketing/WebHostingHero.vue
Normal file
26
website/resources/ts/Components/Marketing/WebHostingHero.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import NetworkHero from './NetworkHero.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="position: relative;">
|
||||||
|
<NetworkHero :node-count="12" primary-color="#06b6d4" secondary-color="#3b82f6" />
|
||||||
|
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(6, 182, 212, 0.15);
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-world" size="32" color="#06b6d4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user