Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
NUXT_HUB_APPLICATION_NAME="NuxtHubLanding"
NUXT_HUB_PROJECT_KEY="<your_nuxt_hub_project_key>"
NUXT_RESEND_API_KEY="<your_resend_api_key>"
NUXT_HUB_LANDING_EMAIL="<your_resend_email>
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,43 @@ Start the development server on `http://localhost:3000`:
npm run dev
```

## Double Opt-in Email Verification

### Overview
This feature enables email verification through a double opt-in process using Resend as the email service provider.

### Configuration

1. Generate your secret key:
```bash
npm run generate:secret
```

2. Add required environment variables to `.env`:
```
NUXT_RESEND_API_KEY=your_resend_api_key_here
# SECRET_KEY will be auto-generated by the script above
```

#### Nuxt Config
Enable email verification in your `nuxt.config.ts`:

```ts
export default defineNuxtConfig({
nuxtHubLanding: {
verifyEmail: true,
email: 'your_resend_email@domain.com'
}
})
```

#### Provider
Currently using [Resend](https://resend.com) as the email service provider. You'll need to:
1. Create a Resend account
2. Generate an API key
3. Verify your sending domain


## Deploy

Deploy the application to NuxtHub
Expand All @@ -63,7 +100,8 @@ Check out the [deployment documentation](https://hub.nuxt.com/docs/getting-start

## Roadmap
- [X] Add rate limiting via [NuxtSecurity](https://nuxt-security.vercel.app/documentation/middleware/rate-limiter)
- [ ] Add Double-Opt-in
- [X] Add Double-Opt-in
- [ ] Add [NuxtSeo](https://nuxtseo.com/nuxt-seo/getting-started/what-is-nuxt-seo) module
- [ ] Add more basic components
- [ ] Faq-Area
- [ ] Pricing-Area
Expand Down
235 changes: 8 additions & 227 deletions app.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
<script setup lang="ts">
import {$fetch} from "ofetch";
import Card from "~/components/ui/cards/Card.vue";
import TestimonialCard from "~/components/ui/cards/TestimonialCard.vue";
import ContentBlock from "~/components/ui/content/ContentBlock.vue";
import Button from "~/components/ui/buttons/Button.vue";

const email = ref('')
const joiningWaitlist = ref(false)
const success = ref(false)
const errors = ref<{ code: string, message: string, path: string [], validation: string }[]>([])

useHead({
bodyAttrs: {
class: 'bg-black'
class: 'bg-black',
}
})

Expand All @@ -27,228 +17,19 @@ useSeoMeta({
twitterCreator: '@lowbits_',
})

const joinWaitlist = async () => {
joiningWaitlist.value = true
errors.value = []

try {
await $fetch('api/join-waitlist', {
method: 'POST', body: {
email: email.value,
}
})

email.value = ''
success.value = true

setTimeout(() => {
success.value = false
}, 5000)
} catch (e) {

const currentYear = computed(() => new Date().getFullYear())

switch (e.response.status) {
case 400: {
errors.value = e.data.data.issues
break;
}
case 429: {
errors.value = [{path: ['server'], message: 'You’ve made too many requests in a short period. Please wait a moment and try again.'}]
break;
}
}
} finally {
joiningWaitlist.value = false
}
}

const currentYear = computed(() => new Date().getFullYear())
</script>
<template>
<div class="px-6 xl:px-0">
<header class="sticky top-0">
<nav class="flex justify-end max-w-6xl mx-auto py-2">
<div>
<a href="https://github.com/lowbits/nuxt-hub-landing" target="_blank" rel="noreferrer"><img
class="size-5"
src="/assets/logos/github-mark-white.svg" alt="Github Logo"></a>
</div>
</nav>
</header>
<div class="mt-20 max-w-6xl mx-auto">
<div class="flex flex-col md:flex-row md:justify-between">
<div class="max-w-lg">
<span class="px-1.5 py-1 text-xs bg-[#D65320] bg-opacity-80 text-gray-300">
💡 Validate Your Idea's
</span>
<h1 class="mt-1.5 text-white text-5xl tracking-tight">
Setup your <span
class="inline bg-gradient-to-r from-[#D65320] to-[#C33E59] bg-clip-text font-display text-transparent">Landing Page</span>
in minutes</h1>

<p class="mt-4 text-sm/6 text-gray-300">
Save time using the <strong class="text-[#D65320]">#NuxtHubLanding</strong> boilerplate, to verify your
ideas. Deploy it for
free on NuxtHub, collect leads for your next big thing.
</p>
<div class="flex flex-col min-h-screen">
<NuxtPage class="flex-1"/>

<p class="text-white font-semibold text-sm mt-2">Clone it, customize it, commercialize it.</p>

<div class="mt-20">
<form class="relative max-w-md overflow-hidden" @submit.prevent="joinWaitlist">
<div>
<div class="flex items-center gap-2 justify-between px-4 py-1.5 border border-[#D65320] rounded-lg">
<label for="email" class="sr-only">E-Mail</label>
<input
class="px-2 md:px-4 py-1 md:py-2 flex-1 bg-transparent text-white transition-colors hover:bg-zinc-900 ease-linear duration-300 rounded"
v-model="email" id="email" required
type="text" placeholder="E-Mail Address"/>
<Button
:loading="joiningWaitlist"
type="submit">Join
Waitlist
</Button>
</div>
<p class="text-base/6 text-red-500 data-[disabled]:opacity-50 sm:text-sm/6"
v-for="error in errors">{{ error.path.join(',') }}: {{ error.message }}</p>
</div>

<Transition
enter-from-class="opacity-0 translate-y-full"
enter-active-class="transition ease-in-out duration-150"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-active-class="transition -translate-y-full ease-out duration-150"
leave-to-class="opacity-0"
>
<div v-if="success"
class="absolute border inset-0 border-[#D65320] bg-[#D65320] flex items-center justify-center">
<p class="text-center text-white">
🫡 We'll keep you posted!
</p>
</div>
</Transition>

</form>
</div>
</div>

<div class="mt-32 relative mx-auto w-[200px] md:mx-0 md:w-[100px]">
<p class="bg-zinc-900 leading-none absolute -top-10 -left-2 px-1.5 py-1 text-xs text-gray-300">
Build with
</p>
<ul class="space-y-5">
<li><a href="https://nuxt.com/" target="_blank" rel="noreferrer" title="Build with nuxtjs"><img
src="~/assets/logos/nuxt-logo-white.svg" alt="Nuxtjs Logo"></a>
</li>

<li><a href="https://tailwindcss.com/" target="_blank" rel="noreferrer" title="Build with tailwindcss"><img
src="~/assets/logos/tailwindcss-logo-white.svg" alt="Tailwindcss Logo"></a>
</li>

<li><a href="https://hub.nuxt.com/" target="_blank" rel="noreferrer" title="Build with nuxt hub"><img
src="~/assets/logos/nuxt-hub-logo-white.svg" alt="Nuxthub Logo"></a>
</li>

</ul>
</div>
<footer class="bg-zinc-900">
<div class="px-6 xl:px-0 max-w-6xl mx-auto text-zinc-500 py-3">
<a href="" class="text-[#D65320]">© {{ currentYear }} NuxtHubLanding</a>
</div>

<ContentBlock class="mt-32" anchor="pricing">
<template #term>
Features
</template>

<template #headline>
Focus on the part that matters...
</template>

<template #description>
Verify your idea before jumping into implementation — it saves time and gray hairs.
</template>


<Card headline="Customizable" description="It's your code, you can adjust it specific to your needs.">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 13.5V3.75m0 9.75a1.5 1.5 0 0 1 0 3m0-3a1.5 1.5 0 0 0 0 3m0 3.75V16.5m12-3V3.75m0 9.75a1.5 1.5 0 0 1 0 3m0-3a1.5 1.5 0 0 0 0 3m0 3.75V16.5m-6-9V3.75m0 3.75a1.5 1.5 0 0 1 0 3m0-3a1.5 1.5 0 0 0 0 3m0 9.75V10.5"/>
</svg>
</template>
</Card>

<Card headline="SEO" description="SEO always sucks, we added all necessary SEO tags so you dont have
to. Replace them with yours.">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/>
</svg>
</template>
</Card>


<Card headline="Deploy" description="Deploy your landing page in minutes, collect emails to verify your
idea.">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.631 8.41m5.96 5.96a14.926 14.926 0 0 1-5.841 2.58m-.119-8.54a6 6 0 0 0-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 0 0-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 0 1-2.448-2.448 14.9 14.9 0 0 1 .06-.312m-2.24 2.39a4.493 4.493 0 0 0-1.757 4.306 4.493 4.493 0 0 0 4.306-1.758M16.5 9a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z"/>
</svg>
</template>
</Card>
</ContentBlock>


<ContentBlock class="mt-32" anchor="pricing">
<template #term>
Pricing
</template>

<template #headline>
Sometimes all you need is a little push...
</template>

<template #description>
Our template is free to use, we'd love to read from you on <a href="">x.com</a>.
</template>
</ContentBlock>

<ContentBlock class="mt-32" anchor="build-with-nuxthublanding" alignment="center">
<template #term>
Build with <strong>#NuxtHubLanding</strong>
</template>

<template #headline>
Launch Fast. Validate Faster.
</template>

<template #description>
Hear firsthand how our boilerplate has empowered startups and indiepreneurs to quickly create landing pages,
validate their ideas, and focus on building their core products.
</template>

<TestimonialCard name="evoize.me"
description="Our team of five set out to build an open-source alternative to SevDesk for freelancers. To gauge interest, we used NuxtHubLanding to quickly set up a professional landing page, allowing us to focus on understanding our audience and refining our idea."
:author="{fullName: 'Christian H.', role: 'CTO', avatar: ''}"/>
<TestimonialCard name="cardict"
description="We focused on building our React Native app and needed a quick, cost-effective landing page solution. The NuxtHubLanding boilerplate let us launch a professional, responsive site in just two hours, saving us development time and making a strong first impression."
:author="{fullName: 'Raul L.', role: 'Co-Founder', avatar: ''}"/>
<TestimonialCard name="suddy.me"
description="Plentiful App ideas, creating landing pages was time-consuming and expensive. This challenge led me to develop NuxtHubLanding, a quick and cost-effective solution for launching professional sites. It allows me to focus on building apps without worrying about landing page setup."
:author="{fullName: 'Tobias L.', role: 'Founder', avatar: ''}"/>


</ContentBlock>
</div>
</footer>
</div>

<footer class="mt-64 bg-zinc-900">
<div class="px-6 xl:px-0 max-w-6xl mx-auto text-zinc-500 py-3">
<a href="" class="text-[#D65320]">© {{ currentYear }} NuxtHubLanding</a>
</div>
</footer>
</template>
Binary file added assets/images/rlugo_profile_square.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/images/tobias-transformed.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion components/ui/cards/TestimonialCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ defineProps<{
<Card :headline="name" :description="description">
<template #footer>
<div class="flex gap-4">
<div class="w-12 h-12 bg-zinc-700 rounded-full"></div>
<div class="w-12 h-12 bg-zinc-700 rounded-full overflow-hidden">
<img :src="`${author.avatar}`" alt=""/>
</div>
<div class="text-zinc-300 text-base leading-5">
<p class="font-semibold">{{ author.fullName }}</p>
<p>{{ author.role }}</p>
Expand Down
63 changes: 63 additions & 0 deletions emails/VerifyEmail.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<script setup lang="ts">
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Link,
Section,
Tailwind,
Text,
Preview
} from '@vue-email/components'


defineProps<{ email: string, appName: string, link: string }>()
</script>

<template>
<Tailwind>
<Html>
<Head/>
<Preview>Verify your email address</Preview>
<Body class="bg-white my-auto mx-auto font-sans">
<Container class="border border-solid border-[#eaeaea] p-[20px] md:p-7 rounded my-[40px] mx-auto max-w-[465px]">
<Section class="mt-[32px] text-center">
{{ appName }}
</Section>
<Heading class="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
<strong>You're almost there</strong>
</Heading>
<Text class="text-black text-[14px] leading-[24px]">
To finish signing up, just click the button below to verify that you're a part of {{ appName }}.
</Text>


<Section class="text-center mt-[32px] mb-[32px]">
<Button :style="{
padding: '10px 20px'
}" class="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center" :href="link">
Verify my email
</Button>
</Section>
<Text class="text-black text-[14px] leading-[24px]">
or copy and paste this URL into your browser:
<Link :href="link" class="text-blue-600 no-underline">
{{ link }}
</Link>
</Text>
<Hr class="border border-solid border-[#eaeaea] my-[26px] mx-0 w-full"/>
<Text class="text-[#666666] text-[12px] leading-[24px]">
This invitation was intended for
<span class="text-black">{{ email }} </span>. If you were not expecting this invitation, you can ignore this
mail. If you are concerned about your account's safety, please reply to this mail to get in touch
with us.
</Text>
</Container>
</Body>
</Html>
</Tailwind>
</template>
Loading