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:
Claude Dev
2026-03-14 16:59:23 -04:00
parent d01ea28a8b
commit f861510625
9 changed files with 440 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>