Add Mollie checkout flow, billing UI, and payment UX polish #71

Merged
uz1mani merged 73 commits from staging into main 2026-03-28 10:28:03 +00:00
39 changed files with 1540 additions and 147 deletions

8
app/checkout/layout.tsx Normal file
View File

@@ -0,0 +1,8 @@
export const metadata = {
title: 'Checkout — Pulse',
robots: 'noindex, nofollow',
}
export default function CheckoutLayout({ children }: { children: React.ReactNode }) {
return children
}

252
app/checkout/page.tsx Normal file
View File

@@ -0,0 +1,252 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { useAuth } from '@/lib/auth/context'
import { useSubscription } from '@/lib/swr/dashboard'
import { getSubscription } from '@/lib/api/billing'
import { PLAN_PRICES, TRAFFIC_TIERS } from '@/lib/plans'
import PlanSummary from '@/components/checkout/PlanSummary'
import PaymentForm from '@/components/checkout/PaymentForm'
import FeatureSlideshow from '@/components/checkout/FeatureSlideshow'
import pulseIcon from '@/public/pulse_icon_no_margins.png'
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
const VALID_PLANS = new Set(Object.keys(PLAN_PRICES))
const VALID_INTERVALS = new Set(['month', 'year'])
const VALID_LIMITS = new Set<number>(TRAFFIC_TIERS.map((t) => t.value))
function isValidCheckoutParams(plan: string | null, interval: string | null, limit: string | null) {
if (!plan || !interval || !limit) return false
const limitNum = Number(limit)
if (!VALID_PLANS.has(plan)) return false
if (!VALID_INTERVALS.has(interval)) return false
if (!VALID_LIMITS.has(limitNum)) return false
if (!PLAN_PRICES[plan]?.[limitNum]) return false
return true
}
// ---------------------------------------------------------------------------
// Success polling component (post-3DS return)
// ---------------------------------------------------------------------------
function CheckoutSuccess() {
const router = useRouter()
const [ready, setReady] = useState(false)
const [timedOut, setTimedOut] = useState(false)
useEffect(() => {
let cancelled = false
const timeout = setTimeout(() => setTimedOut(true), 30000)
const poll = async () => {
for (let i = 0; i < 15; i++) {
if (cancelled) return
try {
const data = await getSubscription()
if (data.subscription_status === 'active' || data.subscription_status === 'trialing') {
setReady(true)
clearTimeout(timeout)
setTimeout(() => router.push('/'), 2000)
return
}
} catch {
// ignore — keep polling
}
await new Promise((r) => setTimeout(r, 2000))
}
setTimedOut(true)
}
poll()
return () => {
cancelled = true
clearTimeout(timeout)
}
}, [router])
return (
<div className="flex min-h-screen items-center justify-center">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
{ready ? (
<>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/20">
<svg className="h-8 w-8 text-emerald-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<h2 className="text-xl font-semibold text-white">You&apos;re all set!</h2>
<p className="mt-2 text-sm text-zinc-400">Redirecting to dashboard...</p>
</>
) : timedOut ? (
<>
<h2 className="text-xl font-semibold text-white">Taking longer than expected</h2>
<p className="mt-2 text-sm text-zinc-400">
Your payment was received. It may take a moment to activate.
</p>
<Link
href="/"
className="mt-4 inline-block text-sm font-medium text-blue-400 hover:text-blue-300 transition-colors"
>
Go to dashboard
</Link>
</>
) : (
<>
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-white" />
<h2 className="text-xl font-semibold text-white">Setting up your subscription...</h2>
<p className="mt-2 text-sm text-zinc-400">This usually takes a few seconds.</p>
</>
)}
</motion.div>
</div>
)
}
// ---------------------------------------------------------------------------
// Main checkout content (reads searchParams)
// ---------------------------------------------------------------------------
function CheckoutContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { user, loading: authLoading } = useAuth()
const { data: subscription } = useSubscription()
const [country, setCountry] = useState('')
const [vatId, setVatId] = useState('')
const status = searchParams.get('status')
const plan = searchParams.get('plan')
const interval = searchParams.get('interval')
const limit = searchParams.get('limit')
// -- Auth guard --
useEffect(() => {
if (!authLoading && !user) {
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search)
router.replace(`/login?redirect=${returnUrl}`)
}
}, [authLoading, user, router])
// -- Subscription guard (skip on success page — it handles its own redirect) --
useEffect(() => {
if (status === 'success') return
if (subscription && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing')) {
router.replace('/')
}
}, [subscription, status, router])
// -- Param validation --
useEffect(() => {
if (status === 'success') return // success state doesn't need plan params
if (!authLoading && user && !isValidCheckoutParams(plan, interval, limit)) {
router.replace('/pricing')
}
}, [authLoading, user, plan, interval, limit, status, router])
// -- Post-3DS success --
if (status === 'success') {
return <CheckoutSuccess />
}
// -- Loading state --
if (authLoading || !user || !isValidCheckoutParams(plan, interval, limit)) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-white" />
</div>
)
}
const planId = plan!
const billingInterval = interval as 'month' | 'year'
const pageviewLimit = Number(limit)
return (
<div className="flex h-screen overflow-hidden">
{/* Left — Feature slideshow (hidden on mobile) */}
<div className="hidden lg:flex lg:w-1/2 relative h-full overflow-hidden">
<FeatureSlideshow />
</div>
{/* Right — Payment (scrollable) */}
<div className="w-full lg:w-1/2 flex flex-col h-full overflow-y-auto">
{/* Logo on mobile only (desktop logo is on the left panel) */}
<div className="px-6 py-5 lg:hidden">
<Link href="/pricing" className="flex items-center gap-2 w-fit hover:opacity-80 transition-opacity">
<Image
src={pulseIcon}
alt="Pulse"
width={36}
height={36}
unoptimized
className="object-contain w-8 h-8"
/>
<span className="text-xl font-bold text-white tracking-tight">Pulse</span>
</Link>
</div>
{/* Main content */}
<div className="flex flex-1 flex-col px-4 pb-12 pt-6 lg:pt-10 sm:px-6 lg:px-10">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: 'easeOut' }}
className="w-full max-w-lg mx-auto flex flex-col gap-6"
>
{/* Plan summary (compact) */}
<PlanSummary
plan={planId}
interval={billingInterval}
limit={pageviewLimit}
country={country}
vatId={vatId}
onCountryChange={setCountry}
onVatIdChange={setVatId}
/>
{/* Payment form */}
<PaymentForm
plan={planId}
interval={billingInterval}
limit={pageviewLimit}
country={country}
vatId={vatId}
/>
</motion.div>
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Page wrapper with Suspense (required for useSearchParams in App Router)
// ---------------------------------------------------------------------------
export default function CheckoutPage() {
return (
<div className="min-h-screen bg-zinc-950 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-zinc-900/40 via-zinc-950 to-zinc-950">
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-white" />
</div>
}
>
<CheckoutContent />
</Suspense>
</div>
)
}

View File

@@ -188,7 +188,7 @@ export default function IntegrationsPage() {
onClick={() => handleCategoryClick('all')}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
activeCategory === 'all'
? 'bg-brand-orange text-white shadow-sm'
? 'bg-brand-orange-button text-white shadow-sm'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
>
@@ -200,7 +200,7 @@ export default function IntegrationsPage() {
onClick={() => handleCategoryClick(cat)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
activeCategory === cat
? 'bg-brand-orange text-white shadow-sm'
? 'bg-brand-orange-button text-white shadow-sm'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
>
@@ -335,7 +335,7 @@ export default function IntegrationsPage() {
</p>
<a
href="mailto:support@ciphera.net"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-brand-orange text-white font-medium rounded-lg hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-brand-orange-button text-white font-medium rounded-lg hover:bg-brand-orange/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
>
Request Integration
</a>

View File

@@ -78,11 +78,15 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
const handleSwitchOrganization = async (orgId: string | null) => {
if (!orgId) return
try {
setIsSwitchingOrg(true)
const { access_token } = await switchContext(orgId)
await setSessionAction(access_token)
sessionStorage.setItem(ORG_SWITCH_KEY, 'true')
window.location.reload()
// Refresh auth context (re-fetches /auth/user/me with new JWT, updates org_id + SWR cache)
await auth.refresh()
router.push('/')
setTimeout(() => setIsSwitchingOrg(false), 300)
} catch (err) {
setIsSwitchingOrg(false)
logger.error('Failed to switch organization', err)
}
}
@@ -91,13 +95,15 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
const showOfflineBar = Boolean(auth.user && !isOnline)
// Site pages use DashboardShell with full sidebar — no Header needed
const isSitePage = pathname.startsWith('/sites/') && pathname !== '/sites/new'
// Checkout page has its own minimal layout — no app header/footer
const isCheckoutPage = pathname.startsWith('/checkout')
if (isSwitchingOrg) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
}
// While auth is loading on a site page, render nothing to prevent flash of public header
if (auth.loading && isSitePage) {
// While auth is loading on a site or checkout page, render nothing to prevent flash of public header
if (auth.loading && (isSitePage || isCheckoutPage)) {
return null
}
@@ -113,6 +119,11 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
)
}
// Checkout page: render children only (has its own layout)
if (isAuthenticated && isCheckoutPage) {
return <>{children}</>
}
// Authenticated non-site pages (sites list, onboarding, etc.): static header
if (isAuthenticated) {
return (

View File

@@ -187,7 +187,7 @@ export default function CDNPage() {
</p>
<button
onClick={() => openUnifiedSettings({ context: 'site', tab: 'integrations' })}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-orange hover:bg-brand-orange/90 text-white text-sm font-medium transition-colors cursor-pointer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-orange-button hover:bg-brand-orange-button-hover text-white text-sm font-medium transition-colors cursor-pointer"
>
Connect in Settings
<ArrowSquareOut size={16} weight="bold" />

View File

@@ -8,6 +8,7 @@ import { updatePageSpeedConfig, triggerPageSpeedCheck, getPageSpeedLatest, getPa
import { toast, Button } from '@ciphera-net/ui'
import { motion } from 'framer-motion'
import ScoreGauge from '@/components/pagespeed/ScoreGauge'
import { remapLearnUrl } from '@/lib/learn-links'
import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip } from '@/components/ui/area-chart'
import { useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
@@ -785,12 +786,14 @@ function AuditDescription({ text }: { text: string }) {
{parts.map((part, i) => {
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (match) {
const href = remapLearnUrl(match[2])
const isInternal = href.startsWith('https://ciphera.net') || href.startsWith('https://pulse.ciphera.net') || href.startsWith('https://pulse-staging.ciphera.net')
return (
<a
key={i}
href={match[2]}
href={href}
target="_blank"
rel="noopener noreferrer"
rel={isInternal ? 'noopener' : 'noopener noreferrer'}
className="text-brand-orange hover:underline"
>
{match[1]}

View File

@@ -175,7 +175,7 @@ export default function SearchConsolePage() {
</p>
<button
onClick={() => openUnifiedSettings({ context: 'site', tab: 'integrations' })}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-orange hover:bg-brand-orange/90 text-white text-sm font-medium transition-colors cursor-pointer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-orange-button hover:bg-brand-orange-button-hover text-white text-sm font-medium transition-colors cursor-pointer"
>
Connect in Settings
<ArrowSquareOut size={16} weight="bold" />

View File

@@ -16,7 +16,6 @@ import {
type Organization,
type OrganizationMember,
} from '@/lib/api/organization'
import { createCheckoutSession } from '@/lib/api/billing'
import { createSite, type Site } from '@/lib/api/sites'
import { setSessionAction } from '@/app/actions/auth'
import { useAuth } from '@/lib/auth/context'
@@ -88,7 +87,6 @@ function WelcomeContent() {
const [orgLoading, setOrgLoading] = useState(false)
const [orgError, setOrgError] = useState('')
const [planLoading, setPlanLoading] = useState(false)
const [planError, setPlanError] = useState('')
const [siteName, setSiteName] = useState('')
@@ -98,7 +96,6 @@ function WelcomeContent() {
const [createdSite, setCreatedSite] = useState<Site | null>(null)
const [showVerificationModal, setShowVerificationModal] = useState(false)
const [redirectingCheckout, setRedirectingCheckout] = useState(false)
const [hadPendingCheckout, setHadPendingCheckout] = useState<boolean | null>(null)
const [dismissedPendingCheckout, setDismissedPendingCheckout] = useState(false)
@@ -211,28 +208,15 @@ function WelcomeContent() {
setStep(4)
return
}
setPlanLoading(true)
setPlanError('')
trackWelcomePlanContinue()
try {
trackWelcomePlanContinue()
const intent = JSON.parse(raw)
const { url } = await createCheckoutSession({
plan_id: intent.planId,
interval: intent.interval || 'month',
limit: intent.limit ?? 100000,
})
const { planId, interval, limit } = JSON.parse(raw)
localStorage.removeItem('pulse_pending_checkout')
if (url) {
setRedirectingCheckout(true)
window.location.href = url
return
}
throw new Error('No checkout URL returned')
} catch (err: unknown) {
setPlanError(getAuthErrorMessage(err) || (err as Error)?.message || 'Failed to start checkout')
router.push(`/checkout?plan=${planId}&interval=${interval || 'month'}&limit=${limit ?? 100000}`)
} catch {
setPlanError('Failed to parse checkout data')
localStorage.removeItem('pulse_pending_checkout')
} finally {
setPlanLoading(false)
}
}
@@ -320,15 +304,6 @@ function WelcomeContent() {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Switching organization..." />
}
if (redirectingCheckout || (planLoading && step === 3)) {
return (
<LoadingOverlay
logoSrc="/pulse_icon_no_margins.png"
title={redirectingCheckout ? 'Taking you to checkout...' : 'Preparing your plan...'}
/>
)
}
const cardClass =
'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-6 max-w-lg mx-auto'
@@ -575,7 +550,6 @@ function WelcomeContent() {
variant="primary"
className="w-full sm:w-auto"
onClick={handlePlanContinue}
disabled={planLoading}
>
Continue to checkout
</Button>
@@ -583,7 +557,6 @@ function WelcomeContent() {
variant="secondary"
className="w-full sm:w-auto"
onClick={handlePlanSkip}
disabled={planLoading}
>
Stay on free plan
</Button>

View File

@@ -2,13 +2,12 @@
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { useSearchParams } from 'next/navigation'
import { useSearchParams, useRouter } from 'next/navigation'
import { motion } from 'framer-motion'
import { Button, CheckCircleIcon } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context'
import { initiateOAuthFlow } from '@/lib/api/oauth'
import { toast } from '@ciphera-net/ui'
import { createCheckoutSession } from '@/lib/api/billing'
// 1. Define Plans with IDs and Site Limits
const PLANS = [
@@ -104,12 +103,13 @@ const TRAFFIC_TIERS = [
export default function PricingSection() {
const searchParams = useSearchParams()
const router = useRouter()
const [isYearly, setIsYearly] = useState(false)
const [sliderIndex, setSliderIndex] = useState(2) // Default to 100k (index 2)
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
const { user } = useAuth()
// * Show toast when redirected from Polar Checkout with canceled=true
// * Show toast when redirected from Mollie Checkout with canceled=true
useEffect(() => {
if (searchParams.get('canceled') === 'true') {
toast.info('Checkout was canceled. You can try again whenever youre ready.')
@@ -166,49 +166,25 @@ export default function PricingSection() {
}
}
const handleSubscribe = async (planId: string, options?: { interval?: string, limit?: number }) => {
try {
setLoadingPlan(planId)
// 1. If not logged in, redirect to login/signup
if (!user) {
// Store checkout intent
const intent = {
planId,
interval: isYearly ? 'year' : 'month',
limit: currentTraffic.value,
sliderIndex, // Store UI state to restore it
isYearly // Store UI state to restore it
}
localStorage.setItem('pulse_pending_checkout', JSON.stringify(intent))
initiateOAuthFlow()
return
const handleSubscribe = (planId: string, options?: { interval?: string, limit?: number }) => {
// 1. If not logged in, redirect to login/signup
if (!user) {
const intent = {
planId,
interval: isYearly ? 'year' : 'month',
limit: currentTraffic.value,
sliderIndex,
isYearly
}
// 2. Call backend to create checkout session
const interval = options?.interval || (isYearly ? 'year' : 'month')
const limit = options?.limit || currentTraffic.value
const { url } = await createCheckoutSession({
plan_id: planId,
interval,
limit,
})
// 3. Redirect to Polar Checkout
if (url) {
window.location.href = url
} else {
throw new Error('No checkout URL returned')
}
} catch (error: unknown) {
logger.error('Checkout error:', error)
toast.error('Failed to start checkout — please try again')
} finally {
setLoadingPlan(null)
localStorage.setItem('pulse_pending_checkout', JSON.stringify(intent))
initiateOAuthFlow()
return
}
// 2. Navigate to embedded checkout page
const selectedInterval = options?.interval || (isYearly ? 'year' : 'month')
const selectedLimit = options?.limit || currentTraffic.value
router.push(`/checkout?plan=${planId}&interval=${selectedInterval}&limit=${selectedLimit}`)
}
return (
@@ -254,7 +230,7 @@ export default function PricingSection() {
onChange={(e) => setSliderIndex(parseInt(e.target.value))}
aria-label="Monthly pageview limit"
aria-valuetext={`${currentTraffic.label} pageviews per month`}
className="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
className="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-brand-orange focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
/>
</div>
@@ -355,6 +331,7 @@ export default function PricingSection() {
{priceDetails.yearlyTotal}
</span>
<span className="text-neutral-400 font-medium">/year</span>
<span className="text-xs text-neutral-500 ml-1">excl. VAT</span>
</div>
<div className="flex items-center gap-2 mt-2 text-sm font-medium">
<span className="text-neutral-400 line-through decoration-neutral-400">
@@ -371,6 +348,7 @@ export default function PricingSection() {
{priceDetails.baseMonthly}
</span>
<span className="text-neutral-400 font-medium">/mo</span>
<span className="text-xs text-neutral-500 ml-1">excl. VAT</span>
</div>
)
) : (
@@ -437,6 +415,7 @@ export default function PricingSection() {
</div>
</div>
</motion.div>
</section>
)
}

View File

@@ -0,0 +1,121 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import pulseIcon from '@/public/pulse_icon_no_margins.png'
import { AnimatePresence, motion } from 'framer-motion'
import { PulseMockup } from '@/components/marketing/mockups/pulse-mockup'
import { PagesCard, ReferrersCard, LocationsCard, TechnologyCard, PeakHoursCard } from '@/components/marketing/mockups/pulse-features-carousel'
interface Slide {
headline: string
mockup: React.ReactNode
}
function FeatureCard({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-xl border border-white/[0.08] bg-neutral-900/80 px-6 py-5 shadow-2xl">
{children}
</div>
)
}
const slides: Slide[] = [
{ headline: 'Your traffic, at a glance.', mockup: <PulseMockup /> },
{ headline: 'See which pages perform best.', mockup: <FeatureCard><PagesCard /></FeatureCard> },
{ headline: 'Know where your visitors come from.', mockup: <FeatureCard><ReferrersCard /></FeatureCard> },
{ headline: 'Visitors from around the world.', mockup: <FeatureCard><LocationsCard /></FeatureCard> },
{ headline: 'Understand your audience\u2019s tech stack.', mockup: <FeatureCard><TechnologyCard /></FeatureCard> },
{ headline: 'Find your peak traffic hours.', mockup: <FeatureCard><PeakHoursCard /></FeatureCard> },
]
export default function FeatureSlideshow() {
const [activeIndex, setActiveIndex] = useState(0)
const advance = useCallback(() => {
setActiveIndex((prev) => (prev + 1) % slides.length)
}, [])
useEffect(() => {
let timer: ReturnType<typeof setInterval> | null = null
const start = () => { timer = setInterval(advance, 8000) }
const stop = () => { if (timer) { clearInterval(timer); timer = null } }
const onVisibility = () => {
if (document.hidden) stop()
else start()
}
start()
document.addEventListener('visibilitychange', onVisibility)
return () => {
stop()
document.removeEventListener('visibilitychange', onVisibility)
}
}, [advance])
const slide = slides[activeIndex]
return (
<div className="relative h-full w-full">
{/* Background image */}
<Image
src="/pulse-showcase-bg.png"
alt=""
fill
unoptimized
className="object-cover"
priority
/>
{/* Dark overlay */}
<div className="absolute inset-0 bg-black/40" />
{/* Logo */}
<div className="absolute top-0 left-0 z-20 px-6 py-5">
<Link href="/pricing" className="flex items-center gap-2 w-fit hover:opacity-80 transition-opacity">
<Image
src={pulseIcon}
alt="Pulse"
width={36}
height={36}
unoptimized
className="object-contain w-8 h-8"
/>
<span className="text-xl font-bold text-white tracking-tight">Pulse</span>
</Link>
</div>
{/* Content */}
<div className="relative z-10 flex h-full flex-col items-center justify-center px-10 xl:px-14 py-12 overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
key={activeIndex}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.45 }}
className="flex flex-col items-center gap-6 w-full max-w-lg"
>
{/* Headline — centered */}
<h2 className="text-3xl xl:text-4xl font-bold text-white leading-tight text-center">
{slide.headline}
</h2>
{/* Mockup — constrained */}
<div className="relative w-full">
{/* Orange glow */}
<div className="absolute -inset-8 rounded-3xl bg-brand-orange/8 blur-3xl pointer-events-none" />
<div className="relative rounded-2xl overflow-hidden" style={{ maxHeight: '55vh' }}>
{slide.mockup}
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
)
}

View File

@@ -0,0 +1,355 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import Script from 'next/script'
import { motion, AnimatePresence } from 'framer-motion'
import { Lock, ShieldCheck } from '@phosphor-icons/react'
import { initMollie, getMollie, MOLLIE_FIELD_STYLES, type MollieComponent } from '@/lib/mollie'
import { createEmbeddedCheckout, createCheckoutSession } from '@/lib/api/billing'
interface PaymentFormProps {
plan: string
interval: string
limit: number
country: string
vatId: string
}
const PAYMENT_METHODS = [
{ id: 'card', label: 'Card' },
{ id: 'bancontact', label: 'Bancontact' },
{ id: 'ideal', label: 'iDEAL' },
{ id: 'applepay', label: 'Apple Pay' },
{ id: 'googlepay', label: 'Google Pay' },
{ id: 'directdebit', label: 'SEPA' },
]
const METHOD_LOGOS: Record<string, { src: string | string[]; alt: string }> = {
card: { src: ['/images/payment/visa.svg', '/images/payment/mastercard.svg'], alt: 'Card' },
bancontact: { src: '/images/payment/bancontact.svg', alt: 'Bancontact' },
ideal: { src: '/images/payment/ideal.svg', alt: 'iDEAL' },
applepay: { src: '/images/payment/applepay.svg', alt: 'Apple Pay' },
googlepay: { src: '/images/payment/googlepay.svg', alt: 'Google Pay' },
directdebit: { src: '/images/payment/sepa.svg', alt: 'SEPA' },
}
function MethodLogo({ type }: { type: string }) {
const logo = METHOD_LOGOS[type]
if (!logo) return null
if (Array.isArray(logo.src)) {
return (
<div className="flex items-center gap-1">
{logo.src.map((s) => (
<img key={s} src={s} alt="" className="h-6 w-auto rounded-sm" />
))}
</div>
)
}
return <img src={logo.src} alt={logo.alt} className="h-6 w-auto rounded-sm" />
}
const mollieFieldBase =
'w-full rounded-lg border border-neutral-700 bg-neutral-800/50 px-3 py-3 h-[48px] transition-all focus-within:ring-1 focus-within:ring-brand-orange focus-within:border-brand-orange'
export default function PaymentForm({ plan, interval, limit, country, vatId }: PaymentFormProps) {
const router = useRouter()
const [selectedMethod, setSelectedMethod] = useState('')
const [mollieReady, setMollieReady] = useState(false)
const [mollieError, setMollieError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const [cardErrors, setCardErrors] = useState<Record<string, string>>({})
const [submitted, setSubmitted] = useState(false)
const [submitting, setSubmitting] = useState(false)
const submitRef = useRef<HTMLButtonElement>(null)
const componentsRef = useRef<Record<string, MollieComponent | null>>({
cardHolder: null,
cardNumber: null,
expiryDate: null,
verificationCode: null,
})
const mollieInitialized = useRef(false)
const [scriptLoaded, setScriptLoaded] = useState(false)
// Mount Mollie components AFTER script loaded
useEffect(() => {
if (!scriptLoaded || mollieInitialized.current) return
const timer = setTimeout(() => {
const mollie = initMollie()
if (!mollie) {
setMollieError(true)
return
}
try {
const fields: Array<{ type: string; selector: string; placeholder?: string }> = [
{ type: 'cardHolder', selector: '#mollie-card-holder', placeholder: 'John Doe' },
{ type: 'cardNumber', selector: '#mollie-card-number', placeholder: '1234 5678 9012 3456' },
{ type: 'expiryDate', selector: '#mollie-card-expiry', placeholder: 'MM / YY' },
{ type: 'verificationCode', selector: '#mollie-card-cvc', placeholder: 'CVC' },
]
for (const { type, selector, placeholder } of fields) {
const el = document.querySelector(selector) as HTMLElement | null
if (!el) {
setMollieError(true)
return
}
const opts: Record<string, unknown> = { styles: MOLLIE_FIELD_STYLES }
if (placeholder) opts.placeholder = placeholder
const component = mollie.createComponent(type, opts)
component.mount(el)
component.addEventListener('change', (event: unknown) => {
const e = event as { error?: string }
setCardErrors((prev) => {
const next = { ...prev }
if (e.error) next[type] = e.error
else delete next[type]
return next
})
})
componentsRef.current[type] = component
}
mollieInitialized.current = true
setMollieReady(true)
} catch (err) {
console.error('Mollie mount error:', err)
setMollieError(true)
}
}, 100)
return () => clearTimeout(timer)
}, [scriptLoaded])
// Cleanup Mollie components on unmount
useEffect(() => {
return () => {
Object.values(componentsRef.current).forEach((c) => {
try { c?.unmount() } catch { /* DOM already removed */ }
})
}
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitted(true)
setFormError(null)
if (!selectedMethod) {
setFormError('Please select a payment method')
return
}
if (!country) {
setFormError('Please select your country')
return
}
setSubmitting(true)
try {
if (selectedMethod === 'card') {
const mollie = getMollie()
if (!mollie) {
setFormError('Payment system not loaded. Please refresh.')
setSubmitting(false)
return
}
const { token, error } = await mollie.createToken()
if (error || !token) {
setFormError(error?.message || 'Invalid card details.')
setSubmitting(false)
return
}
const result = await createEmbeddedCheckout({
plan_id: plan,
interval,
limit,
country,
vat_id: vatId || undefined,
card_token: token,
})
if (result.status === 'success') router.push('/checkout?status=success')
else if (result.status === 'pending' && result.redirect_url)
window.location.href = result.redirect_url
} else {
const result = await createCheckoutSession({
plan_id: plan,
interval,
limit,
country,
vat_id: vatId || undefined,
method: selectedMethod,
})
window.location.href = result.url
}
} catch (err) {
setFormError((err as Error)?.message || 'Payment failed. Please try again.')
} finally {
setSubmitting(false)
}
}
const isCard = selectedMethod === 'card'
return (
<>
<Script
src="https://js.mollie.com/v1/mollie.js"
onLoad={() => setScriptLoaded(true)}
onError={() => setMollieError(true)}
/>
<form
onSubmit={handleSubmit}
className="rounded-2xl border border-neutral-800 bg-neutral-900/50 backdrop-blur-xl p-6"
>
<h2 className="text-lg font-semibold text-white mb-4">Payment method</h2>
{/* Payment method grid */}
<div className="grid grid-cols-3 gap-2 mb-5">
{PAYMENT_METHODS.map((method) => {
const isSelected = selectedMethod === method.id
return (
<button
key={method.id}
type="button"
onClick={() => {
setSelectedMethod(method.id)
setFormError(null)
if (method.id === 'card') {
setTimeout(() => submitRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 350)
}
}}
className={`flex items-center justify-center rounded-xl border h-[44px] transition-all duration-200 ${
isSelected
? 'border-brand-orange bg-brand-orange/5'
: 'border-neutral-700/50 bg-neutral-800/30 hover:border-neutral-600'
}`}
>
<MethodLogo type={method.id} />
</button>
)
})}
</div>
{/* Card form — always rendered for Mollie mount, animated visibility */}
<div
className="overflow-hidden transition-all duration-300 ease-out"
style={{ maxHeight: isCard ? '400px' : '0px', opacity: isCard ? 1 : 0 }}
>
<div className="space-y-4 pb-1">
{/* Cardholder name */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Cardholder name</label>
<div className="overflow-hidden transition-all duration-300" style={{ height: mollieReady ? '48px' : '0px' }}>
<div id="mollie-card-holder" className={mollieFieldBase} />
</div>
{!mollieReady && isCard && <div className={`${mollieFieldBase} bg-neutral-800/30 animate-pulse`} />}
{submitted && cardErrors.cardHolder && (
<p className="mt-1 text-xs text-red-400">{cardErrors.cardHolder}</p>
)}
</div>
{/* Card number */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Card number</label>
<div className="overflow-hidden transition-all duration-300" style={{ height: mollieReady ? '48px' : '0px' }}>
<div id="mollie-card-number" className={mollieFieldBase} />
</div>
{!mollieReady && isCard && <div className={`${mollieFieldBase} bg-neutral-800/30 animate-pulse`} />}
{submitted && cardErrors.cardNumber && (
<p className="mt-1 text-xs text-red-400">{cardErrors.cardNumber}</p>
)}
</div>
{/* Expiry & CVC */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Expiry date</label>
<div className="overflow-hidden transition-all duration-300" style={{ height: mollieReady ? '48px' : '0px' }}>
<div id="mollie-card-expiry" className={mollieFieldBase} />
</div>
{!mollieReady && isCard && <div className={`${mollieFieldBase} bg-neutral-800/30 animate-pulse`} />}
{submitted && cardErrors.expiryDate && (
<p className="mt-1 text-xs text-red-400">{cardErrors.expiryDate}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">CVC</label>
<div className="overflow-hidden transition-all duration-300" style={{ height: mollieReady ? '48px' : '0px' }}>
<div id="mollie-card-cvc" className={mollieFieldBase} />
</div>
{!mollieReady && isCard && <div className={`${mollieFieldBase} bg-neutral-800/30 animate-pulse`} />}
{submitted && cardErrors.verificationCode && (
<p className="mt-1 text-xs text-red-400">{cardErrors.verificationCode}</p>
)}
</div>
</div>
</div>
</div>
{/* Non-card info */}
<AnimatePresence>
{selectedMethod && !isCard && (
<motion.p
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="text-sm text-neutral-400 mb-4 overflow-hidden"
>
You&apos;ll be redirected to complete payment securely via {PAYMENT_METHODS.find((m) => m.id === selectedMethod)?.label}.
</motion.p>
)}
</AnimatePresence>
{/* Form / API errors */}
{formError && (
<div className="mb-4 rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
{formError}
</div>
)}
{/* Mollie fallback */}
{mollieError && isCard && (
<div className="mb-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20 px-4 py-3 text-sm text-yellow-400">
Card fields could not load. Please select another payment method.
</div>
)}
{/* Submit */}
<button
ref={submitRef}
type="submit"
disabled={submitting || !selectedMethod || (isCard && !mollieReady && !mollieError)}
className="mt-4 w-full rounded-lg bg-brand-orange-button px-4 py-3 text-sm font-semibold text-white transition-colors hover:bg-brand-orange-button-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Processing...' : 'Start free trial'}
</button>
{/* Trust signals */}
<div className="mt-4 flex items-center justify-center gap-6 text-xs text-neutral-500">
<span className="flex items-center gap-1.5">
<Lock weight="fill" className="h-3.5 w-3.5" />
Secured with SSL
</span>
<span className="flex items-center gap-1.5">
<ShieldCheck weight="fill" className="h-3.5 w-3.5" />
Cancel anytime
</span>
</div>
</form>
</>
)
}

View File

@@ -0,0 +1,236 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { Select } from '@ciphera-net/ui'
import { TRAFFIC_TIERS, PLAN_PRICES } from '@/lib/plans'
import { COUNTRY_OPTIONS } from '@/lib/countries'
import { calculateVAT, type VATResult } from '@/lib/api/billing'
interface PlanSummaryProps {
plan: string
interval: string
limit: number
country: string
vatId: string
onCountryChange: (country: string) => void
onVatIdChange: (vatId: string) => void
}
const inputClass =
'w-full rounded-lg border border-neutral-700 bg-neutral-800/50 px-3 py-2.5 text-sm text-white placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-brand-orange focus:border-brand-orange transition-colors'
/** Convert VIES ALL-CAPS text to title case (e.g. "SA SODIMAS" → "Sa Sodimas") */
function toTitleCase(s: string) {
return s.replace(/\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
}
export default function PlanSummary({ plan, interval, limit, country, vatId, onCountryChange, onVatIdChange }: PlanSummaryProps) {
const router = useRouter()
const searchParams = useSearchParams()
const [currentInterval, setCurrentInterval] = useState(interval)
const [vatResult, setVatResult] = useState<VATResult | null>(null)
const [vatLoading, setVatLoading] = useState(false)
const [verifiedVatId, setVerifiedVatId] = useState('')
const monthlyCents = PLAN_PRICES[plan]?.[limit] || 0
const isYearly = currentInterval === 'year'
const baseDisplay = isYearly ? (monthlyCents * 11) / 100 : monthlyCents / 100
const tierLabel =
TRAFFIC_TIERS.find((t) => t.value === limit)?.label ||
`${(limit / 1000).toFixed(0)}k`
const handleIntervalToggle = (newInterval: string) => {
setCurrentInterval(newInterval)
const params = new URLSearchParams(searchParams.toString())
params.set('interval', newInterval)
router.replace(`/checkout?${params.toString()}`, { scroll: false })
}
const fetchVAT = useCallback(async (c: string, v: string, iv: string) => {
if (!c) { setVatResult(null); return }
setVatLoading(true)
try {
const result = await calculateVAT({ plan_id: plan, interval: iv, limit, country: c, vat_id: v || undefined })
setVatResult(result)
} catch {
setVatResult(null)
} finally {
setVatLoading(false)
}
}, [plan, limit])
// Auto-fetch when country or interval changes (using the already-verified VAT ID if any)
useEffect(() => {
if (!country) { setVatResult(null); return }
fetchVAT(country, verifiedVatId, currentInterval)
}, [country, currentInterval, fetchVAT, verifiedVatId])
// Clear verified state when VAT ID input changes after a successful verification
useEffect(() => {
if (verifiedVatId !== '' && vatId !== verifiedVatId) {
setVerifiedVatId('')
// Re-fetch without VAT ID to show the 21% rate
if (country) fetchVAT(country, '', currentInterval)
}
}, [vatId]) // eslint-disable-line react-hooks/exhaustive-deps
const handleVerifyVatId = () => {
if (!vatId || !country) return
setVerifiedVatId(vatId)
// useEffect on verifiedVatId will trigger the fetch
}
const isVatChecked = verifiedVatId !== '' && verifiedVatId === vatId
const isVatValid = isVatChecked && !!vatResult?.company_name
return (
<div className="rounded-2xl border border-neutral-800 bg-neutral-900/50 backdrop-blur-xl p-5 space-y-4">
{/* Plan name + interval toggle */}
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-white capitalize">{plan}</h2>
<span className="rounded-full bg-brand-orange/15 px-3 py-0.5 text-xs font-medium text-brand-orange">
30-day trial
</span>
</div>
<div className="flex items-center gap-1 p-1 bg-neutral-800/50 rounded-xl sm:ml-auto">
{(['month', 'year'] as const).map((iv) => (
<button
key={iv}
type="button"
onClick={() => handleIntervalToggle(iv)}
className={`relative px-3.5 py-1.5 text-sm font-medium rounded-lg transition-colors duration-200 ${
currentInterval === iv ? 'text-white' : 'text-neutral-400 hover:text-white'
}`}
>
{currentInterval === iv && (
<motion.div
layoutId="checkout-interval-bg"
className="absolute inset-0 bg-neutral-700 rounded-lg shadow-sm"
transition={{ type: 'spring', bounce: 0.15, duration: 0.35 }}
/>
)}
<span className="relative z-10">{iv === 'month' ? 'Monthly' : 'Yearly'}</span>
</button>
))}
</div>
</div>
{/* Country */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Country</label>
<Select
value={country}
onChange={onCountryChange}
variant="input"
options={[{ value: '', label: 'Select country' }, ...COUNTRY_OPTIONS.map((c) => ({ value: c.value, label: c.label }))]}
/>
</div>
{/* VAT ID */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">
VAT ID <span className="text-neutral-500">(optional)</span>
</label>
<div className="flex gap-2">
<input
type="text"
value={vatId}
onChange={(e) => onVatIdChange(e.target.value)}
placeholder="e.g. DE123456789"
className={inputClass}
/>
<button
type="button"
onClick={handleVerifyVatId}
disabled={!vatId || !country || vatLoading || isVatValid}
className="shrink-0 rounded-lg bg-neutral-700 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-neutral-600 disabled:opacity-40 disabled:cursor-not-allowed"
>
{vatLoading && vatId ? 'Verifying...' : isVatValid ? 'Verified' : 'Verify'}
</button>
</div>
{/* Verified company info */}
<AnimatePresence>
{isVatValid && vatResult?.company_name && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="mt-2 rounded-lg bg-green-500/5 border border-green-500/20 px-3 py-2 text-xs text-neutral-400">
<p className="font-medium text-green-400">{toTitleCase(vatResult.company_name)}</p>
{vatResult.company_address && (
<p className="mt-0.5 whitespace-pre-line">{toTitleCase(vatResult.company_address)}</p>
)}
</div>
</motion.div>
)}
{isVatChecked && !vatLoading && !isVatValid && vatResult && !vatResult.vat_exempt && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="mt-1.5 text-xs text-yellow-400"
>
VAT ID could not be verified. 21% VAT will apply.
</motion.p>
)}
</AnimatePresence>
</div>
{/* Price breakdown */}
<div className={`pt-2 border-t border-neutral-800 transition-opacity duration-200 ${vatLoading ? 'opacity-50' : 'opacity-100'}`}>
{vatResult ? (
<div className="space-y-1.5 text-sm">
<div className="flex justify-between text-neutral-400">
<span>Subtotal ({tierLabel} pageviews)</span>
<span>&euro;{vatResult.base_amount}</span>
</div>
{vatResult.vat_exempt ? (
<div className="flex justify-between text-neutral-500 text-xs">
<span>{vatResult.vat_reason}</span>
<span>&euro;0.00</span>
</div>
) : (
<div className="flex justify-between text-neutral-400">
<span>VAT {vatResult.vat_rate}%</span>
<span>&euro;{vatResult.vat_amount}</span>
</div>
)}
<div className="flex justify-between font-semibold text-white pt-1 border-t border-neutral-800">
<span>Total {isYearly ? '/year' : '/mo'}</span>
<span>&euro;{vatResult.total_amount}</span>
</div>
{isYearly && (
<p className="text-xs text-neutral-500">&euro;{(parseFloat(vatResult.total_amount) / 12).toFixed(2)}/mo</p>
)}
</div>
) : (
<div className="space-y-1.5 text-sm">
<div className="flex justify-between text-neutral-400">
<span>Subtotal ({tierLabel} pageviews)</span>
<span>&euro;{baseDisplay.toFixed(2)}</span>
</div>
<div className="flex justify-between text-neutral-500 text-xs">
<span>VAT</span>
<span>{vatLoading ? 'Calculating...' : 'Select country'}</span>
</div>
<div className="flex justify-between font-semibold text-white pt-1 border-t border-neutral-800">
<span>Total {isYearly ? '/year' : '/mo'} <span className="font-normal text-neutral-500 text-xs">excl. VAT</span></span>
<span>&euro;{baseDisplay.toFixed(2)}</span>
</div>
{isYearly && (
<p className="text-xs text-neutral-500">&euro;{(baseDisplay / 12).toFixed(2)}/mo &middot; Save 1 month</p>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -164,7 +164,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
onClick={() => setOperator(op)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors cursor-pointer ${
operator === op
? 'bg-brand-orange text-white'
? 'bg-brand-orange-button text-white'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
>
@@ -219,7 +219,7 @@ export default function AddFilterDropdown({ onAdd, suggestions = {}, onFetchSugg
<div className="px-3 py-3 border-t border-neutral-800">
<button
onClick={handleSubmitCustom}
className="w-full px-3 py-2 text-sm font-medium bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors cursor-pointer"
className="w-full px-3 py-2 text-sm font-medium bg-brand-orange-button text-white rounded-lg hover:bg-brand-orange/90 transition-colors cursor-pointer"
>
Filter by &ldquo;{search.trim()}&rdquo;
</button>

View File

@@ -674,7 +674,7 @@ export default function Chart({
type="button"
disabled={!annotationForm.text.trim() || !annotationForm.date || saving}
onClick={handleSaveAnnotation}
className="px-3 py-1.5 text-xs font-medium text-white bg-brand-orange hover:bg-brand-orange/90 rounded-lg disabled:opacity-50 cursor-pointer"
className="px-3 py-1.5 text-xs font-medium text-white bg-brand-orange-button hover:bg-brand-orange-button-hover rounded-lg disabled:opacity-50 cursor-pointer"
>
{saving ? 'Saving...' : annotationForm.editingId ? 'Save' : 'Add'}
</button>

View File

@@ -70,7 +70,7 @@ export default function EventProperties({ siteId, eventName, dateRange, onClose
onClick={() => setSelectedKey(k.key)}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors cursor-pointer ${
selectedKey === k.key
? 'bg-brand-orange text-white'
? 'bg-brand-orange-button text-white'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
>

View File

@@ -17,7 +17,7 @@ export default function FilterBar({ filters, onRemove, onClear }: FilterBarProps
<button
key={`${f.dimension}-${f.operator}-${f.values.join(',')}`}
onClick={() => onRemove(i)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-brand-orange text-white hover:bg-brand-orange/80 transition-colors cursor-pointer group"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-brand-orange-button text-white hover:bg-brand-orange-button-hover transition-colors cursor-pointer group"
title={`Remove filter: ${filterLabel(f)}`}
>
<span>{filterLabel(f)}</span>

View File

@@ -518,8 +518,8 @@ export default function Sidebar({
try {
const { access_token } = await switchContext(orgId)
await setSessionAction(access_token)
sessionStorage.setItem('pulse_switching_org', 'true')
window.location.reload()
await auth.refresh()
router.push('/')
} catch (err) {
logger.error('Failed to switch organization', err)
}

View File

@@ -125,7 +125,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
return (
<div
key={ref.referrer}
onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: [ref.referrer] })}
onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: ref.allReferrers ?? [ref.referrer] })}
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
>
<div

View File

@@ -68,7 +68,7 @@ export default function BreakdownDrawer({ siteId, funnelId, stepIndex, stepName,
onClick={() => setActiveDimension(dim)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg whitespace-nowrap transition-colors ${
activeDimension === dim
? 'bg-brand-orange text-white'
? 'bg-brand-orange-button text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>

View File

@@ -296,7 +296,7 @@ export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel,
onClick={() => handleUpdateStep(index, 'category', 'page')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
category === 'page'
? 'bg-brand-orange text-white'
? 'bg-brand-orange-button text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
@@ -307,7 +307,7 @@ export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel,
onClick={() => handleUpdateStep(index, 'category', 'event')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
category === 'event'
? 'bg-brand-orange text-white'
? 'bg-brand-orange-button text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
@@ -470,7 +470,7 @@ export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel,
}}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
windowValue === preset.value && windowUnit === preset.unit
? 'bg-brand-orange text-white'
? 'bg-brand-orange-button text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>

View File

@@ -113,7 +113,7 @@ function BarRow({
// ─── Card 1: Pages ───────────────────────────────────────────────────────────
function PagesCard() {
export function PagesCard() {
const data = [
{ label: '/', value: 142 },
{ label: '/products/drop', value: 68 },
@@ -169,7 +169,7 @@ function getReferrerIcon(name: string, favicon?: string) {
const FAVICON_URL = 'https://www.google.com/s2/favicons'
function ReferrersCard() {
export function ReferrersCard() {
const data = [
{ label: 'Direct', value: 186 },
{ label: 'Google', value: 94, domain: 'google.com' },
@@ -206,7 +206,7 @@ function ReferrersCard() {
// ─── Card 3: Locations (Real Dotted Map) ─────────────────────────────────────
function LocationsCard() {
export function LocationsCard() {
const mockData = [
{ country: 'CH', pageviews: 320 },
{ country: 'US', pageviews: 186 },
@@ -300,7 +300,7 @@ const BROWSER_ICONS: Record<string, string> = {
Opera: '/icons/browsers/opera.svg',
}
function TechnologyCard() {
export function TechnologyCard() {
const data = [
{ label: 'Chrome', value: 412 },
{ label: 'Safari', value: 189 },
@@ -381,7 +381,7 @@ function getHighlightColor(value: number, max: number): string {
return HIGHLIGHT_COLORS[4]
}
function PeakHoursCard() {
export function PeakHoursCard() {
const max = Math.max(...MOCK_GRID.flat())
// Find best time

View File

@@ -2,37 +2,37 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Button, toast, Spinner } from '@ciphera-net/ui'
import { Button, toast, Spinner, Modal } from '@ciphera-net/ui'
import { CreditCard, ArrowSquareOut } from '@phosphor-icons/react'
import { useSubscription } from '@/lib/swr/dashboard'
import { createPortalSession, cancelSubscription, resumeSubscription, getOrders, type Order } from '@/lib/api/billing'
import { updatePaymentMethod, cancelSubscription, resumeSubscription, getOrders, type Order } from '@/lib/api/billing'
import { formatDateLong, formatDate } from '@/lib/utils/formatDate'
import { getAuthErrorMessage } from '@ciphera-net/ui'
export default function WorkspaceBillingTab() {
const { data: subscription, isLoading, mutate } = useSubscription()
const [cancelling, setCancelling] = useState(false)
const [showCancelConfirm, setShowCancelConfirm] = useState(false)
const [orders, setOrders] = useState<Order[]>([])
useEffect(() => {
getOrders().then(setOrders).catch(() => {})
}, [])
const formatAmount = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'USD' }).format(amount / 100)
const formatAmount = (amount: string, currency: string) => {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'EUR' }).format(parseFloat(amount))
}
const handleManageBilling = async () => {
try {
const { url } = await createPortalSession()
if (url) window.open(url, '_blank')
const { url } = await updatePaymentMethod()
if (url) window.location.href = url
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to open billing portal')
toast.error(getAuthErrorMessage(err as Error) || 'Failed to update payment method')
}
}
const handleCancel = async () => {
if (!confirm('Are you sure you want to cancel your subscription?')) return
setCancelling(true)
try {
await cancelSubscription()
@@ -42,6 +42,7 @@ export default function WorkspaceBillingTab() {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to cancel subscription')
} finally {
setCancelling(false)
setShowCancelConfirm(false)
}
}
@@ -149,18 +150,17 @@ export default function WorkspaceBillingTab() {
{subscription.has_payment_method && (
<Button onClick={handleManageBilling} variant="secondary" className="text-sm gap-1.5">
<ArrowSquareOut weight="bold" className="w-3.5 h-3.5" />
Payment method & invoices
Update payment method
</Button>
)}
{isActive && !subscription.cancel_at_period_end && (
<Button
onClick={handleCancel}
onClick={() => setShowCancelConfirm(true)}
variant="secondary"
className="text-sm text-neutral-400 hover:text-red-400"
disabled={cancelling}
>
{cancelling ? 'Cancelling...' : 'Cancel subscription'}
Cancel subscription
</Button>
)}
@@ -171,6 +171,31 @@ export default function WorkspaceBillingTab() {
)}
</div>
{/* Cancel confirmation */}
<Modal isOpen={showCancelConfirm} onClose={() => setShowCancelConfirm(false)} title="Cancel subscription" className="max-w-md">
<p className="text-sm text-neutral-400 mb-1">
Are you sure you want to cancel your subscription?
</p>
{subscription.current_period_end && (
<p className="text-sm text-neutral-500 mb-5">
Your plan will remain active until {formatDateLong(new Date(subscription.current_period_end))}.
</p>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" className="text-sm" onClick={() => setShowCancelConfirm(false)} disabled={cancelling}>
Keep plan
</Button>
<Button
variant="primary"
className="text-sm bg-red-600 hover:bg-red-700 border-red-600 hover:border-red-700"
onClick={handleCancel}
disabled={cancelling}
>
{cancelling ? 'Cancelling...' : 'Yes, cancel'}
</Button>
</div>
</Modal>
{/* Recent Invoices */}
{orders.length > 0 && (
<div className="space-y-2 pt-6 border-t border-neutral-800">
@@ -180,13 +205,10 @@ export default function WorkspaceBillingTab() {
<div key={order.id} className="flex items-center justify-between p-3 rounded-lg border border-neutral-800 text-sm">
<div className="flex items-center gap-3">
<span className="text-neutral-300">{formatDate(new Date(order.created_at))}</span>
<span className="text-white font-medium">{formatAmount(order.total_amount, order.currency)}</span>
{order.invoice_number && (
<span className="text-neutral-500 text-xs">{order.invoice_number}</span>
)}
<span className="text-white font-medium">{formatAmount(order.amount, order.currency)}</span>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${order.paid ? 'bg-green-900/30 text-green-400' : 'bg-neutral-800 text-neutral-400'}`}>
{order.paid ? 'Paid' : order.status}
<span className={`text-xs px-2 py-0.5 rounded-full ${order.status === 'paid' ? 'bg-green-900/30 text-green-400' : 'bg-neutral-800 text-neutral-400'}`}>
{order.status === 'paid' ? 'Paid' : order.status}
</span>
</div>
))}

View File

@@ -72,6 +72,9 @@ export interface CreateCheckoutParams {
plan_id: string
interval: string
limit: number
country: string
vat_id?: string
method?: string
}
export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ url: string }> {
@@ -81,18 +84,63 @@ export async function createCheckoutSession(params: CreateCheckoutParams): Promi
})
}
/** Creates a Mollie checkout session to update the payment mandate. */
export async function updatePaymentMethod(): Promise<{ url: string }> {
return apiRequest<{ url: string }>('/api/billing/update-payment-method', {
method: 'POST',
})
}
export interface Order {
id: string
total_amount: number
subtotal_amount: number
tax_amount: number
amount: string
currency: string
status: string
created_at: string
paid: boolean
invoice_number: string
}
export async function getOrders(): Promise<Order[]> {
return apiRequest<Order[]>('/api/billing/invoices')
}
export interface VATResult {
base_amount: string
vat_rate: number
vat_amount: string
total_amount: string
vat_exempt: boolean
vat_reason: string
company_name?: string
company_address?: string
}
export interface CalculateVATParams {
plan_id: string
interval: string
limit: number
country: string
vat_id?: string
}
export async function calculateVAT(params: CalculateVATParams): Promise<VATResult> {
return apiRequest<VATResult>('/api/billing/calculate-vat', {
method: 'POST',
body: JSON.stringify(params),
})
}
export interface CreateEmbeddedCheckoutParams {
plan_id: string
interval: string
limit: number
country: string
vat_id?: string
card_token: string
}
export async function createEmbeddedCheckout(params: CreateEmbeddedCheckoutParams): Promise<{ status: 'success' | 'pending'; redirect_url?: string }> {
return apiRequest<{ status: 'success' | 'pending'; redirect_url?: string }>('/api/billing/checkout-embedded', {
method: 'POST',
body: JSON.stringify(params),
})
}

View File

@@ -110,14 +110,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const refresh = useCallback(async () => {
try {
const session = await getSessionAction()
const userData = await apiRequest<User>('/auth/user/me')
const merged = { ...userData, org_id: session?.org_id ?? userData.org_id, role: session?.role ?? userData.role }
setUser(prev => {
const merged = {
...userData,
org_id: prev?.org_id,
role: prev?.role
}
setUser(() => {
localStorage.setItem('user', JSON.stringify(merged))
return merged
})

35
lib/countries.ts Normal file
View File

@@ -0,0 +1,35 @@
export const COUNTRY_OPTIONS = [
{ value: 'BE', label: 'Belgium' },
{ value: 'NL', label: 'Netherlands' },
{ value: 'DE', label: 'Germany' },
{ value: 'FR', label: 'France' },
{ value: 'AT', label: 'Austria' },
{ value: 'IT', label: 'Italy' },
{ value: 'ES', label: 'Spain' },
{ value: 'PT', label: 'Portugal' },
{ value: 'IE', label: 'Ireland' },
{ value: 'LU', label: 'Luxembourg' },
{ value: 'FI', label: 'Finland' },
{ value: 'SE', label: 'Sweden' },
{ value: 'DK', label: 'Denmark' },
{ value: 'PL', label: 'Poland' },
{ value: 'CZ', label: 'Czech Republic' },
{ value: 'RO', label: 'Romania' },
{ value: 'BG', label: 'Bulgaria' },
{ value: 'HR', label: 'Croatia' },
{ value: 'SI', label: 'Slovenia' },
{ value: 'SK', label: 'Slovakia' },
{ value: 'HU', label: 'Hungary' },
{ value: 'LT', label: 'Lithuania' },
{ value: 'LV', label: 'Latvia' },
{ value: 'EE', label: 'Estonia' },
{ value: 'MT', label: 'Malta' },
{ value: 'CY', label: 'Cyprus' },
{ value: 'GR', label: 'Greece' },
{ value: 'US', label: 'United States' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'CH', label: 'Switzerland' },
{ value: 'NO', label: 'Norway' },
{ value: 'CA', label: 'Canada' },
{ value: 'AU', label: 'Australia' },
] as const

180
lib/learn-links.ts Normal file
View File

@@ -0,0 +1,180 @@
/**
* Maps Google/Deque documentation URLs to ciphera.net/learn articles.
* Keys are normalized URLs (no protocol, no trailing slash, no query/hash).
* Add entries as new /learn articles are published on ciphera.net.
*/
const LEARN_URL_MAP: Record<string, string> = {
// Performance Metrics
'developer.chrome.com/docs/lighthouse/performance/first-contentful-paint': 'https://ciphera.net/learn/pulse/first-contentful-paint',
'developer.chrome.com/docs/lighthouse/performance/lighthouse-largest-contentful-paint': 'https://ciphera.net/learn/pulse/largest-contentful-paint',
'developer.chrome.com/docs/lighthouse/performance/lighthouse-total-blocking-time': 'https://ciphera.net/learn/pulse/total-blocking-time',
'web.dev/articles/cls': 'https://ciphera.net/learn/pulse/cumulative-layout-shift',
'developer.chrome.com/docs/lighthouse/performance/speed-index': 'https://ciphera.net/learn/pulse/speed-index',
'web.dev/articles/inp': 'https://ciphera.net/learn/pulse/interaction-to-next-paint',
'developer.chrome.com/docs/lighthouse/performance/interactive': 'https://ciphera.net/learn/pulse/time-to-interactive',
'developer.chrome.com/docs/lighthouse/performance/lighthouse-max-potential-fid': 'https://ciphera.net/learn/pulse/max-potential-first-input-delay',
// Performance Insights
'developer.chrome.com/docs/performance/insights/cache': 'https://ciphera.net/learn/pulse/cache-insight',
'developer.chrome.com/docs/performance/insights/cls-culprit': 'https://ciphera.net/learn/pulse/cls-culprits-insight',
'developer.chrome.com/docs/performance/insights/document-latency': 'https://ciphera.net/learn/pulse/document-latency-insight',
'developer.chrome.com/docs/performance/insights/dom-size': 'https://ciphera.net/learn/pulse/dom-size-insight',
'developer.chrome.com/docs/performance/insights/duplicated-javascript': 'https://ciphera.net/learn/pulse/duplicated-javascript-insight',
'developer.chrome.com/docs/performance/insights/font-display': 'https://ciphera.net/learn/pulse/font-display-insight',
'developer.chrome.com/docs/performance/insights/forced-reflow': 'https://ciphera.net/learn/pulse/forced-reflow-insight',
'developer.chrome.com/docs/performance/insights/image-delivery': 'https://ciphera.net/learn/pulse/image-delivery-insight',
'developer.chrome.com/docs/performance/insights/inp-breakdown': 'https://ciphera.net/learn/pulse/inp-breakdown-insight',
'developer.chrome.com/docs/performance/insights/lcp-breakdown': 'https://ciphera.net/learn/pulse/lcp-breakdown-insight',
'developer.chrome.com/docs/performance/insights/lcp-discovery': 'https://ciphera.net/learn/pulse/lcp-discovery-insight',
'developer.chrome.com/docs/performance/insights/legacy-javascript': 'https://ciphera.net/learn/pulse/legacy-javascript-insight',
'developer.chrome.com/docs/performance/insights/modern-http': 'https://ciphera.net/learn/pulse/modern-http-insight',
'developer.chrome.com/docs/performance/insights/network-dependency-tree': 'https://ciphera.net/learn/pulse/network-dependency-tree-insight',
'developer.chrome.com/docs/performance/insights/render-blocking': 'https://ciphera.net/learn/pulse/render-blocking-insight',
'developer.chrome.com/docs/performance/insights/third-parties': 'https://ciphera.net/learn/pulse/third-parties-insight',
'developer.chrome.com/docs/performance/insights/viewport': 'https://ciphera.net/learn/pulse/viewport-insight',
// Performance Diagnostics
'developer.chrome.com/docs/lighthouse/performance/unminified-css': 'https://ciphera.net/learn/pulse/unminified-css',
'developer.chrome.com/docs/lighthouse/performance/unminified-javascript': 'https://ciphera.net/learn/pulse/unminified-javascript',
'developer.chrome.com/docs/lighthouse/performance/unused-css-rules': 'https://ciphera.net/learn/pulse/unused-css-rules',
'developer.chrome.com/docs/lighthouse/performance/unused-javascript': 'https://ciphera.net/learn/pulse/unused-javascript',
'developer.chrome.com/docs/lighthouse/performance/total-byte-weight': 'https://ciphera.net/learn/pulse/total-byte-weight',
'developer.chrome.com/docs/lighthouse/performance/user-timings': 'https://ciphera.net/learn/pulse/user-timings',
'developer.chrome.com/docs/lighthouse/performance/bootup-time': 'https://ciphera.net/learn/pulse/bootup-time',
'developer.chrome.com/docs/lighthouse/performance/mainthread-work-breakdown': 'https://ciphera.net/learn/pulse/mainthread-work-breakdown',
'web.dev/articles/optimize-long-tasks': 'https://ciphera.net/learn/pulse/long-tasks',
'developer.chrome.com/docs/lighthouse/performance/non-composited-animations': 'https://ciphera.net/learn/pulse/non-composited-animations',
'web.dev/articles/optimize-cls': 'https://ciphera.net/learn/pulse/unsized-images',
'developer.chrome.com/docs/lighthouse/performance/bf-cache': 'https://ciphera.net/learn/pulse/bf-cache',
// Performance Hidden
'developer.chrome.com/docs/lighthouse/performance/redirects': 'https://ciphera.net/learn/pulse/redirects',
'developer.chrome.com/docs/lighthouse/performance/time-to-first-byte': 'https://ciphera.net/learn/pulse/server-response-time',
// SEO
'developer.chrome.com/docs/lighthouse/seo/is-crawlable': 'https://ciphera.net/learn/pulse/is-crawlable',
'developer.chrome.com/docs/lighthouse/seo/http-status-code': 'https://ciphera.net/learn/pulse/http-status-code',
'developer.chrome.com/docs/lighthouse/seo/invalid-robots-txt': 'https://ciphera.net/learn/pulse/robots-txt',
'developer.chrome.com/docs/lighthouse/seo/meta-description': 'https://ciphera.net/learn/pulse/meta-description',
'developer.chrome.com/docs/lighthouse/seo/link-text': 'https://ciphera.net/learn/pulse/link-text',
'developer.chrome.com/docs/lighthouse/seo/hreflang': 'https://ciphera.net/learn/pulse/hreflang',
'developer.chrome.com/docs/lighthouse/seo/canonical': 'https://ciphera.net/learn/pulse/canonical',
'developer.chrome.com/docs/lighthouse/seo/structured-data': 'https://ciphera.net/learn/pulse/structured-data',
// Best Practices
'developer.chrome.com/docs/lighthouse/pwa/is-on-https': 'https://ciphera.net/learn/pulse/is-on-https',
'developer.chrome.com/docs/lighthouse/pwa/redirects-http': 'https://ciphera.net/learn/pulse/redirects-http',
'developer.chrome.com/docs/lighthouse/best-practices/geolocation-on-start': 'https://ciphera.net/learn/pulse/geolocation-on-start',
'developer.chrome.com/docs/lighthouse/best-practices/notification-on-start': 'https://ciphera.net/learn/pulse/notification-on-start',
'developer.chrome.com/docs/lighthouse/best-practices/csp-xss': 'https://ciphera.net/learn/pulse/csp-xss',
'developer.chrome.com/docs/lighthouse/best-practices/has-hsts': 'https://ciphera.net/learn/pulse/has-hsts',
'web.dev/articles/why-coop-coep': 'https://ciphera.net/learn/pulse/origin-isolation',
'developer.chrome.com/docs/lighthouse/best-practices/clickjacking-mitigation': 'https://ciphera.net/learn/pulse/clickjacking-mitigation',
'developer.chrome.com/docs/lighthouse/best-practices/paste-preventing-inputs': 'https://ciphera.net/learn/pulse/paste-preventing-inputs',
'developer.chrome.com/docs/lighthouse/best-practices/image-aspect-ratio': 'https://ciphera.net/learn/pulse/image-aspect-ratio',
'web.dev/articles/serve-responsive-images': 'https://ciphera.net/learn/pulse/image-size-responsive',
'developer.chrome.com/docs/lighthouse/best-practices/doctype': 'https://ciphera.net/learn/pulse/doctype',
'developer.chrome.com/docs/lighthouse/best-practices/charset': 'https://ciphera.net/learn/pulse/charset',
// Accessibility
'dequeuniversity.com/rules/axe/4.11/aria-allowed-attr': 'https://ciphera.net/learn/pulse/aria-allowed-attr',
'dequeuniversity.com/rules/axe/4.11/aria-command-name': 'https://ciphera.net/learn/pulse/aria-command-name',
'dequeuniversity.com/rules/axe/4.11/aria-conditional-attr': 'https://ciphera.net/learn/pulse/aria-conditional-attr',
'dequeuniversity.com/rules/axe/4.11/aria-deprecated-role': 'https://ciphera.net/learn/pulse/aria-deprecated-role',
'dequeuniversity.com/rules/axe/4.11/aria-dialog-name': 'https://ciphera.net/learn/pulse/aria-dialog-name',
'dequeuniversity.com/rules/axe/4.11/aria-hidden-body': 'https://ciphera.net/learn/pulse/aria-hidden-body',
'dequeuniversity.com/rules/axe/4.11/aria-hidden-focus': 'https://ciphera.net/learn/pulse/aria-hidden-focus',
'dequeuniversity.com/rules/axe/4.11/aria-input-field-name': 'https://ciphera.net/learn/pulse/aria-input-field-name',
'dequeuniversity.com/rules/axe/4.11/aria-meter-name': 'https://ciphera.net/learn/pulse/aria-meter-name',
'dequeuniversity.com/rules/axe/4.11/aria-progressbar-name': 'https://ciphera.net/learn/pulse/aria-progressbar-name',
'dequeuniversity.com/rules/axe/4.11/aria-prohibited-attr': 'https://ciphera.net/learn/pulse/aria-prohibited-attr',
'dequeuniversity.com/rules/axe/4.11/aria-required-attr': 'https://ciphera.net/learn/pulse/aria-required-attr',
'dequeuniversity.com/rules/axe/4.11/aria-required-children': 'https://ciphera.net/learn/pulse/aria-required-children',
'dequeuniversity.com/rules/axe/4.11/aria-required-parent': 'https://ciphera.net/learn/pulse/aria-required-parent',
'dequeuniversity.com/rules/axe/4.11/aria-roles': 'https://ciphera.net/learn/pulse/aria-roles',
'dequeuniversity.com/rules/axe/4.11/aria-text': 'https://ciphera.net/learn/pulse/aria-text',
'dequeuniversity.com/rules/axe/4.11/aria-toggle-field-name': 'https://ciphera.net/learn/pulse/aria-toggle-field-name',
'dequeuniversity.com/rules/axe/4.11/aria-tooltip-name': 'https://ciphera.net/learn/pulse/aria-tooltip-name',
'dequeuniversity.com/rules/axe/4.11/aria-treeitem-name': 'https://ciphera.net/learn/pulse/aria-treeitem-name',
'dequeuniversity.com/rules/axe/4.11/aria-valid-attr-value': 'https://ciphera.net/learn/pulse/aria-valid-attr-value',
'dequeuniversity.com/rules/axe/4.11/aria-valid-attr': 'https://ciphera.net/learn/pulse/aria-valid-attr',
'dequeuniversity.com/rules/axe/4.11/duplicate-id-aria': 'https://ciphera.net/learn/pulse/duplicate-id-aria',
'dequeuniversity.com/rules/axe/4.11/button-name': 'https://ciphera.net/learn/pulse/button-name',
'dequeuniversity.com/rules/axe/4.11/document-title': 'https://ciphera.net/learn/pulse/document-title',
'dequeuniversity.com/rules/axe/4.11/form-field-multiple-labels': 'https://ciphera.net/learn/pulse/form-field-multiple-labels',
'dequeuniversity.com/rules/axe/4.11/frame-title': 'https://ciphera.net/learn/pulse/frame-title',
'dequeuniversity.com/rules/axe/4.11/image-alt': 'https://ciphera.net/learn/pulse/image-alt',
'dequeuniversity.com/rules/axe/4.11/input-button-name': 'https://ciphera.net/learn/pulse/input-button-name',
'dequeuniversity.com/rules/axe/4.11/input-image-alt': 'https://ciphera.net/learn/pulse/input-image-alt',
'dequeuniversity.com/rules/axe/4.11/label': 'https://ciphera.net/learn/pulse/label',
'dequeuniversity.com/rules/axe/4.11/link-name': 'https://ciphera.net/learn/pulse/link-name',
'dequeuniversity.com/rules/axe/4.11/object-alt': 'https://ciphera.net/learn/pulse/object-alt',
'dequeuniversity.com/rules/axe/4.11/select-name': 'https://ciphera.net/learn/pulse/select-name',
'dequeuniversity.com/rules/axe/4.11/skip-link': 'https://ciphera.net/learn/pulse/skip-link',
'dequeuniversity.com/rules/axe/4.11/image-redundant-alt': 'https://ciphera.net/learn/pulse/image-redundant-alt',
'dequeuniversity.com/rules/axe/4.11/color-contrast': 'https://ciphera.net/learn/pulse/color-contrast',
'dequeuniversity.com/rules/axe/4.11/link-in-text-block': 'https://ciphera.net/learn/pulse/link-in-text-block',
'dequeuniversity.com/rules/axe/4.11/accesskeys': 'https://ciphera.net/learn/pulse/accesskeys',
'dequeuniversity.com/rules/axe/4.11/bypass': 'https://ciphera.net/learn/pulse/bypass',
'dequeuniversity.com/rules/axe/4.11/heading-order': 'https://ciphera.net/learn/pulse/heading-order',
'dequeuniversity.com/rules/axe/4.11/tabindex': 'https://ciphera.net/learn/pulse/tabindex',
'dequeuniversity.com/rules/axe/4.11/definition-list': 'https://ciphera.net/learn/pulse/definition-list',
'dequeuniversity.com/rules/axe/4.11/dlitem': 'https://ciphera.net/learn/pulse/dlitem',
'dequeuniversity.com/rules/axe/4.11/list': 'https://ciphera.net/learn/pulse/list',
'dequeuniversity.com/rules/axe/4.11/listitem': 'https://ciphera.net/learn/pulse/listitem',
'dequeuniversity.com/rules/axe/4.11/td-headers-attr': 'https://ciphera.net/learn/pulse/td-headers-attr',
'dequeuniversity.com/rules/axe/4.11/th-has-data-cells': 'https://ciphera.net/learn/pulse/th-has-data-cells',
'dequeuniversity.com/rules/axe/4.11/meta-refresh': 'https://ciphera.net/learn/pulse/meta-refresh',
'dequeuniversity.com/rules/axe/4.11/meta-viewport': 'https://ciphera.net/learn/pulse/meta-viewport',
'dequeuniversity.com/rules/axe/4.11/target-size': 'https://ciphera.net/learn/pulse/target-size',
'dequeuniversity.com/rules/axe/4.11/empty-heading': 'https://ciphera.net/learn/pulse/empty-heading',
'dequeuniversity.com/rules/axe/4.11/html-has-lang': 'https://ciphera.net/learn/pulse/html-has-lang',
'dequeuniversity.com/rules/axe/4.11/html-lang-valid': 'https://ciphera.net/learn/pulse/html-lang-valid',
'dequeuniversity.com/rules/axe/4.11/html-xml-lang-mismatch': 'https://ciphera.net/learn/pulse/html-xml-lang-mismatch',
'dequeuniversity.com/rules/axe/4.11/valid-lang': 'https://ciphera.net/learn/pulse/valid-lang',
'dequeuniversity.com/rules/axe/4.11/video-caption': 'https://ciphera.net/learn/pulse/video-caption',
// Best Practices General (batch 2)
'developer.chrome.com/docs/lighthouse/best-practices/trusted-types-xss': 'https://ciphera.net/learn/pulse/trusted-types-xss',
'developer.chrome.com/docs/lighthouse/best-practices/deprecations': 'https://ciphera.net/learn/pulse/deprecations',
'developer.chrome.com/docs/lighthouse/best-practices/errors-in-console': 'https://ciphera.net/learn/pulse/errors-in-console',
'developer.chrome.com/docs/lighthouse/best-practices/js-libraries': 'https://ciphera.net/learn/pulse/js-libraries',
'developer.chrome.com/docs/devtools/javascript/source-maps': 'https://ciphera.net/learn/pulse/valid-source-maps',
// Accessibility Best Practices (batch 2)
'dequeuniversity.com/rules/axe/4.11/aria-allowed-role': 'https://ciphera.net/learn/pulse/aria-allowed-role',
'dequeuniversity.com/rules/axe/4.11/identical-links-same-purpose': 'https://ciphera.net/learn/pulse/identical-links-same-purpose',
'dequeuniversity.com/rules/axe/4.11/landmark-one-main': 'https://ciphera.net/learn/pulse/landmark-one-main',
'dequeuniversity.com/rules/axe/4.11/table-duplicate-name': 'https://ciphera.net/learn/pulse/table-duplicate-name',
// Accessibility Experimental (batch 2)
'dequeuniversity.com/rules/axe/4.11/label-content-name-mismatch': 'https://ciphera.net/learn/pulse/label-content-name-mismatch',
'dequeuniversity.com/rules/axe/4.11/table-fake-caption': 'https://ciphera.net/learn/pulse/table-fake-caption',
'dequeuniversity.com/rules/axe/4.11/td-has-header': 'https://ciphera.net/learn/pulse/td-has-header',
// Accessibility Manual Audits
'developer.chrome.com/docs/lighthouse/accessibility/focusable-controls': 'https://ciphera.net/learn/pulse/focusable-controls',
'developer.chrome.com/docs/lighthouse/accessibility/interactive-element-affordance': 'https://ciphera.net/learn/pulse/interactive-element-affordance',
'developer.chrome.com/docs/lighthouse/accessibility/logical-tab-order': 'https://ciphera.net/learn/pulse/logical-tab-order',
'developer.chrome.com/docs/lighthouse/accessibility/visual-order-follows-dom': 'https://ciphera.net/learn/pulse/visual-order-follows-dom',
'developer.chrome.com/docs/lighthouse/accessibility/focus-traps': 'https://ciphera.net/learn/pulse/focus-traps',
'developer.chrome.com/docs/lighthouse/accessibility/managed-focus': 'https://ciphera.net/learn/pulse/managed-focus',
'developer.chrome.com/docs/lighthouse/accessibility/use-landmarks': 'https://ciphera.net/learn/pulse/use-landmarks',
'developer.chrome.com/docs/lighthouse/accessibility/offscreen-content-hidden': 'https://ciphera.net/learn/pulse/offscreen-content-hidden',
'developer.chrome.com/docs/lighthouse/accessibility/custom-controls-labels': 'https://ciphera.net/learn/pulse/custom-controls-labels',
'developer.chrome.com/docs/lighthouse/accessibility/custom-control-roles': 'https://ciphera.net/learn/pulse/custom-controls-roles',
}
function normalizeUrl(url: string): string {
try {
const u = new URL(url)
return (u.host + u.pathname).replace(/\/+$/, '')
} catch {
return url
}
}
export function remapLearnUrl(url: string): string {
const normalized = normalizeUrl(url)
return LEARN_URL_MAP[normalized] || url
}

71
lib/mollie.ts Normal file
View File

@@ -0,0 +1,71 @@
'use client'
// Mollie.js types (no official @types package)
export interface MollieInstance {
createComponent: (type: string, options?: { styles?: Record<string, unknown> }) => MollieComponent
createToken: () => Promise<{ token: string | null; error: MollieError | null }>
}
export interface MollieComponent {
mount: (selector: string | HTMLElement) => void
unmount: () => void
addEventListener: (event: string, callback: (event: unknown) => void) => void
}
export interface MollieError {
field: string
message: string
}
declare global {
interface Window {
Mollie: (profileId: string, options?: { locale?: string; testmode?: boolean }) => MollieInstance
}
}
const MOLLIE_PROFILE_ID = process.env.NEXT_PUBLIC_MOLLIE_PROFILE_ID || ''
// Mollie Components card field styles — matches Pulse dark theme
export const MOLLIE_FIELD_STYLES = {
base: {
color: '#ffffff',
fontSize: '15px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontWeight: '400',
letterSpacing: '0.025em',
'::placeholder': {
color: '#737373',
},
},
valid: {
color: '#ffffff',
},
invalid: {
color: '#ef4444',
},
}
let mollieInstance: MollieInstance | null = null
/**
* Initialize Mollie.js. Must be called after the Mollie script has loaded.
*/
export function initMollie(): MollieInstance | null {
if (mollieInstance) return mollieInstance
if (typeof window === 'undefined' || !window.Mollie || !MOLLIE_PROFILE_ID) return null
// testmode must match the API key type on the backend (test_ = true, live_ = false)
const testmode = process.env.NEXT_PUBLIC_MOLLIE_TESTMODE === 'true'
mollieInstance = window.Mollie(MOLLIE_PROFILE_ID, {
locale: 'en_US',
testmode,
})
return mollieInstance
}
/**
* Get the current Mollie instance (must call initMollie first).
*/
export function getMollie(): MollieInstance | null {
return mollieInstance
}

View File

@@ -79,3 +79,14 @@ export function formatRetentionMonths(months: number): string {
if (Number.isInteger(years)) return years === 1 ? '1 year' : `${years} years`
return `${months} months`
}
/**
* Monthly prices in EUR cents per plan and traffic tier.
* IMPORTANT: Must stay in sync with backend GetSubscriptionAmount() in internal/billing/mollie.go
* Yearly = monthly * 11 (1 month free)
*/
export const PLAN_PRICES: Record<string, Record<number, number>> = {
solo: { 10000: 700, 50000: 1100, 100000: 1500, 250000: 2500, 500000: 3900, 1000000: 5500, 2500000: 7900, 5000000: 10300, 10000000: 13500 },
team: { 10000: 1100, 50000: 1900, 100000: 2300, 250000: 3900, 500000: 5900, 1000000: 7900, 2500000: 11900, 5000000: 15500, 10000000: 19900 },
business: { 10000: 1500, 50000: 2700, 100000: 3100, 250000: 5900, 500000: 7900, 1000000: 11100, 2500000: 16900, 5000000: 20700, 10000000: 26900 },
}

View File

@@ -174,6 +174,9 @@ const REFERRER_REGISTRY: Record<string, ReferrerEntry> = {
// ── Browsers as referrers ──
googlechrome: { display: 'Google Chrome', icon: () => <img src="/icons/browsers/chrome.svg" alt="Chrome" width={16} height={16} className="inline-block" />, hostnames: ['googlechrome.github.io'] },
// ── Ciphera products ──
pulse: { display: 'Pulse', icon: () => <img src="/pulse_icon_no_margins.png" alt="Pulse" width={16} height={16} className="inline-block" />, hostnames: ['pulse.ciphera.net', 'pulse-staging.ciphera.net'] },
}
// ── Derived lookup maps (built once at module load) ──
@@ -319,15 +322,16 @@ export function getReferrerFavicon(referrer: string): string | null {
*/
export function mergeReferrersByDisplayName(
items: Array<{ referrer: string; pageviews: number }>
): Array<{ referrer: string; pageviews: number }> {
const byDisplayName = new Map<string, { referrer: string; pageviews: number; maxSingle: number }>()
): Array<{ referrer: string; pageviews: number; allReferrers: string[] }> {
const byDisplayName = new Map<string, { referrer: string; pageviews: number; maxSingle: number; allReferrers: Set<string> }>()
for (const ref of items) {
const name = getReferrerDisplayName(ref.referrer)
const existing = byDisplayName.get(name)
if (!existing) {
byDisplayName.set(name, { referrer: ref.referrer, pageviews: ref.pageviews, maxSingle: ref.pageviews })
byDisplayName.set(name, { referrer: ref.referrer, pageviews: ref.pageviews, maxSingle: ref.pageviews, allReferrers: new Set([ref.referrer]) })
} else {
existing.pageviews += ref.pageviews
existing.allReferrers.add(ref.referrer)
if (ref.pageviews > existing.maxSingle) {
existing.maxSingle = ref.pageviews
existing.referrer = ref.referrer
@@ -335,6 +339,6 @@ export function mergeReferrersByDisplayName(
}
}
return Array.from(byDisplayName.values())
.map(({ referrer, pageviews }) => ({ referrer, pageviews }))
.map(({ referrer, pageviews, allReferrers }) => ({ referrer, pageviews, allReferrers: Array.from(allReferrers) }))
.sort((a, b) => b.pageviews - a.pageviews)
}

View File

@@ -11,13 +11,13 @@ const withPWA = withPWAInit({
const cspDirectives = [
"default-src 'self'",
// Next.js requires 'unsafe-inline' for its bootstrap scripts; 'unsafe-eval' only in dev (HMR)
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''}`,
`script-src 'self' 'unsafe-inline' https://js.mollie.com${process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://www.google.com https://*.gstatic.com https://ciphera.net",
"font-src 'self'",
`connect-src 'self' https://*.ciphera.net https://ciphera.net https://www.google.com https://*.gstatic.com https://cdn.jsdelivr.net${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`,
`connect-src 'self' https://*.ciphera.net https://ciphera.net https://www.google.com https://*.gstatic.com https://cdn.jsdelivr.net https://*.mollie.com${process.env.NODE_ENV === 'development' ? ' http://localhost:*' : ''}`,
"worker-src 'self' blob:",
"frame-src 'none'",
"frame-src https://*.mollie.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self' https://*.ciphera.net",

8
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "pulse-frontend",
"version": "0.15.0-alpha",
"dependencies": {
"@ciphera-net/ui": "^0.3.3",
"@ciphera-net/ui": "^0.3.6",
"@ducanh2912/next-pwa": "^10.2.9",
"@icons-pack/react-simple-icons": "^13.13.0",
"@phosphor-icons/react": "^2.1.10",
@@ -1682,9 +1682,9 @@
}
},
"node_modules/@ciphera-net/ui": {
"version": "0.3.3",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.3.3/e67893f5cadc93c0dcb0b9987b9d370dce0d073b",
"integrity": "sha512-Z5A8qj0ZuMMJq72ZREknxDlL1LciUoirmQmkHmp4H2Vl/5ZWELrvdm65V11hJuc2TZpXnCBxtWa2UoKi/SVOIw==",
"version": "0.3.6",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.3.6/886a376c94508da1c8b499404e9bf97ea37c9ba3",
"integrity": "sha512-Utio35+w3/Yso3d/6ebobmWJOvF7VFS3GQRq7/FbpEYqWxpI2VrxUPoa0zQ9x5aCgXKabdQj+88Og+0pixQVJw==",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"class-variance-authority": "^0.7.1",

View File

@@ -12,7 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@ciphera-net/ui": "^0.3.3",
"@ciphera-net/ui": "^0.3.6",
"@ducanh2912/next-pwa": "^10.2.9",
"@icons-pack/react-simple-icons": "^13.13.0",
"@phosphor-icons/react": "^2.1.10",

View File

@@ -0,0 +1,16 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_823_247)">
<rect x="1.375" y="1.375" width="117.25" height="77.25" rx="6.625" fill="white"/>
<path d="M55.5533 22.9046C61.103 22.9046 64.9675 26.7301 64.9675 32.2997C64.9675 37.8892 61.0235 41.7345 55.4142 41.7345H49.2696V51.5062H44.8301V22.9046L55.5533 22.9046ZM49.2695 38.0081H54.3635C58.2288 38.0081 60.4286 35.9271 60.4286 32.3196C60.4286 28.7124 58.2288 26.6509 54.3834 26.6509H49.2695V38.0081Z" fill="black"/>
<path d="M66.1274 45.5799C66.1274 41.9326 68.9222 39.6929 73.8778 39.4154L79.5858 39.0786V37.4732C79.5858 35.1541 78.0198 33.7666 75.404 33.7666C72.9258 33.7666 71.3797 34.9556 71.0035 36.8191H66.9601C67.1979 33.0528 70.4086 30.278 75.5623 30.278C80.6165 30.278 83.8471 32.9538 83.8471 37.136V51.5062H79.7441V48.0772H79.6454C78.4365 50.3963 75.8001 51.8629 73.065 51.8629C68.9818 51.8629 66.1274 49.3258 66.1274 45.5799ZM79.5858 43.697V42.0518L74.452 42.3688C71.8951 42.5473 70.4484 43.6771 70.4484 45.461C70.4484 47.2842 71.9547 48.4736 74.254 48.4736C77.2468 48.4736 79.5858 46.4122 79.5858 43.697Z" fill="black"/>
<path d="M87.7206 59.177V55.7082C88.0372 55.7874 88.7506 55.7874 89.1077 55.7874C91.0896 55.7874 92.1601 54.9551 92.8139 52.8145C92.8139 52.7747 93.1908 51.5459 93.1908 51.5261L85.6592 30.6546H90.2967L95.5696 47.6214H95.6484L100.921 30.6546H105.44L97.6303 52.5962C95.8472 57.6508 93.7857 59.276 89.4648 59.276C89.1077 59.276 88.0372 59.2363 87.7206 59.177Z" fill="black"/>
<path d="M31.7358 25.6955C32.8058 24.3572 33.5319 22.5603 33.3404 20.724C31.7741 20.8019 29.8627 21.7573 28.7562 23.0967C27.7626 24.2436 26.8832 26.1158 27.1124 27.8751C28.8707 28.0276 30.6273 26.9962 31.7358 25.6955Z" fill="black"/>
<path d="M33.3204 28.2186C30.7671 28.0665 28.5961 29.6678 27.3767 29.6678C26.1567 29.6678 24.2894 28.2952 22.2698 28.3322C19.6412 28.3708 17.2022 29.8571 15.8682 32.2209C13.1246 36.9497 15.1442 43.9642 17.8122 47.8155C19.1079 49.7209 20.6694 51.8189 22.7269 51.7435C24.6709 51.6672 25.4328 50.4847 27.7958 50.4847C30.1571 50.4847 30.8435 51.7435 32.9013 51.7054C35.0353 51.6672 36.3695 49.799 37.6651 47.8918C39.1515 45.7198 39.7599 43.6225 39.7982 43.5073C39.7599 43.4692 35.6832 41.9053 35.6454 37.2158C35.6069 33.2892 38.8461 31.4215 38.9985 31.3057C37.1694 28.6003 34.3113 28.2952 33.3204 28.2186Z" fill="black"/>
<rect x="1.375" y="1.375" width="117.25" height="77.25" rx="6.625" stroke="black" stroke-width="2.75"/>
</g>
<defs>
<clipPath id="clip0_823_247">
<rect width="120" height="80" rx="4" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,16 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path d="M15.5 67.7753V54.7492H19.4669C22.3503 54.7492 24.2052 55.8471 24.2052 58.1174C24.2052 59.4014 23.6175 60.2946 22.7911 60.8156C23.9848 61.3738 24.6827 62.4532 24.6827 63.9233C24.6827 66.5472 22.7911 67.7753 19.8526 67.7753H15.5ZM18.0528 60.1643H19.9445C21.1014 60.1643 21.5974 59.5874 21.5974 58.5268C21.5974 57.3916 20.6974 57.0195 19.4853 57.0195H18.0528V60.1643ZM18.0528 65.505H19.6322C21.1749 65.505 22.0748 65.1142 22.0748 63.8861C22.0748 62.6765 21.3035 62.1741 19.8526 62.1741H18.0528V65.505ZM29.6333 67.9986C27.1172 67.9986 25.8499 66.7518 25.8499 65.077C25.8499 63.2347 27.3376 62.1554 29.5414 62.1368C30.0891 62.147 30.6353 62.1967 31.1759 62.2857V61.8391C31.1759 60.704 30.5331 60.1643 29.3027 60.1643C28.4737 60.1538 27.6506 60.3054 26.8784 60.6109L26.4193 58.6012C27.209 58.2662 28.4762 58.0429 29.5965 58.0429C32.2962 58.0429 33.6369 59.4944 33.6369 62.0066V67.1798C32.8839 67.5706 31.4698 67.9986 29.6333 67.9986ZM31.1759 65.8958V63.9047C30.7477 63.8082 30.3106 63.7583 29.872 63.7558C29.0272 63.7558 28.366 64.0908 28.366 64.9654C28.366 65.747 28.917 66.1564 29.8903 66.1564C30.333 66.171 30.7729 66.0818 31.1759 65.8958ZM35.4212 67.7753V58.8617C36.741 58.3246 38.1496 58.0467 39.5718 58.0429C42.2532 58.0429 43.7959 59.3827 43.7959 61.8577V67.7753H41.2614V62.0438C41.2614 60.7598 40.6737 60.1643 39.5534 60.1643C38.9972 60.1564 38.4458 60.2707 37.9373 60.4993V67.7753H35.4212ZM52.6528 58.6012L52.1753 60.6295C51.5192 60.3415 50.8148 60.1836 50.1 60.1643C48.6124 60.1643 47.8043 61.225 47.8043 62.9743C47.8043 64.8909 48.6491 65.8772 50.2286 65.8772C50.9335 65.8577 51.627 65.693 52.2671 65.3934L52.6712 67.459C51.8396 67.8339 50.9365 68.0182 50.0265 67.9986C46.9779 67.9986 45.2148 66.0819 45.2148 63.0486C45.2148 60.0341 46.9595 58.0429 49.8797 58.0429C50.8317 58.0382 51.7749 58.2281 52.6528 58.6012ZM58.128 67.9986C55.2997 67.9986 53.5366 66.0074 53.5366 63.0114C53.5366 60.0341 55.2997 58.0429 58.128 58.0429C60.9746 58.0429 62.701 60.0341 62.701 63.0114C62.701 66.0074 60.9746 67.9986 58.128 67.9986ZM58.128 65.8772C59.432 65.8772 60.1115 64.7793 60.1115 63.0115C60.1115 61.2622 59.432 60.1643 58.128 60.1643C56.8424 60.1643 56.1262 61.2622 56.1262 63.0115C56.1262 64.7793 56.8424 65.8772 58.128 65.8772ZM64.1676 67.7753V58.8617C65.4874 58.3246 66.896 58.0467 68.3182 58.0429C70.9996 58.0429 72.5423 59.3827 72.5423 61.8577V67.7753H70.0078V62.0438C70.0078 60.7598 69.4201 60.1643 68.2998 60.1643C67.7436 60.1564 67.1922 60.2707 66.6837 60.4993V67.7753H64.1676ZM78.258 67.9986C76.0725 67.9986 74.9523 66.789 74.9523 64.3326V60.3132H73.7034V58.2662H74.9523V56.2006L77.4866 56.0704V58.2662H79.5252V60.3132H77.4866V64.2955C77.4866 65.3748 77.9275 65.8772 78.7539 65.8772C79.0814 65.8764 79.4078 65.8389 79.7272 65.7656L79.8558 67.8312C79.3313 67.9489 78.7952 68.005 78.258 67.9986ZM84.6551 67.9986C82.139 67.9986 80.8718 66.7518 80.8718 65.077C80.8718 63.2347 82.3594 62.1554 84.5633 62.1368C85.1109 62.147 85.6571 62.1967 86.1978 62.2857V61.8391C86.1978 60.704 85.555 60.1643 84.3245 60.1643C83.4956 60.1538 82.6725 60.3054 81.9003 60.6109L81.4411 58.6012C82.2308 58.2662 83.4981 58.0429 84.6184 58.0429C87.3181 58.0429 88.6587 59.4944 88.6587 62.0066V67.1798C87.9058 67.5706 86.4916 67.9986 84.6551 67.9986ZM86.1978 65.8958V63.9047C85.7696 63.8082 85.3325 63.7583 84.8938 63.7558C84.049 63.7558 83.3879 64.0908 83.3879 64.9654C83.3879 65.747 83.9388 66.1564 84.9122 66.1564C85.3548 66.171 85.7948 66.0818 86.1978 65.8958ZM97.3299 58.6012L96.8524 60.6295C96.1964 60.3415 95.4919 60.1836 94.7771 60.1643C93.2896 60.1643 92.4814 61.225 92.4814 62.9743C92.4814 64.8909 93.3262 65.8772 94.9057 65.8772C95.6106 65.8577 96.3041 65.693 96.9442 65.3934L97.3483 67.459C96.5167 67.8339 95.6137 68.0182 94.7037 67.9986C91.655 67.9986 89.8919 66.0819 89.8919 63.0486C89.8919 60.0341 91.6367 58.0429 94.5567 58.0429C95.5088 58.0382 96.452 58.2281 97.3299 58.6012ZM102.902 67.9986C100.717 67.9986 99.5964 66.789 99.5964 64.3326V60.3132H98.3475V58.2662H99.5964V56.2006L102.131 56.0704V58.2662H104.169V60.3132H102.131V64.2955C102.131 65.3748 102.572 65.8772 103.398 65.8772C103.726 65.8764 104.052 65.8389 104.371 65.7656L104.5 67.8312C103.975 67.9489 103.439 68.005 102.902 67.9986Z" fill="#1E3764"/>
<path d="M33.1782 48.2362C46.5891 48.2362 53.2945 39.1772 60 30.1181H15.5V48.2362H33.1782Z" fill="url(#paint0_linear_823_627)"/>
<path d="M86.8218 12C73.4109 12 66.7054 21.059 60 30.1181H104.5V12H86.8218Z" fill="url(#paint1_linear_823_627)"/>
<defs>
<linearGradient id="paint0_linear_823_627" x1="24.5009" y1="40.0136" x2="57.1574" y2="28.0426" gradientUnits="userSpaceOnUse">
<stop stop-color="#005AB9"/>
<stop offset="1" stop-color="#1E3764"/>
</linearGradient>
<linearGradient id="paint1_linear_823_627" x1="62.6944" y1="31.6895" x2="97.3534" y2="20.0193" gradientUnits="userSpaceOnUse">
<stop stop-color="#FBA900"/>
<stop offset="1" stop-color="#FFD800"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,8 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path d="M57.5437 26.997V35.9796H63.0833C64.4033 35.9796 65.4945 35.5352 66.3569 34.6486C67.2435 33.7642 67.6879 32.7082 67.6879 31.4872C67.6879 30.2904 67.2435 29.2476 66.3569 28.3588C65.4945 27.4502 64.4033 26.9948 63.0833 26.9948H57.5437V26.997ZM57.5437 39.141V49.5602H54.2349V23.8356H63.0129C65.2415 23.8356 67.1335 24.577 68.6933 26.062C70.2773 27.547 71.0693 29.3554 71.0693 31.4872C71.0693 33.6674 70.2773 35.489 68.6933 36.9476C67.1599 38.4106 65.2635 39.1388 63.0107 39.1388H57.5437V39.141ZM74.4133 44.1724C74.4133 45.0348 74.7785 45.752 75.5111 46.3284C76.2415 46.9004 77.0995 47.1886 78.0807 47.1886C79.4733 47.1886 80.7119 46.6738 81.8031 45.6464C82.8965 44.6146 83.4399 43.4046 83.4399 42.0164C82.4081 41.2024 80.9693 40.7954 79.1235 40.7954C77.7815 40.7954 76.6595 41.121 75.7619 41.7678C74.8621 42.4146 74.4133 43.2132 74.4133 44.1724ZM78.6945 31.3794C81.1409 31.3794 83.0703 32.0328 84.4871 33.3374C85.8995 34.6442 86.6079 36.435 86.6079 38.7098V49.5602H83.4421V47.1182H83.2991C81.9307 49.129 80.1091 50.1366 77.8299 50.1366C75.8895 50.1366 74.2637 49.5602 72.9569 48.4118C71.6501 47.2612 70.9967 45.8246 70.9967 44.0998C70.9967 42.2782 71.6853 40.8306 73.0647 39.7526C74.4441 38.6746 76.2833 38.1356 78.5867 38.1356C80.5491 38.1356 82.1705 38.4942 83.4399 39.2136V38.4568C83.4399 37.3084 82.9845 36.3316 82.0737 35.5308C81.1961 34.7411 80.0531 34.3114 78.8727 34.3274C77.0247 34.3274 75.5639 35.104 74.4837 36.6638L71.5709 34.829C73.1769 32.53 75.5529 31.3794 78.6945 31.3794ZM104.771 31.9558L93.7271 57.3218H90.3105L94.4113 48.447L87.1469 31.9558H90.7439L95.9953 44.6036H96.0657L101.174 31.9536L104.771 31.9558Z" fill="#3C4043"/>
<path d="M44.1722 36.8948C44.1722 35.8542 44.0842 34.8488 43.917 33.8896H29.9602V39.5832H37.955C37.6239 41.4215 36.5557 43.0445 34.9982 44.0756V47.7716H39.77C42.564 45.1976 44.1722 41.3916 44.1722 36.8948Z" fill="#4285F4"/>
<path d="M29.9603 51.34C33.9555 51.34 37.3171 50.031 39.7701 47.7738L34.9983 44.0756C33.6717 44.9688 31.9623 45.4902 29.9603 45.4902C26.1015 45.4902 22.8235 42.8898 21.6531 39.3874H16.7383V43.1956C19.2527 48.1909 24.3678 51.3425 29.9603 51.3422" fill="#34A853"/>
<path d="M21.6529 39.3874C21.0355 37.5518 21.0355 35.5646 21.6529 33.729V29.9208H16.7381C15.6991 31.9782 15.1587 34.2512 15.1606 36.556C15.1606 38.943 15.7327 41.198 16.7381 43.1934L21.6529 39.3852V39.3874Z" fill="#FABB05"/>
<path d="M29.96 27.6262C32.1424 27.6262 34.096 28.3742 35.636 29.8438V29.846L39.86 25.6264C37.2992 23.2416 33.9552 21.7764 29.9622 21.7764C24.3703 21.7757 19.2553 24.9264 16.7402 29.9208L21.655 33.729C22.8254 30.2266 26.1034 27.6262 29.9622 27.6262" fill="#E94235"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,11 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.6001 12.7443V67.2557C23.6001 69.8651 25.7406 72 28.3567 72H61.0109C85.6977 72 96.4001 58.2179 96.4001 39.9288C96.4001 21.7346 85.6977 8 61.0109 8H28.3567C25.7406 8 23.6001 10.1349 23.6001 12.7443Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.2002 21.4283V61.6H62.5782C78.3572 61.6 85.2002 52.6335 85.2002 39.9527C85.2002 27.816 78.3572 18.4 62.5782 18.4H48.2102C46.5406 18.4 45.2002 19.7722 45.2002 21.4283Z" fill="#CC0066"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M60.6001 12C87.5763 12 91.6001 29.2633 91.6001 39.5408C91.6001 57.3725 80.5763 67.2 60.6001 67.2H32.1715C29.6477 67.2 27.6001 65.1637 27.6001 62.6533V16.5467C27.6001 14.0366 29.6477 12 32.1715 12H60.6001ZM60.6001 13.5156H32.1715C30.4811 13.5156 29.1239 14.8654 29.1239 16.5467V62.6533C29.1239 64.3349 30.4811 65.6844 32.1715 65.6844H60.6001C79.6001 65.6844 90.0763 56.4018 90.0763 39.5408C90.0763 16.9019 71.6001 13.5156 60.6001 13.5156Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.8526 34.4V34.4234C52.4917 34.4234 53.0817 34.5183 53.647 34.7074C54.2124 34.8964 54.6794 35.204 55.0973 35.5823C55.4906 35.9843 55.8102 36.4809 56.056 37.0484C56.2772 37.6395 56.4001 38.325 56.4001 39.1292C56.4001 39.8386 56.3018 40.477 56.1297 41.0682C55.9331 41.6593 55.6627 42.1793 55.294 42.6052C54.9252 43.0308 54.4582 43.3616 53.8928 43.6219C53.3275 43.8584 52.6638 44 51.9018 44H47.6001V34.4H51.8526ZM51.3364 36.2208H49.7632V42.2741H51.7051V42.2505C52.0247 42.2505 52.3196 42.2032 52.6392 42.1084C52.9342 42.014 53.2046 41.8485 53.4258 41.612C53.647 41.3756 53.8437 41.0918 53.9912 40.7135C54.1386 40.3349 54.2124 39.9095 54.2124 39.3657C54.2124 38.8927 54.1632 38.4432 54.0649 38.0651C53.9666 37.6868 53.7945 37.3321 53.5733 37.072C53.3521 36.8119 53.0571 36.5754 52.6884 36.4333C52.3197 36.2917 51.8772 36.2208 51.3364 36.2208Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M71.9338 34.4L75.6001 44H73.3569L72.6094 41.8719H68.9431L68.171 44H66.0001L69.6905 34.4H71.9338ZM70.8242 36.7646H70.8003L69.5217 40.2877H72.0544L70.8242 36.7646Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.2832 36.1971H59.2806V38.2542H63.8858V39.8857H59.2806V42.2266H64.4001V44H57.2001V34.4H64.2832V36.1971Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M78.6327 34.4V42.2267H83.6001V44H76.4001V34.4H78.6327Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.2002 46.4C38.9579 46.4 40.4002 47.8826 40.4002 49.7414V61.6H40.3777C36.8396 61.6 34.0002 58.6118 34.0002 54.9413V49.7414C34.0002 47.9061 35.4199 46.4 37.2002 46.4ZM37.6002 35.2C40.0303 35.2 42.0002 37.1701 42.0002 39.6C42.0002 42.0301 40.0303 44 37.6002 44C35.1701 44 33.2002 42.0301 33.2002 39.6C33.2002 37.1701 35.1701 35.2 37.6002 35.2Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,7 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5288 54.6562V53.7384H97.289L97.0137 54.3698L96.7378 53.7384H96.498V54.6562H96.6675V53.9637L96.9257 54.5609H97.1011L97.36 53.9624V54.6562H97.5288ZM96.0111 54.6562V53.8947H96.318V53.7397H95.5361V53.8947H95.843V54.6562H96.0111Z" fill="#F79E1B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6521 58.595H70.3479V21.4044H49.6521V58.595Z" fill="#FF5F00"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.2675 40.0003C98.2675 53.063 87.6791 63.652 74.6171 63.652C69.0996 63.652 64.0229 61.7624 60 58.5956C65.5011 54.2646 69.0339 47.5448 69.0339 40.0003C69.0339 32.4552 65.5011 25.7354 60 21.4044C64.0229 18.2376 69.0996 16.348 74.6171 16.348C87.6791 16.348 98.2675 26.937 98.2675 40.0003Z" fill="#F79E1B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.966 40.0003C50.966 32.4552 54.4988 25.7354 59.9999 21.4044C55.977 18.2376 50.9003 16.348 45.3828 16.348C32.3208 16.348 21.7324 26.937 21.7324 40.0003C21.7324 53.063 32.3208 63.652 45.3828 63.652C50.9003 63.652 55.977 61.7624 59.9999 58.5956C54.4988 54.2646 50.966 47.5448 50.966 40.0003Z" fill="#EB001B"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,22 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.7961 51.0291L61.7626 52.2779H57.8379V46.1184H59.2417V51.0291H61.7961Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M65.3571 50.1424C65.2336 50.0933 64.9856 50.0354 64.6468 50.0354C64.0768 50.0354 63.8121 50.2573 63.8121 50.6922C63.8121 51.1772 64.1015 51.3499 64.5242 51.3499C64.9609 51.3499 65.3571 51.0868 65.3571 50.7668V50.1424ZM65.5486 51.8015L65.523 51.7928C65.2177 52.1541 64.7386 52.3601 64.1668 52.3601C63.2174 52.3601 62.6147 51.8261 62.6147 50.7089C62.6147 49.789 63.2589 49.1567 64.3989 49.1567C64.7703 49.1567 65.0924 49.2146 65.3571 49.2962V49.0094C65.3571 48.5078 65.0924 48.2781 64.4077 48.2781C63.8545 48.2781 63.4909 48.3605 63.0268 48.549L62.9192 47.5309C63.4247 47.3169 63.9939 47.2021 64.6389 47.2021C66.1177 47.2021 66.6303 47.8343 66.6303 49.1979V52.2777H65.6465L65.5486 51.8015Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M69.4 52.3767C68.7956 52.3767 68.2503 52.2618 67.8294 52.0321L67.9609 50.9806C68.3756 51.2183 68.9624 51.3332 69.3912 51.3332C69.9038 51.3332 70.1183 51.128 70.1183 50.8324C70.1183 50.0353 67.7968 50.4957 67.7968 48.755C67.7968 47.8597 68.4083 47.2186 69.6974 47.2186C70.2003 47.2186 70.698 47.3177 71.118 47.4896L71.0112 48.5323C70.5974 48.3604 70.0441 48.278 69.6727 48.278C69.2518 48.278 69.0188 48.4428 69.0188 48.6971C69.0188 49.4522 71.3085 49.0006 71.3085 50.7176C71.3085 51.7111 70.7544 52.3767 69.4 52.3767Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M73.8302 48.2537V50.7994C73.8302 51.1861 74.0287 51.3501 74.4249 51.3501C74.5573 51.3501 74.7302 51.3255 74.822 51.2922L74.8961 52.22C74.7223 52.2945 74.3923 52.3603 74.0358 52.3603C73.037 52.3603 72.549 51.785 72.549 50.8406V48.2537H71.987V47.3013H72.6064L72.8208 46.1754L73.8302 46.085V47.3013H74.9464V48.2537H73.8302Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.9976 52.2778V48.2457H97.3693V47.3013H97.9976V47.0873C97.9976 46.3401 98.5182 45.7245 99.7155 45.7245C99.9979 45.7245 100.31 45.7649 100.526 45.8307L100.451 46.6523C100.302 46.619 100.138 46.6032 99.9714 46.6032C99.4693 46.6032 99.2779 46.7997 99.2779 47.1206V47.3013H100.344V48.2457H99.2779V52.2778H97.9976Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M94.8401 52.2778V47.3337L96.154 47.2758V52.2778H94.8401ZM95.501 46.7672C95.0051 46.7672 94.766 46.6269 94.766 46.2253C94.766 45.8719 95.0051 45.6746 95.501 45.6746C96.0057 45.6746 96.2281 45.864 96.2281 46.2253C96.2281 46.5866 96.0057 46.7672 95.501 46.7672Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M90.5345 52.2778V47.3337L91.5581 47.2846L91.6745 48.1142H91.6983C92.0133 47.5143 92.5013 47.2188 93.0713 47.2188C93.2945 47.2188 93.4842 47.2521 93.6157 47.2925L93.5328 48.5491C93.3775 48.5 93.1869 48.4667 92.9875 48.4667C92.3186 48.4667 91.8236 48.9516 91.8236 49.7812V52.2778H90.5345Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M87.9053 52.2778V49.1077C87.9053 48.5982 87.6494 48.3351 87.1041 48.3351C86.7318 48.3351 86.4097 48.5 86.2527 48.7131V52.2778H84.9715V45.8885L86.2527 45.8228V46.9557L86.2359 47.6126L86.2606 47.6301C86.633 47.317 87.0856 47.2022 87.5091 47.2022C88.5662 47.2022 89.1856 47.909 89.1856 49.1077V52.2778H87.9053Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M83.6814 48.5161C83.4256 48.4258 83.1194 48.36 82.83 48.36C81.8973 48.36 81.5903 48.7459 81.5903 49.7973C81.5903 50.8654 82.0288 51.2837 82.8053 51.2837C83.1361 51.2837 83.4503 51.2179 83.715 51.0952L83.8067 52.0887C83.5094 52.2694 83.062 52.3764 82.5247 52.3764C81.0458 52.3764 80.2597 51.538 80.2597 49.7973C80.2597 48.1461 80.9461 47.2183 82.5494 47.2183C82.9703 47.2183 83.4423 47.3086 83.7556 47.4323L83.6814 48.5161Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M77.4249 52.3767C76.8213 52.3767 76.276 52.2618 75.8543 52.0321L75.9866 50.9806C76.4005 51.2183 76.9872 51.3332 77.4169 51.3332C77.9296 51.3332 78.144 51.128 78.144 50.8324C78.144 50.0353 75.8225 50.4957 75.8225 48.755C75.8225 47.8597 76.4331 47.2186 77.7231 47.2186C78.226 47.2186 78.7228 47.3177 79.1437 47.4896L79.0369 48.5323C78.6231 48.3604 78.0699 48.278 77.6984 48.278C77.2766 48.278 77.0446 48.4428 77.0446 48.6971C77.0446 49.4522 79.3343 49.0006 79.3343 50.7176C79.3343 51.7111 78.7802 52.3767 77.4249 52.3767Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M102.973 48.2537V50.7994C102.973 51.1861 103.171 51.3501 103.567 51.3501C103.699 51.3501 103.873 51.3255 103.964 51.2922L104.038 52.22C103.865 52.2945 103.535 52.3603 103.178 52.3603C102.179 52.3603 101.691 51.785 101.691 50.8406V48.2537H101.128V47.3013H101.748L101.963 46.1754L102.973 46.085V47.3013H104.088V48.2537H102.973Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.9028 42.6681C60.3543 42.6681 58.9311 42.2971 57.7734 41.678L58.1652 39.113C59.3405 39.7321 60.5502 40.0864 61.8667 40.0864C63.3261 40.0864 63.9676 39.5559 63.9676 38.636C63.9676 36.5489 57.8273 37.4328 57.8273 32.993C57.8273 30.7472 59.2867 28.9249 62.6679 28.9249C63.9676 28.9249 65.3731 29.1906 66.4055 29.6151L66.0323 32.1266C64.9284 31.7732 63.8255 31.561 62.8108 31.561C61.1555 31.561 60.7108 32.1266 60.7108 32.8342C60.7108 34.9038 66.8502 33.9488 66.8502 38.4413C66.8502 41.0589 65.0167 42.6681 61.9028 42.6681Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M78.421 39.8206V42.4207H69.3451V29.1555H78.421V31.7906H72.3531V34.4258H77.2819V36.7961H72.3531V39.8206H78.421Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M85.8635 31.6491H84.0829V35.7171H85.8635C87.3406 35.7171 87.9803 35.3462 87.9803 33.6835C87.9803 32.0911 87.4121 31.6491 85.8635 31.6491ZM85.8635 38.105H84.0829V42.4212H81.0759V29.1551H85.8635C89.565 29.1551 91.0774 30.6055 91.0774 33.7011C91.0774 36.4958 89.5835 38.105 85.8635 38.105Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.4702 32.7984L96.1352 37.3443H99.5349L98.2528 32.7984L97.8972 31.4014H97.8434L97.4702 32.7984ZM100.246 39.8383H95.4055L94.6581 42.4209H91.4905L96.0999 29.1556H99.6769L104.339 42.4209H100.993L100.246 39.8383Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.6441 39.6275C29.6018 39.6836 29.5374 39.7117 29.4712 39.7257C29.3971 39.7345 29.3203 39.7178 29.2568 39.6705L25.8235 37.1072L21.2291 33.6776L17.795 31.1135C17.7324 31.0661 17.6953 30.9977 17.6821 30.924C17.6777 30.8574 17.6874 30.7881 17.7297 30.732L18.6659 29.4929C18.6659 29.4929 19.6938 28.1714 20.5965 26.9779L24.4603 29.863L28.6462 32.9883L32.5109 35.8734C31.6082 37.0669 30.5803 38.3884 30.5803 38.3884L29.6441 39.6275ZM23.1862 24.0034L26.5991 26.5526L28.9065 28.2749L31.2121 29.998L34.6268 32.5455C34.7582 32.6428 34.7856 32.8261 34.6885 32.9541L33.8697 34.0748L30.0059 31.1897L25.82 28.0644L21.9562 25.1793L22.7741 24.0586C22.8703 23.9306 23.0556 23.906 23.1862 24.0034ZM35.0177 32.0491L29.2868 27.7715L23.5568 23.4921C23.1571 23.1948 22.5906 23.2738 22.2915 23.6693L17.2471 30.3435C17.1668 30.4479 17.1191 30.5636 17.0909 30.6838C17.0044 31.024 17.1244 31.4002 17.4235 31.6247L20.8391 34.1739L25.453 37.6185L28.8668 40.1668C29.1668 40.3913 29.5638 40.4009 29.8682 40.2221C29.9768 40.1633 30.0747 40.0844 30.1541 39.98L35.1985 33.3066C35.4977 32.9111 35.4165 32.3473 35.0177 32.0491Z" fill="#A4AAB6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4587 56.1126L20.484 42.8281H31.3907L30.451 44.0786L29.0975 45.9368L28.944 46.2113L28.8019 46.5305L28.6563 46.89L28.5046 47.2715L28.359 47.6871L28.2152 48.0958L28.0802 48.507L27.9469 48.9104L27.8269 49.2928L27.7166 49.6453L27.6187 49.9531L27.5446 50.21L27.4872 50.4056L27.4396 50.5275C27.4396 50.5275 27.4316 50.5538 27.4078 50.5854C27.3805 50.6231 27.2155 50.773 27.0813 50.6739C26.9525 50.5783 26.9657 50.2416 26.979 50.0811C27.0196 49.5892 27.0593 49.0972 27.0999 48.6061C27.1122 48.4334 26.9004 48.336 26.7637 48.4141C26.161 48.7587 25.8019 49.1761 25.4728 49.6532C25.5037 49.5418 25.5346 49.4296 25.5646 49.3182C25.704 48.8113 25.899 48.2869 25.974 47.766C26.0163 47.4688 25.9405 47.1154 25.591 47.0584C25.1975 46.9926 24.9399 47.4039 24.7475 47.6748C24.1643 48.4921 23.821 49.5304 23.0975 50.2416C22.8249 50.5091 22.5231 50.7064 22.1322 50.5959C21.6778 50.4678 21.5013 50.9984 22.3025 51.0703C23.0905 51.1404 23.6763 50.3135 24.0337 49.7356C24.2931 49.3156 24.5216 48.8771 24.774 48.4536C24.8957 48.2501 25.0219 48.0493 25.1613 47.8572C25.2222 47.7757 25.336 47.5565 25.4419 47.5275C25.5813 47.4889 25.5452 47.5687 25.5372 47.6784C25.5187 47.9256 25.4128 48.18 25.3475 48.4185C25.2769 48.6789 25.2054 48.9394 25.134 49.1998C24.9813 49.7584 24.8296 50.317 24.6769 50.8747C24.6081 51.1282 24.9434 51.2457 25.0846 51.0457C25.6457 50.2512 25.9255 49.5778 26.6172 49.0464C26.6022 49.2209 26.5881 49.3954 26.574 49.5708C26.551 49.8593 26.4955 50.1644 26.5149 50.4529C26.5484 50.9247 26.9066 51.2518 27.3575 51.0457C27.5931 50.937 27.7625 50.773 27.7625 50.773L27.789 50.7537L27.8843 50.6827L28.0581 50.5687L28.2937 50.4073L28.5557 50.2127L28.8452 50.0092L29.1857 49.7716L29.5316 49.5181L29.8766 49.2656L30.2225 49.0122L30.5587 48.7578L30.8746 48.5202L31.1657 48.2843L31.4207 48.0879L31.6237 47.9002L31.7816 47.7494L31.7878 47.7406L31.7913 47.7441L32.8669 46.4814L35.6154 42.8281H40.4949L44.2907 56.1126H16.4587ZM36.0487 42.2528L44.6922 30.7651L45.5737 29.6102C45.6646 29.4866 45.6513 29.3735 45.5649 29.3112L43.1181 27.5495C43.0325 27.4872 42.9205 27.5118 42.8569 27.5968C42.851 27.605 42.7319 27.7567 42.4996 28.0519L31.8231 42.2528H20.0657L15.6602 56.6879H45.0769L40.921 42.2528H36.0487Z" fill="#005DA1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M46.6821 30.1526L46.3592 29.9114C46.2947 29.8632 46.2021 29.8763 46.1536 29.9404L42.0453 35.3738C41.9959 35.4369 42.01 35.529 42.0736 35.5772L42.3974 35.8184C42.4618 35.8666 42.5527 35.8534 42.6021 35.7894L46.7112 30.356C46.7598 30.292 46.7465 30.2008 46.6821 30.1526Z" fill="#0062A5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.178 53.2862H38.353L38.1792 52.6426H30.178H22.1768L22.0039 53.2862H30.178Z" fill="#005DA1"/>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,7 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M86.6666 44.9375L90.3239 35.0625L92.3809 44.9375H86.6666ZM100.952 52.8375L95.8086 27.1625H88.7383C86.3525 27.1625 85.7723 29.0759 85.7723 29.0759L76.1904 52.8375H82.8868L84.2269 49.0244H92.3947L93.1479 52.8375H100.952Z" fill="#1434CB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M77.1866 33.5711L78.0952 28.244C78.0952 28.244 75.2896 27.1625 72.3648 27.1625C69.2031 27.1625 61.6955 28.5638 61.6955 35.3738C61.6955 41.7825 70.5071 41.8621 70.5071 45.2266C70.5071 48.5912 62.6034 47.9901 59.9955 45.8676L59.0476 51.4362C59.0476 51.4362 61.8919 52.8375 66.2397 52.8375C70.5869 52.8375 77.1467 50.5544 77.1467 44.3455C77.1467 37.8964 68.2552 37.296 68.2552 34.4921C68.2552 31.6882 74.4602 32.0484 77.1866 33.5711Z" fill="#1434CB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.6517 52.8375H47.6191L52.0144 27.1625H59.0477L54.6517 52.8375Z" fill="#1434CB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M42.3113 27.1625L35.9217 44.8213L35.1663 41.0185L35.167 41.0199L32.9114 29.4749C32.9114 29.4749 32.6394 27.1625 29.7324 27.1625H19.1709L19.0476 27.5966C19.0476 27.5966 22.2782 28.2669 26.057 30.5326L31.8793 52.8375H38.8617L49.5238 27.1625H42.3113Z" fill="#1434CB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB