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