Migrate front-end to Nuxt app (#284)
* wip * Managed to load a page * Stuck at changing routes * Fixed the router, and editable div * WIP * Fix app loader * WIP * Fix check-auth middleware * Started to refactor input components * WIP * Added select input, v-click-outside for vselect * update vselect & phone input * Fixed the mixin * input component updates * Fix signature input import * input component updates in vue3 * image input in vue3 * small fixes * fix useFormInput watcher * scale input in vue3 * Vue3: migrating from vuex to Pinia (#249) * Vue3: migrating from vuex to Pinia * toggle input fixes * update configureCompat --------- Co-authored-by: Forms Dev <chirag+new@notionforms.io> * support vue3 query builder * Refactor inpus * fix: Vue3 Query Builder - Logic Editor (#251) * support vue3 query builder * upgrade * remove local from middleware * Submission table pagination & migrate chart to vue3 (#254) * Submission table Pagination in background * migrate chart to vue3 * Form submissions pagination * Form submissions * Fix form starts * Fix openSelect key issue --------- Co-authored-by: Forms Dev <chirag+new@notionforms.io> Co-authored-by: Julien Nahum <julien@nahum.net> * Vue 3 better animation (#257) * vue-3-better-animation * Working on migration to vueuse/motion * Form sidebar animations * Clean code * Added animations for modal * Finished implementing better animations --------- Co-authored-by: Forms Dev <chirag+new@notionforms.io> * Work in progress * Migrating amplitude and crisp plugin/composable * Started to refactor pages * WIP * vue3-scroll-shadow-fixes (#260) * WIP * WIP * WIP * Figured out auth & middlewares * WI * Refactoring stores and templates pages to comp. api * Finishing the templates pages * fix collapsible * Finish reworking most templates pages * Reworked workspaces store * Working on home page and modal * Fix dropdown * Fix modal * Fixed form creation * Fixed most of the form/show pages * Updated cors dependency * fix custom domain warning * NuxtLink migration (#262) Co-authored-by: Forms Dev <chirag+new@notionforms.io> * Tiny fixes + start pre-rendering * migrate-to-nuxt-useappconfig (#263) * migrate-to-nuxt-useappconfig * defineAppConfig --------- Co-authored-by: Forms Dev <chirag+new@notionforms.io> * Working on form/show and editor * Globally import form inputs to fix resolve * Remove vform - working on form public page * Remove initform mixin * Work in progress for form create guess user * Nuxt Migration notifications (#265) * Nuxt Migration notifications * @input to @update:model-value * change field type fixes * @update:model-value * Enable form-block-logic-editor * vue-confetti migration * PR request changes * useAlert in setup * Migrate to nuxt settings page AND remove axios (#266) * Settings pages migration * remove axios and use opnFetch * Make created form reactive (#267) * Remove verify pages and axios lib --------- Co-authored-by: Julien Nahum <julien@nahum.net> * Fix alert styling + bug fixes and cleaning * Refactor notifications + add shadow * Fix vselect issue * Working on page pre-rendering * Created NotionPages store * Added sitemap on nuxt side * Sitemap done, working on aws amplify * Adding missing module * Remove axios and commit backend changes to sitemap * Fix notifications * fix guestpage editor (#269) Co-authored-by: Julien Nahum <julien@nahum.net> * Remove appconfig in favor of runtimeconfig * Fixed amplitude bugs, and added staging environment * Added amplify file * Change basdirectory amplify * Fix loading bar position * Fix custom redirect (#273) * Dirty form handling - nuxt migration (#272) * SEO meta nuxt migration (#274) * SEO meta nuxt migration * Polish seo metas, add defaults for OG and twitter --------- Co-authored-by: Julien Nahum <julien@nahum.net> * migrate to nuxt useClipboard (#268) * Set middleware on pages (#278) * Se middleware on pages * Se middleware on account page * add robots.txt (#276) * 404 page migration (#277) * Templates pages migration (#275) * NuxtImg Migration (#279) Co-authored-by: Julien Nahum <julien@nahum.net> * Update package json * Fix build script * Add loglevel param * Disable page pre-rendering * Attempt to allow svgs * Fix SVGs with NuxtImage * Add .env file at AWS build time * tRGIGGER deploy * Fix issue * ANother attrempt * Fix typo * Fix env? * Attempt to simplify build * Enable swr caching instead of prerenderign * Better image compression * Last attempt at nuxt images efficiency * Improve image optimization again * Remove NuxtImg for non asset files * Restore templates pages cache * Remove useless images + fix templates show page * image optimization caching + fix hydratation issue form template page * URL generation (front&back) + fixed authJWT for SSR * Fix composable issue * Fix form share page * Embeddable form as a nuxt middleware * Fix URL for embeddable middleware * Debugging embeddable on amplify * Add custom domain support * No follow for non-production env * Fix sentry nuxt and custom domain redirect * remove api prefix from routes (#280) * remove api prefix from routes * PR changes --------- Co-authored-by: Julien Nahum <julien@nahum.net> * nuxt migration -file upload - WIP (#271) Co-authored-by: Julien Nahum <julien@nahum.net> * Fix local file upload * Fix file submissions preview * API redirect to back-end from nuxt * API redirect to back-end from nuxt * Remove old JS app, update deploy script * Fix tests, added gh action nuxt step * Updated package-lock.json * Setup node in GH Nuxt action * Setup client directory for GH workflow --------- Co-authored-by: Forms Dev <chirag+new@notionforms.io> Co-authored-by: Chirag Chhatrala <60499540+chiragchhatrala@users.noreply.github.com> Co-authored-by: Rishi Raj Jain <rishi18304@iiitd.ac.in> Co-authored-by: formsdev <136701234+formsdev@users.noreply.github.com>
This commit is contained in:
528
client/pages/ai-form-builder.vue
Normal file
528
client/pages/ai-form-builder.vue
Normal file
@@ -0,0 +1,528 @@
|
||||
<template>
|
||||
<div class="flex-1">
|
||||
<!-- START HERO -->
|
||||
<section class="bg-gradient-to-b relative from-white to-gray-100 py-12 sm:py-16 lg:py-20 xl:py-24">
|
||||
<div class="absolute inset-0">
|
||||
<img class="w-full h-full object-cover object-top" src="/img/pages/ai_form_builder/background-pattern.svg" alt=""/>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto relative">
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-semibold text-gray-900 tracking-tight">
|
||||
Say goodbye to tedious form building with OpnForm's new <span
|
||||
class="bg-clip-text text-transparent bg-gradient-to-r lg:block from-blue-600 to-blue-400">AI-powered
|
||||
feature!</span>
|
||||
</h1>
|
||||
<p class="mt-4 sm:mt-5 text-base leading-7 sm:text-xl sm:leading-9 font-medium text-gray-500">
|
||||
Easily generate a fully working form in seconds with just a simple description.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
<v-button v-if="!authenticated" class="mr-1" :to="{ name: 'forms-create-guest' }" :arrow="true">
|
||||
Get started for free
|
||||
</v-button>
|
||||
<v-button v-else class="mr-1" :to="{ name: 'forms-create' }" :arrow="true">
|
||||
Get started for free
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-6xl mx-auto mt-12 sm:mt-16">
|
||||
<div
|
||||
class="-m-2 rounded-xl bg-blue-900/5 p-2 backdrop-blur-sm ring-1 ring-inset ring-blue-900/10 lg:-m-4 lg:rounded-2xl lg:p-4">
|
||||
<video class="rounded-md ring-1 ring-gray-200 shadow-xl shadow-blue-600/10 ring-blue-900/10" controls autoplay loop muted>
|
||||
<source src="/video/opnform-ai.mp4" type="video/mp4">
|
||||
|
||||
This browser does not display the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid lg:grid-cols-3 grid-cols-1 sm:max-w-lg lg:max-w-5xl sm:mx-auto gap-8 sm:gap-10 mt-12 sm:mt-16">
|
||||
<div class="flex items-start gap-4">
|
||||
<NuxtImg class="w-12 h-12 shrink-0" src="/img/pages/ai_form_builder/icon-fast.svg" alt=""/>
|
||||
|
||||
<div>
|
||||
<p class="text-md font-semibold text-gray-900">
|
||||
Faster than Ever
|
||||
</p>
|
||||
<p class="text-base font-medium text-gray-500 mt-2">
|
||||
Save time and effort by generating a form in seconds
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<NuxtImg class="w-12 h-12 shrink-0" src="/img/pages/ai_form_builder/icon-customization.svg" alt=""/>
|
||||
|
||||
<div>
|
||||
<p class="text-md font-semibold text-gray-900">
|
||||
Customizations
|
||||
</p>
|
||||
<p class="text-base font-medium text-gray-500 mt-2">
|
||||
Customize your form to your exact specifications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<NuxtImg class="w-12 h-12 shrink-0" src="/img/pages/ai_form_builder/icon-browser.svg" alt=""/>
|
||||
|
||||
<div>
|
||||
<p class="text-md font-semibold text-gray-900">
|
||||
No Coding Knowledge Required
|
||||
</p>
|
||||
<p class="text-base font-medium text-gray-500 mt-2">
|
||||
No coding knowledge required and it's completely free to use
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- END HERO -->
|
||||
|
||||
<!-- START HOW IT WORKS -->
|
||||
<section class="bg-white py-12 sm:py-16 lg:py-20 xl:py-24">
|
||||
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div class="text-center max-w-3xl mx-auto">
|
||||
<h2 class="text-sm font-semibold text-blue-600">
|
||||
How Does It Work?
|
||||
</h2>
|
||||
<p
|
||||
class="text-3xl mt-4 sm:text-4xl lg:text-5xl font-semibold text-gray-900 tracking-tight lg:leading-tight">
|
||||
Save hours in just a few clicks
|
||||
</p>
|
||||
<p class="text-gray-500 text-base leading-7 sm:text-lg sm:leading-8 font-medium mt-4">
|
||||
Building forms has never been easier
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="space-y-16 text-center lg:text-left sm:max-w-md sm:mx-auto lg:max-w-none lg:space-y-20 xl:space-y-24 mt-8 sm:mt-12 lg:mt-16">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8 lg:gap-12">
|
||||
<NuxtImg class="w-full lg:flex-1 bg-gray-300 lg:shrink-0 rounded-2xl ring-1 ring-gray-200 shadow-sm"
|
||||
src="/img/pages/ai_form_builder/step-1.svg" alt=""/>
|
||||
|
||||
|
||||
<div
|
||||
class="w-16 h-16 rounded-full bg-blue-50 border-2 border-blue-200 hidden xl:inline-flex items-center justify-center text-blue-600 text-2xl font-semibold leading-none">
|
||||
1
|
||||
</div>
|
||||
|
||||
<div class="lg:flex-1 lg:shrink-0">
|
||||
<NuxtImg class="w-auto h-16 hidden lg:block" src="/img/pages/ai_form_builder/icon-create.svg" alt=""/>
|
||||
|
||||
<h3 class="text-2xl sm:text-3xl lg:text-4xl font-semibold text-gray-900 lg:mt-8">
|
||||
Building forms made easy
|
||||
</h3>
|
||||
<p class="text-base font-medium leading-7 sm:text-lg sm:leading-8 text-gray-500 mt-4">
|
||||
OpnForm's easy-to-use online form creator lets you create a beautiful web form in no time. Whether you
|
||||
need to create
|
||||
contact forms and registration forms for a landing page or an online order form for your business, you
|
||||
will no longer
|
||||
need to spend hours working on forms.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8 lg:gap-12">
|
||||
<NuxtImg
|
||||
class="w-full lg:flex-1 bg-gray-300 lg:shrink-0 rounded-2xl ring-1 ring-gray-200 shadow-sm lg:order-3"
|
||||
src="/img/pages/ai_form_builder/step-2.svg" alt=""/>
|
||||
<div
|
||||
class="w-16 h-16 lg:order-2 rounded-full bg-blue-50 border-2 border-blue-200 hidden xl:inline-flex items-center justify-center text-blue-600 text-2xl font-semibold leading-none">
|
||||
2
|
||||
</div>
|
||||
|
||||
<div class="lg:flex-1 lg:shrink-0 lg:order-1">
|
||||
<NuxtImg class="w-auto h-16 hidden lg:block" src="/img/pages/ai_form_builder/icon-customization.svg" alt=""/>
|
||||
|
||||
<h3 class="text-2xl sm:text-3xl lg:text-4xl font-semibold text-gray-900 lg:mt-8">
|
||||
Customized forms work wonders
|
||||
</h3>
|
||||
<p class="text-base font-medium leading-7 sm:text-lg sm:leading-8 text-gray-500 mt-4">
|
||||
Did you know that good-looking forms are more likely to convert more responses than ordinary ones? On
|
||||
OpnForm, you can
|
||||
customize your form design to the tiniest detail and match your brand image with your forms.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8 lg:gap-12">
|
||||
<NuxtImg class="w-full lg:flex-1 bg-gray-300 lg:shrink-0 rounded-2xl ring-1 ring-gray-200 shadow-sm"
|
||||
src="/img/pages/ai_form_builder/step-3.svg" alt=""/>
|
||||
<div
|
||||
class="w-16 h-16 rounded-full bg-blue-50 border-2 border-blue-200 hidden xl:inline-flex items-center justify-center text-blue-600 text-2xl font-semibold leading-none">
|
||||
3
|
||||
</div>
|
||||
|
||||
<div class="lg:flex-1 lg:shrink-0">
|
||||
<NuxtImg class="w-auto h-16 hidden lg:block" src="/img/pages/ai_form_builder/icon-share.svg" alt=""/>
|
||||
|
||||
<h3 class="text-2xl sm:text-3xl lg:text-4xl font-semibold text-gray-900 lg:mt-8">
|
||||
Share your forms anywhere
|
||||
</h3>
|
||||
<p class="text-base font-medium leading-7 sm:text-lg sm:leading-8 text-gray-500 mt-4">
|
||||
You can share your forms anywhere using their unique weblink or embed them on your landing pages
|
||||
seamlessly.
|
||||
Furthermore, you can adjust your form’s privacy settings in order to reach the targeted audience and
|
||||
prevent others from
|
||||
viewing your form.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- END HOW IT WORKS -->
|
||||
|
||||
<!-- START EXAMPLES -->
|
||||
<!-- <section class="bg-gray-50 py-12 sm:py-16 lg:py-20 xl:py-24">-->
|
||||
<!-- <div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">-->
|
||||
<!-- <div class="text-center max-w-3xl mx-auto">-->
|
||||
<!-- <h2 class="text-sm font-semibold text-blue-600">-->
|
||||
<!-- Examples-->
|
||||
<!-- </h2>-->
|
||||
<!-- <p-->
|
||||
<!-- class="text-3xl mt-4 sm:text-4xl lg:text-5xl font-semibold text-gray-900 tracking-tight lg:leading-tight">-->
|
||||
<!-- What our user has created-->
|
||||
<!-- </p>-->
|
||||
<!-- <p class="text-gray-500 text-base leading-7 sm:text-lg sm:leading-8 font-medium mt-4">-->
|
||||
<!-- Amet minim mollit non deserunt ullamco est sit aliqua dolor do amet sint. Velit officia consequat duis-->
|
||||
<!-- enim velit-->
|
||||
<!-- mollit.-->
|
||||
<!-- </p>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-8 sm:mt-12 lg:mt-16">-->
|
||||
<!-- <div-->
|
||||
<!-- class="bg-white overflow-hidden rounded-2xl ring-1 ring-gray-200 shadow-sm hover:shadow-lg hover:-translate-y-2 transition-all duration-150">-->
|
||||
<!-- <NuxtImg class="w-full" src="/img/pages/ai_form_builder/examples-placeholder.png" alt=""/>
|
||||
-->
|
||||
<!-- <div class="px-4 py-5 sm:p-6">-->
|
||||
<!-- <h3 class="text-lg font-semibold text-gray-900">-->
|
||||
<!-- Example 1-->
|
||||
<!-- </h3>-->
|
||||
<!-- <p class="text-base font-medium text-gray-500 mt-2 line-clamp-2">-->
|
||||
<!-- Nulla Lorem mollit cupidatat irure. Laborum magna nulla duis ullamco cillum dolor-->
|
||||
<!-- </p>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div-->
|
||||
<!-- class="bg-white overflow-hidden rounded-2xl ring-1 ring-gray-200 shadow-sm hover:shadow-lg hover:-translate-y-2 transition-all duration-150">-->
|
||||
<!-- <NuxtImg class="w-full" src="/img/pages/ai_form_builder/examples-placeholder.png" alt=""/>
|
||||
-->
|
||||
<!-- <div class="px-4 py-5 sm:p-6">-->
|
||||
<!-- <h3 class="text-lg font-semibold text-gray-900">-->
|
||||
<!-- Example 2-->
|
||||
<!-- </h3>-->
|
||||
<!-- <p class="text-base font-medium text-gray-500 mt-2 line-clamp-2">-->
|
||||
<!-- Nulla Lorem mollit cupidatat irure. Laborum magna nulla duis ullamco cillum dolor-->
|
||||
<!-- </p>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div-->
|
||||
<!-- class="bg-white overflow-hidden rounded-2xl ring-1 ring-gray-200 shadow-sm hover:shadow-lg hover:-translate-y-2 transition-all duration-150">-->
|
||||
<!-- <NuxtImg class="w-full" src="/img/pages/ai_form_builder/examples-placeholder.png" alt=""/>
|
||||
-->
|
||||
<!-- <div class="px-4 py-5 sm:p-6">-->
|
||||
<!-- <h3 class="text-lg font-semibold text-gray-900">-->
|
||||
<!-- Example 3-->
|
||||
<!-- </h3>-->
|
||||
<!-- <p class="text-base font-medium text-gray-500 mt-2 line-clamp-2">-->
|
||||
<!-- Nulla Lorem mollit cupidatat irure. Laborum magna nulla duis ullamco cillum dolor-->
|
||||
<!-- </p>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div-->
|
||||
<!-- class="bg-white overflow-hidden rounded-2xl ring-1 ring-gray-200 shadow-sm hover:shadow-lg hover:-translate-y-2 transition-all duration-150">-->
|
||||
<!-- <NuxtImg class="w-full" src="/img/pages/ai_form_builder/examples-placeholder.png" alt=""/>
|
||||
-->
|
||||
<!-- <div class="px-4 py-5 sm:p-6">-->
|
||||
<!-- <h3 class="text-lg font-semibold text-gray-900">-->
|
||||
<!-- Example 4-->
|
||||
<!-- </h3>-->
|
||||
<!-- <p class="text-base font-medium text-gray-500 mt-2 line-clamp-2">-->
|
||||
<!-- Nulla Lorem mollit cupidatat irure. Laborum magna nulla duis ullamco cillum dolor-->
|
||||
<!-- </p>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!--<!– <hr class="mt-12 border-gray-200 sm:mt-16">–>-->
|
||||
|
||||
<!--<!– <div class="max-w-2xl mx-auto mt-12 text-center sm:mt-16">–>-->
|
||||
<!--<!– <h4 class="text-2xl font-semibold tracking-tight text-gray-900 sm:text-3xl lg:text-4xl">–>-->
|
||||
<!--<!– Ready to level-up?–>-->
|
||||
<!--<!– </h4>–>-->
|
||||
<!--<!– <p class="mt-4 text-base leading-7 sm:text-xl sm:leading-9 font-medium text-gray-500">–>-->
|
||||
<!--<!– Save time and effortlessly create forms with OpnForm–>-->
|
||||
<!--<!– </p>–>-->
|
||||
|
||||
<!--<!– <div class="mt-8 flex justify-center">–>-->
|
||||
<!--<!– <v-button v-if="!authenticated" class="mr-1" :to="{ name: 'forms-create-guest' }" :arrow="true">–>-->
|
||||
<!--<!– Get started for free–>-->
|
||||
<!--<!– </v-button>–>-->
|
||||
<!--<!– <v-button v-else class="mr-1" :to="{ name: 'forms-create' }" :arrow="true">–>-->
|
||||
<!--<!– Get started for free–>-->
|
||||
<!--<!– </v-button>–>-->
|
||||
<!--<!– </div>–>-->
|
||||
|
||||
<!--<!– <ul–>-->
|
||||
<!--<!– class="flex mt-8 sm:mt-12 text-sm font-medium text-gray-900 items-center justify-center flex-wrap gap-x-6 gap-y-4">–>-->
|
||||
<!--<!– <li class="flex items-center gap-2">–>-->
|
||||
<!--<!– <svg aria-hidden="true" class="h-5 w-5 text-gray-400 shrink-0" xmlns="http://www.w3.org/2000/svg"–>-->
|
||||
<!--<!– viewBox="0 0 20 20" fill="currentColor">–>-->
|
||||
<!--<!– <path fill-rule="evenodd"–>-->
|
||||
<!--<!– d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"–>-->
|
||||
<!--<!– clip-rule="evenodd" />–>-->
|
||||
<!--<!– </svg>–>-->
|
||||
<!--<!– No design skills required–>-->
|
||||
<!--<!– </li>–>-->
|
||||
|
||||
<!--<!– <li class="flex items-center gap-2">–>-->
|
||||
<!--<!– <svg aria-hidden="true" class="h-5 w-5 text-gray-400 shrink-0" xmlns="http://www.w3.org/2000/svg"–>-->
|
||||
<!--<!– viewBox="0 0 20 20" fill="currentColor">–>-->
|
||||
<!--<!– <path fill-rule="evenodd"–>-->
|
||||
<!--<!– d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"–>-->
|
||||
<!--<!– clip-rule="evenodd" />–>-->
|
||||
<!--<!– </svg>–>-->
|
||||
<!--<!– Setup in minutes–>-->
|
||||
<!--<!– </li>–>-->
|
||||
|
||||
<!--<!– <li class="flex items-center gap-2">–>-->
|
||||
<!--<!– <svg aria-hidden="true" class="h-5 w-5 text-gray-400 shrink-0" xmlns="http://www.w3.org/2000/svg"–>-->
|
||||
<!--<!– viewBox="0 0 20 20" fill="currentColor">–>-->
|
||||
<!--<!– <path fill-rule="evenodd"–>-->
|
||||
<!--<!– d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"–>-->
|
||||
<!--<!– clip-rule="evenodd" />–>-->
|
||||
<!--<!– </svg>–>-->
|
||||
<!--<!– It's free–>-->
|
||||
<!--<!– </li>–>-->
|
||||
<!--<!– </ul>–>-->
|
||||
<!--<!– </div>–>-->
|
||||
<!-- </div>-->
|
||||
<!-- </section>-->
|
||||
<!-- END EXAMPLES -->
|
||||
|
||||
<!-- START TESTIMONIALS -->
|
||||
<!-- <section class="bg-white py-12 sm:py-16 lg:py-20 xl:py-24">-->
|
||||
<!-- <div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">-->
|
||||
<!-- <div class="text-center max-w-3xl mx-auto">-->
|
||||
<!-- <h2 class="text-sm font-semibold text-blue-600">-->
|
||||
<!-- Customer Testimonials-->
|
||||
<!-- </h2>-->
|
||||
<!-- <p-->
|
||||
<!-- class="text-3xl mt-4 sm:text-4xl lg:text-5xl font-semibold text-gray-900 tracking-tight lg:leading-tight">-->
|
||||
<!-- See what people are saying-->
|
||||
<!-- </p>-->
|
||||
<!-- <p class="text-gray-500 text-base leading-7 sm:text-lg sm:leading-8 font-medium mt-4">-->
|
||||
<!-- These are the stories of our customers who have joined us with great pleasure when using this crazy-->
|
||||
<!-- feature.-->
|
||||
<!-- </p>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </section>-->
|
||||
<!-- END TESTIMONIALS -->
|
||||
|
||||
<!-- START FAQS -->
|
||||
<!-- <section class="bg-white py-12 sm:py-16 lg:py-20 xl:py-24 border-t border-gray-200">-->
|
||||
<!-- <div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">-->
|
||||
<!-- <div class="text-center max-w-3xl mx-auto">-->
|
||||
<!-- <h2 class="text-3xl sm:text-4xl lg:text-5xl font-semibold text-gray-900 tracking-tight lg:leading-tight">-->
|
||||
<!-- Frequently Asked Questions-->
|
||||
<!-- </h2>-->
|
||||
<!-- <p class="text-gray-500 text-base leading-7 sm:text-lg sm:leading-8 font-medium mt-4">-->
|
||||
<!-- We've compiled a list of the most common questions we get asked.-->
|
||||
<!-- </p>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div class="mt-12 sm:mt-16 lg:mt-20">-->
|
||||
<!-- <dl class="gap-y-12 grid grid-cols-1 sm:grid-cols-2 sm:gap-x-8 sm:gap-y-16 lg:gap-x-10">-->
|
||||
<!-- <div>-->
|
||||
<!-- <dt class="sm:text-lg text-base leading-7 font-medium sm:leading-8 text-gray-900">-->
|
||||
<!-- What's the best thing about Switzerland?-->
|
||||
<!-- </dt>-->
|
||||
<!-- <dd class="mt-2 text-base font-medium leading-7 text-gray-500">-->
|
||||
<!-- I don't know, but the flag is a big plus. Lorem ipsum dolor sit amet consectetur adipisicing elit.-->
|
||||
<!-- Quas cupiditate-->
|
||||
<!-- laboriosam fugiat.-->
|
||||
<!-- </dd>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div>-->
|
||||
<!-- <dt class="sm:text-lg text-base leading-7 font-medium sm:leading-8 text-gray-900">-->
|
||||
<!-- How do you make holy water?-->
|
||||
<!-- </dt>-->
|
||||
<!-- <dd class="mt-2 text-base font-medium leading-7 text-gray-500">-->
|
||||
<!-- You boil the hell out of it. Lorem ipsum dolor sit amet consectetur adipisicing elit. Magnam aut-->
|
||||
<!-- tempora vitae odio-->
|
||||
<!-- inventore fuga aliquam nostrum quod porro. Delectus quia facere id sequi expedita natus.-->
|
||||
<!-- </dd>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div>-->
|
||||
<!-- <dt class="sm:text-lg text-base leading-7 font-medium sm:leading-8 text-gray-900">-->
|
||||
<!-- What do you call someone with no body and no nose?-->
|
||||
<!-- </dt>-->
|
||||
<!-- <dd class="mt-2 text-base font-medium leading-7 text-gray-500">-->
|
||||
<!-- Nobody knows. Lorem ipsum dolor sit amet consectetur adipisicing elit. Culpa, voluptas ipsa quia-->
|
||||
<!-- excepturi, quibusdam-->
|
||||
<!-- natus exercitationem sapiente tempore labore voluptatem.-->
|
||||
<!-- </dd>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div>-->
|
||||
<!-- <dt class="sm:text-lg text-base leading-7 font-medium sm:leading-8 text-gray-900">-->
|
||||
<!-- Why do you never see elephants hiding in trees?-->
|
||||
<!-- </dt>-->
|
||||
<!-- <dd class="mt-2 text-base font-medium leading-7 text-gray-500">-->
|
||||
<!-- Because they're so good at it. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quas-->
|
||||
<!-- cupiditate laboriosam-->
|
||||
<!-- fugiat.-->
|
||||
<!-- </dd>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div>-->
|
||||
<!-- <dt class="sm:text-lg text-base leading-7 font-medium sm:leading-8 text-gray-900">-->
|
||||
<!-- Why can't you hear a pterodactyl go to the bathroom?-->
|
||||
<!-- </dt>-->
|
||||
<!-- <dd class="mt-2 text-base font-medium leading-7 text-gray-500">-->
|
||||
<!-- Because the pee is silent. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ipsam, quas-->
|
||||
<!-- voluptatibus ex culpa-->
|
||||
<!-- ipsum, aspernatur blanditiis fugiat ullam magnam suscipit deserunt illum natus facilis atque vero-->
|
||||
<!-- consequatur! Quisquam,-->
|
||||
<!-- debitis error.-->
|
||||
<!-- </dd>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div>-->
|
||||
<!-- <dt class="sm:text-lg text-base leading-7 font-medium sm:leading-8 text-gray-900">-->
|
||||
<!-- Why did the invisible man turn down the job offer?-->
|
||||
<!-- </dt>-->
|
||||
<!-- <dd class="mt-2 text-base font-medium leading-7 text-gray-500">-->
|
||||
<!-- He couldn't see himself doing it. Lorem ipsum dolor sit, amet consectetur adipisicing elit. Eveniet-->
|
||||
<!-- perspiciatis-->
|
||||
<!-- officiis corrupti tenetur. Temporibus ut voluptatibus, perferendis sed unde rerum deserunt eius.-->
|
||||
<!-- </dd>-->
|
||||
<!-- </div>-->
|
||||
<!-- </dl>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </section>-->
|
||||
<!-- END FAQS -->
|
||||
|
||||
<!-- START CTA -->
|
||||
<section class="bg-gradient-to-b from-gray-100 to-white py-12 sm:py-16 lg:pt-20 xl:pt-24">
|
||||
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-center gap-8">
|
||||
<NuxtImg class="w-auto h-12" src="/img/pages/ai_form_builder/icon-email-input.svg" alt=""/>
|
||||
|
||||
<NuxtImg class="w-auto h-12" src="/img/pages/ai_form_builder/icon-radio-buttons.svg" alt=""/>
|
||||
|
||||
<NuxtImg class="w-auto h-12" src="/img/pages/ai_form_builder/icon-textarea.svg" alt=""/>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="text-center max-w-3xl mx-auto mt-8 sm:mt-12">
|
||||
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-semibold text-gray-900 tracking-tight lg:leading-tight">
|
||||
Create beautiful forms and share them anywhere
|
||||
</h2>
|
||||
<p class="mt-4 sm:mt-5 text-base leading-7 sm:text-xl sm:leading-9 font-medium text-gray-500">
|
||||
It takes seconds, you don't need to know how to code and <span class="text-blue-600">it's free</span>.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
<v-button v-if="!authenticated" class="mr-1" :to="{ name: 'forms-create-guest' }" :arrow="true">
|
||||
Get started for free
|
||||
</v-button>
|
||||
<v-button v-else class="mr-1" :to="{ name: 'forms-create' }" :arrow="true">
|
||||
Get started for free
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="flex mt-8 sm:mt-12 text-sm font-medium text-gray-900 items-center justify-center flex-wrap gap-x-6 gap-y-4">
|
||||
<li class="flex items-center gap-2">
|
||||
<svg aria-hidden="true" class="h-5 w-5 text-gray-400 shrink-0" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
No design skills required
|
||||
</li>
|
||||
|
||||
<li class="flex items-center gap-2">
|
||||
<svg aria-hidden="true" class="h-5 w-5 text-gray-400 shrink-0" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Setup in minutes
|
||||
</li>
|
||||
|
||||
<li class="flex items-center gap-2">
|
||||
<svg aria-hidden="true" class="h-5 w-5 text-gray-400 shrink-0" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Free plan available
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- END CTA -->
|
||||
|
||||
<open-form-footer class="dark:border-t border-t"/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Free AI form builder',
|
||||
description: 'Transform your ideas into fully functional forms with OpnForm AI Builder – quick, accurate, and tailored to fit any requirement.'
|
||||
})
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
|
||||
let authenticated = computed(() => authStore.check)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.customer-logo-container {
|
||||
max-width: 130px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ticks {
|
||||
color:#2563eb;
|
||||
}
|
||||
|
||||
@screen md {
|
||||
#macbook-video {
|
||||
position: absolute;
|
||||
max-width: 84.8% !important;
|
||||
right: 0px;
|
||||
top: 6.8%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
client/pages/auth/password/email.vue
Normal file
53
client/pages/auth/password/email.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex mt-6 mb-10">
|
||||
<div class="w-full md:w-2/3 md:mx-auto md:max-w-md px-4">
|
||||
<h1 class="my-6">
|
||||
Reset password
|
||||
</h1>
|
||||
<form @submit.prevent="send" @keydown="form.onKeydown($event)">
|
||||
<alert-success :form="form" :message="status" class="mb-4" />
|
||||
|
||||
<!-- Email -->
|
||||
<text-input name="email" :form="form" label="Email" :required="true" />
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button class="w-full" :loading="form.busy">
|
||||
Send Password Reset Link
|
||||
</v-button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
setup () {
|
||||
definePageMeta({
|
||||
middleware: "guest"
|
||||
})
|
||||
useOpnSeoMeta({
|
||||
title: 'Reset Password'
|
||||
})
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
status: '',
|
||||
form: useForm({
|
||||
email: ''
|
||||
})
|
||||
}),
|
||||
|
||||
methods: {
|
||||
async send () {
|
||||
const { data } = await this.form.post('/password/email')
|
||||
|
||||
this.status = data.status
|
||||
|
||||
this.form.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
71
client/pages/auth/password/reset.vue
Normal file
71
client/pages/auth/password/reset.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex mt-6 mb-10">
|
||||
<div class="w-full md:w-2/3 md:mx-auto md:max-w-md px-4">
|
||||
<h1 class="my-6">
|
||||
Reset Password
|
||||
</h1>
|
||||
<form @submit.prevent="reset" @keydown="form.onKeydown($event)">
|
||||
<alert-success class="mb-4" :form="form" :message="status" />
|
||||
|
||||
<!-- Email -->
|
||||
<text-input name="email" :form="form" label="Email" :required="true" />
|
||||
|
||||
<!-- Password -->
|
||||
<text-input class="mt-8" native-type="password"
|
||||
name="password" :form="form" label="Password" :required="true"
|
||||
/>
|
||||
|
||||
<!-- Password Confirmation-->
|
||||
<text-input class="mt-8" native-type="password"
|
||||
name="password_confirmation" :form="form" label="Confirm Password" :required="true"
|
||||
/>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button class="w-full" :loading="form.busy">
|
||||
Reset Password
|
||||
</v-button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
setup () {
|
||||
definePageMeta({
|
||||
middleware: "guest"
|
||||
})
|
||||
useOpnSeoMeta({
|
||||
title: 'Reset Password'
|
||||
})
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
status: '',
|
||||
form: useForm({
|
||||
token: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: ''
|
||||
})
|
||||
}),
|
||||
|
||||
created () {
|
||||
this.form.email = this.$route.query.email
|
||||
this.form.token = this.$route.params.token
|
||||
},
|
||||
|
||||
methods: {
|
||||
async reset () {
|
||||
const { data } = await this.form.post('/password/reset')
|
||||
|
||||
this.status = data.status
|
||||
|
||||
this.form.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
106
client/pages/forms/[slug]/edit.vue
Normal file
106
client/pages/forms/[slug]/edit.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col">
|
||||
<form-editor v-if="!formsLoading || form" ref="editor"
|
||||
:is-edit="true"
|
||||
@on-save="formInitialHash=null"
|
||||
/>
|
||||
<div v-else-if="!formsLoading && error" class="mt-4 rounded-lg max-w-xl mx-auto p-6 bg-red-100 text-red-500">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else class="text-center mt-4 py-6">
|
||||
<Loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import Breadcrumb from '~/components/global/Breadcrumb.vue'
|
||||
import FormEditor from "~/components/open/forms/components/FormEditor.vue";
|
||||
import {hash} from "~/lib/utils.js";
|
||||
|
||||
export default {
|
||||
name: 'EditForm',
|
||||
components: { Breadcrumb, FormEditor },
|
||||
|
||||
beforeRouteLeave (to, from, next) {
|
||||
if (this.isDirty()) {
|
||||
return useAlert().confirm('Changes you made may not be saved. Are you sure want to leave?', () => {
|
||||
window.onbeforeunload = null
|
||||
next()
|
||||
}, () => {})
|
||||
}
|
||||
next()
|
||||
},
|
||||
|
||||
setup () {
|
||||
const formsStore = useFormsStore()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
|
||||
if (!formsStore.allLoaded) {
|
||||
formsStore.startLoading()
|
||||
}
|
||||
const updatedForm = storeToRefs(workingFormStore).content
|
||||
const form = computed(() => formsStore.getByKey(useRoute().params.slug))
|
||||
|
||||
// Create a form.id watcher that updates working form
|
||||
watch(form, (form) => {
|
||||
if (form) {
|
||||
updatedForm.value = useForm(form)
|
||||
}
|
||||
})
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Edit ' + ((form && form.value) ? form.value.title : 'Your Form')
|
||||
})
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
|
||||
return {
|
||||
formsStore,
|
||||
workingFormStore,
|
||||
workspacesStore,
|
||||
updatedForm,
|
||||
form,
|
||||
formsLoading: computed(() => formsStore.loading),
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
error: null,
|
||||
formInitialHash: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
|
||||
async beforeMount() {
|
||||
window.onbeforeunload = () => {
|
||||
if (this.isDirty()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.form && !this.formsStore.allLoaded) {
|
||||
await this.formsStore.loadAll(this.workspacesStore.currentId)
|
||||
}
|
||||
|
||||
this.updatedForm = useForm(this.form)
|
||||
this.formInitialHash = hash(JSON.stringify(this.updatedForm.data()))
|
||||
|
||||
if (this.updatedForm && (!this.updatedForm.notification_settings || Array.isArray(this.updatedForm.notification_settings))) {
|
||||
this.updatedForm.notification_settings = {}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
isDirty () {
|
||||
return this.formInitialHash && this.formInitialHash !== hash(JSON.stringify(this.updatedForm.data()))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
148
client/pages/forms/[slug]/index.vue
Normal file
148
client/pages/forms/[slug]/index.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div v-if="form && !isIframe && (form.logo_picture || form.cover_picture)">
|
||||
<div v-if="form.cover_picture">
|
||||
<div id="cover-picture" class="max-h-56 w-full overflow-hidden flex items-center justify-center">
|
||||
<img alt="Form Cover Picture" :src="form.cover_picture" class="w-full"/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="form.logo_picture" class="w-full p-5 relative mx-auto"
|
||||
:class="{'pt-20':!form.cover_picture, 'md:w-3/5 lg:w-1/2 md:max-w-2xl': form.width === 'centered', 'max-w-7xl': (form.width === 'full' && !isIframe) }"
|
||||
>
|
||||
<img alt="Logo Picture" :src="form.logo_picture"
|
||||
:class="{'top-5':!form.cover_picture, '-top-10':form.cover_picture}"
|
||||
class="w-20 h-20 object-contain absolute left-5 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full mx-auto px-4"
|
||||
:class="{'mt-6':!isIframe, 'md:w-3/5 lg:w-1/2 md:max-w-2xl': form && (form.width === 'centered'), 'max-w-7xl': (form && form.width === 'full' && !isIframe)}"
|
||||
>
|
||||
<div v-if="!formLoading && !form">
|
||||
<h1 class="mt-6" v-text="'Whoops'"/>
|
||||
<p class="mt-6">
|
||||
Unfortunately we could not find this form. It may have been deleted by it's author.
|
||||
</p>
|
||||
<p class="mb-10 mt-4">
|
||||
<router-link :to="{name:'index'}">
|
||||
Create your form for free with OpnForm
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="formLoading">
|
||||
<p class="text-center mt-6 p-4">
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto"/>
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="recordLoading">
|
||||
<p class="text-center mt-6 p-4">
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto"/>
|
||||
</p>
|
||||
</div>
|
||||
<open-complete-form v-show="!recordLoading" ref="open-complete-form" :form="form" class="mb-10"
|
||||
@password-entered="passwordEntered"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from 'vue'
|
||||
import OpenCompleteForm from '../../components/open/forms/OpenCompleteForm.vue'
|
||||
import sha256 from 'js-sha256'
|
||||
import {onBeforeRouteLeave} from 'vue-router'
|
||||
import {disableDarkMode, handleDarkMode, handleTransparentMode, focusOnFirstFormElement} from '~/lib/forms/public-page'
|
||||
|
||||
const crisp = useCrisp()
|
||||
const formsStore = useFormsStore()
|
||||
const recordsStore = useRecordsStore()
|
||||
|
||||
const isIframe = useIsIframe()
|
||||
const formLoading = computed(() => formsStore.loading)
|
||||
const recordLoading = computed(() => recordsStore.loading)
|
||||
const slug = useRoute().params.slug
|
||||
const form = computed(() => formsStore.getByKey(slug))
|
||||
const submitted = ref(false)
|
||||
|
||||
crisp.hideChat()
|
||||
onBeforeRouteLeave((to, from) => {
|
||||
crisp.showChat()
|
||||
disableDarkMode()
|
||||
})
|
||||
|
||||
const passwordEntered = function (password) {
|
||||
useCookie('password-' + slug, {
|
||||
maxAge: {expires: 60 * 60 * 7},
|
||||
sameSite: false,
|
||||
secure: true
|
||||
}).value = sha256(password)
|
||||
loadForm(slug).then(() => {
|
||||
if (form.value?.is_password_protected) {
|
||||
this.$refs['open-complete-form'].addPasswordError('Invalid password.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const loadForm = async () => {
|
||||
if (formsStore.loading || form.value) return Promise.resolve()
|
||||
const {data, error} = await formsStore.publicLoad(slug)
|
||||
if (error.value) {
|
||||
formsStore.stopLoading()
|
||||
return
|
||||
}
|
||||
formsStore.save(data.value)
|
||||
formsStore.stopLoading()
|
||||
|
||||
// Adapt page to form: colors, custom code etc
|
||||
handleDarkMode(form.value)
|
||||
handleTransparentMode(form.value)
|
||||
|
||||
if (process.server) return
|
||||
if (form.value.custom_code) {
|
||||
const scriptEl = document.createRange().createContextualFragment(form.value.custom_code)
|
||||
document.head.append(scriptEl)
|
||||
}
|
||||
if (!isIframe) focusOnFirstFormElement()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadForm(slug)
|
||||
})
|
||||
|
||||
await loadForm(slug)
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: () => {
|
||||
if (form && form.value.is_pro && form.value.seo_meta.page_title) {
|
||||
return form.value.seo_meta.page_title
|
||||
}
|
||||
return form.value ? form.value.title : 'Create beautiful forms'
|
||||
},
|
||||
description () {
|
||||
if (form && form.value.is_pro && form.value.seo_meta.page_description) {
|
||||
return form.value.seo_meta.page_description
|
||||
}
|
||||
return (form && form.value.description) ? form.value.description.substring(0, 160) : null
|
||||
},
|
||||
ogImage () {
|
||||
if (form && form.value.is_pro && form.value.seo_meta.page_thumbnail) {
|
||||
return form.value.seo_meta.page_thumbnail
|
||||
}
|
||||
return (form && form.value.cover_picture) ? form.value.cover_picture : null
|
||||
},
|
||||
robots: () => {
|
||||
return (form && form.value.can_be_indexed) ? null : 'noindex, nofollow'
|
||||
}
|
||||
})
|
||||
useHead({
|
||||
titleTemplate: (titleChunk) => {
|
||||
if (form && form.value?.is_pro && form.value?.seo_meta.page_title) {
|
||||
// Disable template if custom SEO title
|
||||
return titleChunk
|
||||
}
|
||||
return titleChunk ? `${titleChunk} - OpnForm` : 'OpnForm';
|
||||
}
|
||||
})
|
||||
</script>
|
||||
199
client/pages/forms/[slug]/show.vue
Normal file
199
client/pages/forms/[slug]/show.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="bg-white">
|
||||
<template v-if="form">
|
||||
<div class="flex bg-gray-50">
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4">
|
||||
<div class="pt-4 pb-0">
|
||||
<a href="#" class="flex text-blue mb-2 font-semibold text-sm" @click.prevent="goBack">
|
||||
<svg class="w-3 h-3 text-blue mt-1 mr-1" viewBox="0 0 6 10" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M5 9L1 5L5 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Go back
|
||||
</a>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
<h2 class="flex-grow text-gray-900 truncate">
|
||||
{{ form.title }}
|
||||
</h2>
|
||||
<div class="flex">
|
||||
<extra-menu :form="form"/>
|
||||
|
||||
<v-button v-track.view_form_click="{form_id:form.id, form_slug:form.slug}" target="_blank"
|
||||
:href="form.share_url" color="white"
|
||||
class="mr-2 text-blue-600 hidden sm:block"
|
||||
>
|
||||
<svg class="w-6 h-6 inline -mt-1" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</v-button>
|
||||
<v-button class="text-white" :to="{name: 'forms-slug-edit', params: {slug: slug}}">
|
||||
<svg class="inline mr-1 -mt-1" width="18" height="17" viewBox="0 0 18 17" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.99998 15.6662H16.5M1.5 15.6662H2.89545C3.3031 15.6662 3.50693 15.6662 3.69874 15.6202C3.8688 15.5793 4.03138 15.512 4.1805 15.4206C4.34869 15.3175 4.49282 15.1734 4.78107 14.8852L15.25 4.4162C15.9404 3.72585 15.9404 2.60656 15.25 1.9162C14.5597 1.22585 13.4404 1.22585 12.75 1.9162L2.28105 12.3852C1.9928 12.6734 1.84867 12.8175 1.7456 12.9857C1.65422 13.1348 1.58688 13.2974 1.54605 13.4675C1.5 13.6593 1.5 13.8631 1.5 14.2708V15.6662Z"
|
||||
stroke="currentColor" stroke-width="1.67" stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Edit form
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-500 text-sm">
|
||||
<span class="pr-1">{{ form.views_count }} view{{ form.views_count > 0 ? 's' : '' }}</span>
|
||||
<span class="pr-1">- {{ form.submissions_count }}
|
||||
submission{{ form.submissions_count > 0 ? 's' : '' }}
|
||||
</span>
|
||||
<span>- Edited {{ form.last_edited_human }}</span>
|
||||
</p>
|
||||
<div v-if="['draft','closed'].includes(form.visibility) || (form.tags && form.tags.length > 0)"
|
||||
class="mt-2 flex items-center flex-wrap gap-3"
|
||||
>
|
||||
<span v-if="form.visibility=='draft'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
Draft - not publicly accessible
|
||||
</span>
|
||||
<span v-else-if="form.visibility=='closed'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
Closed - won't accept new submissions
|
||||
</span>
|
||||
<span v-for="(tag,i) in form.tags" :key="tag"
|
||||
class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="form.closes_at" class="text-yellow-500">
|
||||
<span v-if="form.is_closed"> This form stopped accepting submissions on the {{
|
||||
displayClosesDate
|
||||
}} </span>
|
||||
<span v-else> This form will stop accepting submissions on the {{ displayClosesDate }} </span>
|
||||
</p>
|
||||
<p v-if="form.max_submissions_count > 0" class="text-yellow-500">
|
||||
<span v-if="form.max_number_of_submissions_reached"> The form is now closed because it reached its limit of {{
|
||||
form.max_submissions_count
|
||||
}} submissions. </span>
|
||||
<span v-else> This form will stop accepting submissions after {{ form.max_submissions_count }} submissions. </span>
|
||||
</p>
|
||||
|
||||
<form-cleanings class="mt-4" :form="form"/>
|
||||
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center">
|
||||
<li v-for="(tab, i) in tabsList" :key="i+1" class="mr-6">
|
||||
<nuxt-link :to="{ name: tab.route }"
|
||||
class="hover:no-underline inline-block py-4 rounded-t-lg border-b-2 text-gray-500 hover:text-gray-600"
|
||||
active-class="text-blue-600 hover:text-blue-900 dark:text-blue-500 dark:hover:text-blue-500 border-blue-600 dark:border-blue-500"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex bg-white">
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4">
|
||||
<div class="py-4">
|
||||
<NuxtPage :form="form"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="loading" class="text-center w-full p-5">
|
||||
<Loader class="h-6 w-6 mx-auto"/>
|
||||
</div>
|
||||
<div v-else class="text-center w-full p-5">
|
||||
Form not found.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from 'vue'
|
||||
import ProTag from '~/components/global/ProTag.vue'
|
||||
import VButton from '~/components/global/VButton.vue'
|
||||
import ExtraMenu from '../../../components/pages/forms/show/ExtraMenu.vue'
|
||||
import FormCleanings from '../../../components/pages/forms/show/FormCleanings.vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
useOpnSeoMeta({
|
||||
title: 'Home'
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const formsStore = useFormsStore()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
|
||||
const slug = useRoute().params.slug
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const form = computed(() => formsStore.getByKey(slug))
|
||||
const workspace = computed(() => workspacesStore.getByKey(form?.value?.workspace_id))
|
||||
const loading = computed(() => formsStore.loading || workspacesStore.loading)
|
||||
const displayClosesDate = computed(() => {
|
||||
if (form.value && form.value.closes_at) {
|
||||
const dateObj = new Date(form.value.closes_at)
|
||||
return dateObj.getFullYear() + '-' +
|
||||
String(dateObj.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(dateObj.getDate()).padStart(2, '0') + ' ' +
|
||||
String(dateObj.getHours()).padStart(2, '0') + ':' +
|
||||
String(dateObj.getMinutes()).padStart(2, '0')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const tabsList = [
|
||||
{
|
||||
name: 'Submissions',
|
||||
route: 'forms-slug-show-submissions'
|
||||
},
|
||||
{
|
||||
name: 'Analytics',
|
||||
route: 'forms-slug-show-stats'
|
||||
},
|
||||
{
|
||||
name: 'Share',
|
||||
route: 'forms-slug-show-share'
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
workingFormStore.set(null) // Reset old working form
|
||||
if (form.value) {
|
||||
workingFormStore.set(form.value)
|
||||
} else {
|
||||
formsStore.loadAll(useWorkspacesStore().currentId)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => form?.value?.id, (id) => {
|
||||
if (id) {
|
||||
workingFormStore.set(form)
|
||||
}
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
useRouter().push({name: 'home'})
|
||||
}
|
||||
</script>
|
||||
7
client/pages/forms/[slug]/show/index.vue
Normal file
7
client/pages/forms/[slug]/show/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
redirect: to => {
|
||||
return { name: 'forms-slug-show-submissions'}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
55
client/pages/forms/[slug]/show/share.vue
Normal file
55
client/pages/forms/[slug]/show/share.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="mb-20">
|
||||
|
||||
<div class="mb-6 pb-6 border-b w-full flex flex-col sm:flex-row gap-2">
|
||||
<regenerate-form-link class="sm:w-1/2 flex" :form="props.form"/>
|
||||
|
||||
<url-form-prefill class="sm:w-1/2" :form="props.form" :extra-query-param="shareUrlForQueryParams"/>
|
||||
|
||||
<embed-form-as-popup-modal class="sm:w-1/2 flex" :form="props.form"/>
|
||||
</div>
|
||||
|
||||
<share-link class="mt-4" :form="props.form" :extra-query-param="shareUrlForQueryParams"/>
|
||||
|
||||
<embed-code class="mt-6" :form="props.form" :extra-query-param="shareUrlForQueryParams"/>
|
||||
|
||||
<form-qr-code class="mt-6" :form="props.form" :extra-query-param="shareUrlForQueryParams"/>
|
||||
|
||||
<advanced-form-url-settings :form="props.form" v-model="shareFormConfig"/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ShareLink from '../../../../components/pages/forms/show/ShareLink.vue'
|
||||
import EmbedCode from '../../../../components/pages/forms/show/EmbedCode.vue'
|
||||
import FormQrCode from '../../../../components/pages/forms/show/FormQrCode.vue'
|
||||
import UrlFormPrefill from '../../../../components/pages/forms/show/UrlFormPrefill.vue'
|
||||
import RegenerateFormLink from '../../../../components/pages/forms/show/RegenerateFormLink.vue'
|
||||
import AdvancedFormUrlSettings from '../../../../components/open/forms/components/AdvancedFormUrlSettings.vue'
|
||||
import EmbedFormAsPopupModal from '../../../../components/pages/forms/show/EmbedFormAsPopupModal.vue'
|
||||
|
||||
const props = defineProps({form: {type: Object, required: true}})
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
useOpnSeoMeta({
|
||||
title: (props.form) ? 'Share Form - ' + props.form.title : 'Share Form'
|
||||
})
|
||||
|
||||
const shareFormConfig = ref({
|
||||
hide_title: false,
|
||||
auto_submit: false
|
||||
})
|
||||
|
||||
const shareUrlForQueryParams = computed(() => {
|
||||
let queryStr = ''
|
||||
for (const [key, value] of Object.entries(shareFormConfig.value)) {
|
||||
if (value && value !== 'false' && value !== false) {
|
||||
queryStr += '&' + encodeURIComponent(key) + "=" + encodeURIComponent(value)
|
||||
}
|
||||
}
|
||||
return queryStr.slice(1)
|
||||
})
|
||||
</script>
|
||||
32
client/pages/forms/[slug]/show/stats.vue
Normal file
32
client/pages/forms/[slug]/show/stats.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="font-semibold mt-4 text-xl">
|
||||
Form Analytics (last 30 days)
|
||||
</h3>
|
||||
<form-stats :form="form"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FormStats from '../../../../components/open/forms/components/FormStats.vue'
|
||||
|
||||
export default {
|
||||
components: {FormStats},
|
||||
|
||||
props: {
|
||||
form: {type: Object, required: true},
|
||||
},
|
||||
|
||||
setup (props) {
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
useOpnSeoMeta({
|
||||
title: (props.form) ? 'Form Analytics - '+props.form.title : 'Form Analytics'
|
||||
})
|
||||
},
|
||||
|
||||
computed: {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
21
client/pages/forms/[slug]/show/submissions.vue
Normal file
21
client/pages/forms/[slug]/show/submissions.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div id="table-page">
|
||||
<form-submissions/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FormSubmissions from '../../../../components/open/forms/components/FormSubmissions.vue'
|
||||
|
||||
const props = {
|
||||
form: {type: Object, required: true}
|
||||
}
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
useOpnSeoMeta({
|
||||
title: (props.form) ? 'Form Submissions - ' + props.form.title : 'Form Submissions'
|
||||
})
|
||||
|
||||
</script>
|
||||
105
client/pages/forms/create/guest.vue
Normal file
105
client/pages/forms/create/guest.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap flex-col">
|
||||
<transition v-if="stateReady" name="fade" mode="out-in">
|
||||
<div key="2">
|
||||
<create-form-base-modal :show="showInitialFormModal" @form-generated="formGenerated"
|
||||
@close="showInitialFormModal=false"
|
||||
/>
|
||||
<form-editor v-if="!workspacesLoading" ref="editor"
|
||||
class="w-full flex flex-grow"
|
||||
:error="error"
|
||||
:is-guest="isGuest"
|
||||
@openRegister="registerModal=true"
|
||||
/>
|
||||
<div v-else class="text-center mt-4 py-6">
|
||||
<Loader class="h-6 w-6 text-nt-blue mx-auto"/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<quick-register :show-register-modal="registerModal" @close="registerModal=false" @reopen="registerModal=true"
|
||||
@afterLogin="afterLogin"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FormEditor from "~/components/open/forms/components/FormEditor.vue"
|
||||
import QuickRegister from '~/components/pages/auth/components/QuickRegister.vue'
|
||||
import CreateFormBaseModal from '../../../components/pages/forms/create/CreateFormBaseModal.vue'
|
||||
import {initForm} from "~/composables/forms/initForm.js"
|
||||
import {fetchTemplate} from "~/stores/templates.js"
|
||||
import {fetchAllWorkspaces} from "~/stores/workspaces.js";
|
||||
|
||||
|
||||
const templatesStore = useTemplatesStore()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
const route = useRoute()
|
||||
|
||||
// Fetch the template
|
||||
if (route.query.template !== undefined && route.query.template) {
|
||||
const {data} = await fetchTemplate(route.query.template)
|
||||
templatesStore.save(data.value)
|
||||
}
|
||||
|
||||
// Store values
|
||||
const workspace = computed(() => workspacesStore.getCurrent)
|
||||
const workspacesLoading = computed(() => workspacesStore.loading)
|
||||
const form = storeToRefs(workingFormStore).content
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Create a new Form for free',
|
||||
})
|
||||
definePageMeta({
|
||||
middleware: "guest"
|
||||
})
|
||||
|
||||
// Data
|
||||
const stateReady = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const registerModal = ref(false)
|
||||
const isGuest = ref(true)
|
||||
const showInitialFormModal = ref(false)
|
||||
|
||||
// Component ref
|
||||
const editor = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
// Set as guest user
|
||||
workspacesStore.set([{
|
||||
id: null,
|
||||
name: 'Guest Workspace',
|
||||
is_enterprise: false,
|
||||
is_pro: false
|
||||
}])
|
||||
|
||||
form.value = initForm()
|
||||
if (route.query.template !== undefined && route.query.template) {
|
||||
const template = templatesStore.getByKey(route.query.template)
|
||||
if (template && template.structure) {
|
||||
form.value = useForm({...form.value.data(), ...template.structure})
|
||||
}
|
||||
} else {
|
||||
// No template loaded, ask how to start
|
||||
showInitialFormModal.value = true
|
||||
}
|
||||
stateReady.value = true
|
||||
})
|
||||
|
||||
const afterLogin = () => {
|
||||
registerModal.value = false
|
||||
isGuest.value = false
|
||||
fetchAllWorkspaces()
|
||||
setTimeout(() => {
|
||||
if (editor) {
|
||||
editor.value.saveFormCreate()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const formGenerated = (newForm) => {
|
||||
form.value = useForm({...form.value.data(), ...newForm})
|
||||
}
|
||||
</script>
|
||||
117
client/pages/forms/create/index.vue
Normal file
117
client/pages/forms/create/index.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap flex-col">
|
||||
<transition name="fade" mode="out-in">
|
||||
<div key="2">
|
||||
<create-form-base-modal :show="showInitialFormModal" @form-generated="formGenerated"
|
||||
@close="showInitialFormModal=false"
|
||||
/>
|
||||
|
||||
<form-editor v-if="form && !workspacesLoading" ref="editor"
|
||||
class="w-full flex flex-grow"
|
||||
:error="error"
|
||||
@on-save="formInitialHash=null"
|
||||
/>
|
||||
<div v-else class="text-center mt-4 py-6">
|
||||
<Loader class="h-6 w-6 text-nt-blue mx-auto"/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import {watch} from 'vue'
|
||||
import {initForm} from "~/composables/forms/initForm.js"
|
||||
import FormEditor from "~/components/open/forms/components/FormEditor.vue"
|
||||
import CreateFormBaseModal from '../../../components/pages/forms/create/CreateFormBaseModal.vue'
|
||||
import {fetchTemplate} from "~/stores/templates.js"
|
||||
import {hash} from "~/lib/utils.js"
|
||||
import {onBeforeRouteLeave} from 'vue-router'
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Create a new Form'
|
||||
})
|
||||
|
||||
onBeforeRouteLeave((to, from, next) => {
|
||||
if (isDirty()) {
|
||||
return useAlert().confirm('Changes you made may not be saved. Are you sure want to leave?', () => {
|
||||
window.onbeforeunload = null
|
||||
next()
|
||||
}, () => {})
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const templatesStore = useTemplatesStore()
|
||||
const workingFormStore = useWorkingFormStore()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
const formStore = useFormsStore()
|
||||
|
||||
// Fetch the template
|
||||
if (route.query.template !== undefined && route.query.template) {
|
||||
const {data} = await fetchTemplate(route.query.template)
|
||||
templatesStore.save(data.value)
|
||||
}
|
||||
|
||||
const {
|
||||
getCurrent: workspace,
|
||||
getAll: workspaces,
|
||||
workspacesLoading: workspacesLoading
|
||||
} = storeToRefs(workspacesStore)
|
||||
const {content: form} = storeToRefs(workingFormStore)
|
||||
|
||||
// State
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const showInitialFormModal = ref(false)
|
||||
const formInitialHash = ref(null)
|
||||
|
||||
watch(() => workspace, () => {
|
||||
if (workspace) {
|
||||
form.workspace_id = workspace.value.id
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (process.client) {
|
||||
window.onbeforeunload = () => {
|
||||
if (isDirty()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!formStore.allLoaded) {
|
||||
formStore.loadAll(workspace.value.id)
|
||||
}
|
||||
|
||||
form.value = initForm({workspace_id: workspace.value?.id})
|
||||
formInitialHash.value = hash(JSON.stringify(form.value.data()))
|
||||
if (route.query.template !== undefined && route.query.template) {
|
||||
const template = templatesStore.getByKey(route.query.template)
|
||||
if (template && template.structure) {
|
||||
form.value = useForm({...form.value.data(), ...template.structure})
|
||||
}
|
||||
} else {
|
||||
// No template loaded, ask how to start
|
||||
showInitialFormModal.value = true
|
||||
}
|
||||
// workspacesStore.loadIfEmpty()
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formGenerated = (newForm) => {
|
||||
form.value = useForm({...form.value.data(), ...newForm})
|
||||
}
|
||||
|
||||
const isDirty = () => {
|
||||
return !loading.value && formInitialHash.value && formInitialHash.value !== hash(JSON.stringify(form.value.data()))
|
||||
}
|
||||
</script>
|
||||
200
client/pages/home.vue
Normal file
200
client/pages/home.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="bg-white">
|
||||
<div class="flex bg-gray-50 pb-5 border-b">
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl p-4">
|
||||
<div class="pt-4 pb-0">
|
||||
<div class="flex">
|
||||
<h2 class="flex-grow text-gray-900">
|
||||
Your Forms
|
||||
</h2>
|
||||
<v-button v-track.create_form_click :to="{name:'forms-create'}">
|
||||
<svg class="w-4 h-4 text-white inline mr-1 -mt-1" viewBox="0 0 14 14" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.99996 1.1665V12.8332M1.16663 6.99984H12.8333" stroke="currentColor" stroke-width="1.67"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Create a new form
|
||||
</v-button>
|
||||
</div>
|
||||
<small class="flex text-gray-500">Manage your forms and submissions.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex bg-white">
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4">
|
||||
<div class="mt-8 pb-0">
|
||||
<text-input v-if="forms.length > 0" class="mb-6" v-model="search" name="search" label="Search a form"
|
||||
placeholder="Name of form to search"
|
||||
/>
|
||||
<div v-if="allTags.length > 0" class="mb-4">
|
||||
<div v-for="tag in allTags" :key="tag"
|
||||
:class="[
|
||||
'inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset cursor-pointer mr-2',
|
||||
{'bg-blue-50 text-blue-600 ring-blue-500/10 dark:bg-blue-400':selectedTags.includes(tag),
|
||||
'bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-700 hover:bg-blue-50 hover:text-blue-600 hover:ring-blue-500/10 hover:dark:bg-blue-400':!selectedTags.includes(tag)}
|
||||
]"
|
||||
title="Click for filter by tag(s)"
|
||||
@click="onTagClick(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!formsLoading && enrichedForms.length === 0" class="flex flex-wrap justify-center max-w-4xl">
|
||||
<NuxtImg class="w-56"
|
||||
src="/img/pages/forms/search_notfound.png" alt="search-not-found"
|
||||
/>
|
||||
|
||||
<h3 class="w-full mt-4 text-center text-gray-900 font-semibold">
|
||||
No forms found
|
||||
</h3>
|
||||
<div v-if="isFilteringForms && enrichedForms.length === 0 && search"
|
||||
class="mt-2 w-full text-center">
|
||||
Your search "{{ search }}" did not match any forms. Please try again.
|
||||
</div>
|
||||
<v-button v-if="forms.length === 0" v-track.create_form_click class="mt-4" :to="{name:'forms-create'}">
|
||||
<svg class="w-4 h-4 text-white inline mr-1 -mt-1" viewBox="0 0 14 14" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.99996 1.1665V12.8332M1.16663 6.99984H12.8333" stroke="currentColor" stroke-width="1.67"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Create a new form
|
||||
</v-button>
|
||||
</div>
|
||||
<div v-else-if="forms.length > 0" class="mb-10">
|
||||
<div v-if="enrichedForms && enrichedForms.length">
|
||||
<div v-for="(form) in enrichedForms" :key="form.id"
|
||||
class="mt-4 p-4 flex group bg-white hover:bg-gray-50 dark:bg-notion-dark items-center relative"
|
||||
>
|
||||
<div class="flex-grow items-center truncate cursor-pointer relative">
|
||||
<NuxtLink :to="{name:'forms-slug-show-submissions', params: {slug:form.slug}}"
|
||||
class="absolute inset-0"/>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ form.title }}</span>
|
||||
<ul class="flex text-gray-500">
|
||||
<li class="pr-1">
|
||||
{{ form.views_count }} view{{ form.views_count > 0 ? 's' : '' }}
|
||||
</li>
|
||||
<li class="list-disc ml-6 pr-1">
|
||||
{{ form.submissions_count }}
|
||||
submission{{ form.submissions_count > 0 ? 's' : '' }}
|
||||
</li>
|
||||
<li class="list-disc ml-6">
|
||||
Edited {{ form.last_edited_human }}
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="['draft','closed'].includes(form.visibility) || (form.tags && form.tags.length > 0)"
|
||||
class="mt-1 flex items-center flex-wrap gap-3">
|
||||
<span v-if="form.visibility=='draft'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
|
||||
Draft
|
||||
</span>
|
||||
<span v-else-if="form.visibility=='closed'"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700">
|
||||
Closed
|
||||
</span>
|
||||
<span v-for="(tag,i) in form.tags" :key="tag"
|
||||
class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:text-white dark:bg-gray-700"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<extra-menu :form="form" :is-main-page="true"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="formsLoading" class="text-center">
|
||||
<Loader class="h-6 w-6 text-nt-blue mx-auto"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer class="mt-8 border-t"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useAuthStore} from '../stores/auth'
|
||||
import {useFormsStore} from '../stores/forms'
|
||||
import {useWorkspacesStore} from '../stores/workspaces'
|
||||
import Fuse from 'fuse.js'
|
||||
import TextInput from '../components/forms/TextInput.vue'
|
||||
import ExtraMenu from '../components/pages/forms/show/ExtraMenu.vue'
|
||||
import {refDebounced} from "@vueuse/core"
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Your Forms',
|
||||
description: 'All of your OpnForm are here. Create new forms, or update your existing forms.'
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const formsStore = useFormsStore()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
formsStore.startLoading()
|
||||
|
||||
onMounted(() => {
|
||||
if (!formsStore.allLoaded) {
|
||||
formsStore.loadAll(workspacesStore.currentId)
|
||||
} else {
|
||||
formsStore.stopLoading()
|
||||
}
|
||||
})
|
||||
|
||||
// State
|
||||
const {getAll: forms, loading: formsLoading, allTags} = storeToRefs(formsStore)
|
||||
const showEditFormModal = ref(false)
|
||||
const selectedForm = ref(null)
|
||||
const search = ref('')
|
||||
const debouncedSearch = refDebounced(search, 500)
|
||||
const selectedTags = ref(new Set())
|
||||
|
||||
// Methods
|
||||
const editForm = (form) => {
|
||||
selectedForm.value = form
|
||||
showEditFormModal.value = true
|
||||
}
|
||||
const onTagClick = (tag) => {
|
||||
if (selectedTags.value.has(tag)) {
|
||||
selectedTags.value.remove(tag)
|
||||
} else {
|
||||
selectedTags.value.add(tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Computed
|
||||
const isFilteringForms = computed(() => {
|
||||
return (search.value !== '' && search.value !== null) || selectedTags.value.size > 0
|
||||
})
|
||||
|
||||
const enrichedForms = computed(() => {
|
||||
let enrichedForms = forms.value.map((form) => {
|
||||
form.workspace = workspacesStore.getByKey(form.workspace_id)
|
||||
return form
|
||||
}).filter((form) => {
|
||||
if (selectedTags.value.size === 0) {
|
||||
return true
|
||||
}
|
||||
return form.tags && form.tags.length ? [...selectedTags].every(r => form.tags.includes(r)) : false
|
||||
})
|
||||
|
||||
if (!isFilteringForms || search.value === '' || search.value === null) {
|
||||
return enrichedForms
|
||||
}
|
||||
|
||||
// Fuze search
|
||||
const fuzeOptions = {
|
||||
keys: [
|
||||
'title',
|
||||
'slug',
|
||||
'tags'
|
||||
]
|
||||
}
|
||||
const fuse = new Fuse(enrichedForms, fuzeOptions)
|
||||
return fuse.search(debouncedSearch.value).map((res) => {
|
||||
return res.item
|
||||
})
|
||||
})
|
||||
</script>
|
||||
247
client/pages/index.vue
Normal file
247
client/pages/index.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="bg-gradient-to-b relative from-white to-gray-100 py-8 sm:py-16 ">
|
||||
<div class="absolute inset-0">
|
||||
<img class="w-full h-full object-cover object-top"
|
||||
src="/img/pages/ai_form_builder/background-pattern.svg" alt="Page abstract background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto relative -mb-32 md:-mb-52 lg:-mb-72">
|
||||
<div class="flex justify-center mb-5">
|
||||
<div
|
||||
class="relative flex items-center shadow-sm bg-white gap-x-4 rounded-full px-4 py-1 text-sm leading-6 text-gray-600 ring-1 ring-gray-900/10 hover:ring-gray-900/20"
|
||||
>
|
||||
<span class="font-semibold text-gray-500">We're Open-Source</span><span class="h-4 w-px bg-gray-900/10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<a
|
||||
target="_blank" class="flex items-center gap-x-1 hover:no-underline"
|
||||
href="https://github.com/jhumanj/opnform" v-track.welcome_github_click
|
||||
>
|
||||
<span class="absolute inset-0" aria-hidden="true"/>
|
||||
Star us on GitHub
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" aria-hidden="true" class="-mr-2 h-5 w-5 text-gray-400"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-semibold text-gray-900 tracking-tight">
|
||||
Build
|
||||
<span
|
||||
class="bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-blue-400">beautiful forms</span>
|
||||
<br>
|
||||
in seconds
|
||||
</h1>
|
||||
<p class="mt-4 sm:mt-5 text-base leading-7 sm:text-xl sm:leading-9 font-medium text-gray-500">
|
||||
Create beautiful forms and share them anywhere. It super fast, you don't need to know how to code. Get
|
||||
started
|
||||
<span class="font-semibold">for free</span>!
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
<v-button v-if="!authenticated" class="mr-1" :to="{ name: 'forms-create-guest' }" :arrow="true">
|
||||
Create a form for FREE
|
||||
</v-button>
|
||||
<v-button v-else class="mr-1" :to="{ name: 'forms-create' }" :arrow="true">
|
||||
Create a form for FREE
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<div class="justify-center flex gap-2 mt-10">
|
||||
<div class="flex items-center text-gray-400 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="w-4 h-4 mr-1 ticks"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
|
||||
</svg>
|
||||
<span>Unlimited forms</span>
|
||||
</div>
|
||||
<div class="flex items-center text-gray-400 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="w-4 h-4 mr-1 ticks"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
|
||||
</svg>
|
||||
<span>
|
||||
Unlimited fields
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex text-gray-400 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||
stroke="currentColor" class="w-4 h-4 mr-1 ticks"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
|
||||
</svg>
|
||||
<span>Unlimited responses</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full mt-12 relative px-6 mx-auto max-w-4xl sm:px-10 lg:px-0 z-10 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="-m-2 rounded-xl bg-blue-900/5 p-2 backdrop-blur-sm ring-1 ring-inset ring-blue-900/10 lg:-m-4 lg:rounded-2xl lg:p-4 w-full"
|
||||
>
|
||||
<NuxtImg src="/img/pages/welcome/product-cover.jpg"
|
||||
sizes="320px sm:650px lg:896px"
|
||||
alt="Product screenshot" loading="lazy" class="rounded-md w-full shadow-2xl ring-1 ring-gray-900/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col bg-gray-50 dark:bg-notion-dark">
|
||||
<div class="bg-white dark:bg-notion-dark-light pt-32 md:pt-52 lg:pt-72 pb-8">
|
||||
<div class="md:max-w-5xl md:mx-auto w-full">
|
||||
<features class="pb-8"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ai-feature class="bg-white -mb-56"/>
|
||||
|
||||
<more-features class="pt-56"/>
|
||||
|
||||
<pricing-table v-if="paidPlansEnabled" class="pb-20" :home-page="true">
|
||||
<template #pricing-table>
|
||||
<li class="flex gap-x-3">
|
||||
<NuxtLink :to="{name:'pricing'}" class="flex gap-3">
|
||||
<div class="w-5"/>
|
||||
Read more about our pricing
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</template>
|
||||
</pricing-table>
|
||||
|
||||
<!-- <div class="pt-20 pb-5 text-center bg-white dark:bg-notion-dark-light">-->
|
||||
<!-- <h3 class="font-semibold text-3xl">See what people are saying</h3>-->
|
||||
<!-- <p class="w-full mt-2 mb-8">-->
|
||||
<!-- These are the stories of our customers who have joined us with great pleasure when using this crazy feature.-->
|
||||
<!-- </p>-->
|
||||
<!-- <testimonials/>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<templates-slider class="max-w-full mb-12"/>
|
||||
|
||||
<div class="w-full bg-blue-900 p-12 md:p-24 text-center">
|
||||
<h4 class="font-semibold text-3xl text-white">
|
||||
Take your forms to the next level
|
||||
</h4>
|
||||
<p class="text-gray-300 my-8">
|
||||
Generous, unlimited free plan.
|
||||
</p>
|
||||
<div class="mt-6 flex justify-center">
|
||||
<v-button v-track.welcome_create_form_click :to="{ name: 'forms-create-guest' }" :arrow="true" color="blue">
|
||||
Create a form for FREE
|
||||
</v-button>
|
||||
</div>
|
||||
<div class="flex justify-center mt-6">
|
||||
<a target="_blank" :href="configLinks.twitter" class="mr-4">
|
||||
<svg class="w-6 h-6 text-white" viewBox="0 0 24 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M7.55016 19.7502C16.6045 19.7502 21.5583 12.2469 21.5583 5.74211C21.5583 5.53117 21.5536 5.31554 21.5442 5.1046C22.5079 4.40771 23.3395 3.5445 24 2.55554C23.1025 2.95484 22.1496 3.21563 21.1739 3.32898C22.2013 2.71315 22.9705 1.74572 23.3391 0.606011C22.3726 1.1788 21.3156 1.58286 20.2134 1.80085C19.4708 1.01181 18.489 0.48936 17.4197 0.314295C16.3504 0.13923 15.2532 0.321295 14.2977 0.832341C13.3423 1.34339 12.5818 2.15495 12.1338 3.14156C11.6859 4.12816 11.5754 5.23486 11.8195 6.29054C9.86249 6.19233 7.94794 5.68395 6.19998 4.79834C4.45203 3.91274 2.90969 2.66968 1.67297 1.14976C1.0444 2.23349 0.852057 3.51589 1.13503 4.73634C1.418 5.95678 2.15506 7.02369 3.19641 7.72023C2.41463 7.69541 1.64998 7.48492 0.965625 7.10617V7.1671C0.964925 8.30439 1.3581 9.40683 2.07831 10.287C2.79852 11.1672 3.80132 11.7708 4.91625 11.9952C4.19206 12.1934 3.43198 12.2222 2.69484 12.0796C3.00945 13.0577 3.62157 13.9131 4.44577 14.5266C5.26997 15.14 6.26512 15.4808 7.29234 15.5015C5.54842 16.8714 3.39417 17.6144 1.17656 17.6109C0.783287 17.6103 0.390399 17.5861 0 17.5387C2.25286 18.984 4.87353 19.7516 7.55016 19.7502Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a target="_blank" :href="configLinks.facebook_group" class="mr-4">
|
||||
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M24 12C24 5.37258 18.6274 0 12 0C5.37258 0 0 5.37258 0 12C0 17.9895 4.3882 22.954 10.125 23.8542V15.4688H7.07812V12H10.125V9.35625C10.125 6.34875 11.9166 4.6875 14.6576 4.6875C15.9701 4.6875 17.3438 4.92188 17.3438 4.92188V7.875H15.8306C14.34 7.875 13.875 8.80008 13.875 9.75V12H17.2031L16.6711 15.4688H13.875V23.8542C19.6118 22.954 24 17.9895 24 12Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a v-track.welcome_github_click target="_blank" :href="configLinks.github_url" class="mr-4">
|
||||
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M12 0C5.3724 0 0 5.3808 0 12.0204C0 17.3304 3.438 21.8364 8.2068 23.4252C8.8068 23.5356 9.0252 23.1648 9.0252 22.8456C9.0252 22.5612 9.0156 21.804 9.0096 20.802C5.6712 21.528 4.9668 19.1904 4.9668 19.1904C4.422 17.8008 3.6348 17.4312 3.6348 17.4312C2.5452 16.6872 3.7176 16.7016 3.7176 16.7016C4.9212 16.7856 5.5548 17.94 5.5548 17.94C6.6252 19.776 8.364 19.2456 9.0468 18.9384C9.1572 18.162 9.4668 17.6328 9.81 17.3328C7.146 17.0292 4.344 15.9972 4.344 11.3916C4.344 10.08 4.812 9.006 5.5788 8.166C5.4552 7.8624 5.0436 6.6396 5.6964 4.986C5.6964 4.986 6.7044 4.662 8.9964 6.2172C9.97532 5.95022 10.9853 5.81423 12 5.8128C13.02 5.8176 14.046 5.9508 15.0048 6.2172C17.2956 4.662 18.3012 4.9848 18.3012 4.9848C18.9564 6.6396 18.5436 7.8624 18.4212 8.166C19.1892 9.006 19.6548 10.08 19.6548 11.3916C19.6548 16.0092 16.848 17.0256 14.1756 17.3232C14.6064 17.694 14.9892 18.4272 14.9892 19.5492C14.9892 21.1548 14.9748 22.452 14.9748 22.8456C14.9748 23.1672 15.1908 23.5416 15.8004 23.424C18.19 22.6225 20.2672 21.0904 21.7386 19.0441C23.2099 16.9977 24.001 14.5408 24 12.0204C24 5.3808 18.6264 0 12 0Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="mt-12 text-white text-lg">
|
||||
The form below is an OpnForm, give it a try !
|
||||
</p>
|
||||
<div class="md:max-w-5xl md:mx-auto w-full bg-white rounded-md mt-6 p-4 shadow-lg">
|
||||
<iframe class="mt-4" style="border:none;width:100%;" height="470px"
|
||||
src="https://opnform.com/forms/opnform-contact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<open-form-footer class="dark:border-t border-t"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {computed} from 'vue'
|
||||
import {useAuthStore} from '../stores/auth'
|
||||
import Features from '~/components/pages/welcome/Features.vue'
|
||||
import MoreFeatures from '~/components/pages/welcome/MoreFeatures.vue'
|
||||
import PricingTable from '../components/pages/pricing/PricingTable.vue'
|
||||
import AiFeature from '~/components/pages/welcome/AiFeature.vue'
|
||||
import Testimonials from '../components/pages/welcome/Testimonials.vue'
|
||||
import TemplatesSlider from '../components/pages/welcome/TemplatesSlider.vue'
|
||||
import opnformConfig from "~/opnform.config.js";
|
||||
|
||||
export default {
|
||||
components: {Testimonials, Features, MoreFeatures, PricingTable, AiFeature, TemplatesSlider},
|
||||
layout: 'default',
|
||||
|
||||
setup() {
|
||||
const authStore = useAuthStore()
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
|
||||
return {
|
||||
authenticated: computed(() => authStore.check),
|
||||
config: opnformConfig,
|
||||
runtimeConfig: useRuntimeConfig()
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
}),
|
||||
|
||||
computed: {
|
||||
configLinks() {
|
||||
return this.config.links
|
||||
},
|
||||
paidPlansEnabled() {
|
||||
return this.runtimeConfig.public.paidPlansEnabled
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.customer-logo-container {
|
||||
max-width: 130px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ticks {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
@screen md {
|
||||
#macbook-video {
|
||||
position: absolute;
|
||||
max-width: 84.8% !important;
|
||||
right: 0px;
|
||||
top: 6.8%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
client/pages/login.vue
Normal file
67
client/pages/login.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex mt-6 mb-10">
|
||||
<div class="w-full md:max-w-6xl mx-auto px-4 flex md:flex-row-reverse flex-wrap">
|
||||
<div class="w-full md:w-1/2 md:p-6">
|
||||
<div class="border rounded-md p-6 shadow-md sticky top-4">
|
||||
<h2 class="font-semibold text-2xl">
|
||||
Login to OpnForm
|
||||
</h2>
|
||||
<small>Welcome back! Please enter your details.</small>
|
||||
|
||||
<login-form />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 md:p-6 mt-8 md:mt-0 ">
|
||||
<h1 class="font-bold">
|
||||
Create beautiful forms and share them anywhere
|
||||
</h1>
|
||||
<p class="text-gray-900 my-4 text-lg">
|
||||
It takes seconds, you don't need to know how to code and it's free.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<p class="px-3 pb-3 text-sm text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Unlimited forms
|
||||
</p>
|
||||
<p class="px-3 pb-3 text-sm text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Unlimited fields
|
||||
</p>
|
||||
<p class="px-3 pb-3 text-sm text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Unlimited submissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LoginForm from "~/components/pages/auth/components/LoginForm.vue"
|
||||
|
||||
definePageMeta({
|
||||
middleware: "guest"
|
||||
})
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
useOpnSeoMeta({
|
||||
title: 'Login'
|
||||
})
|
||||
</script>
|
||||
279
client/pages/pricing.vue
Normal file
279
client/pages/pricing.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="relative py-12 bg-gradient-to-b from-white to-gray-100 sm:py-16 lg:py-20 xl:py-24">
|
||||
<div class="relative px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<h1 class="text-4xl font-semibold tracking-tight text-gray-900 sm:text-5xl lg:text-6xl">
|
||||
Simple, transparent pricing. No surprises.
|
||||
</h1>
|
||||
<p
|
||||
class="max-w-2xl mx-auto mt-4 text-base font-medium leading-7 text-gray-500 sm:mt-5 sm:text-xl sm:leading-9">
|
||||
Just like our codebase, our pricing is 100% transparent. One flat price for all features. No hidden fees.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<pricing-table/>
|
||||
|
||||
<section class="py-12 bg-white sm:py-16 lg:py-24 xl:py-24">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<h2 class="text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl lg:leading-tight">
|
||||
<span class="text-blue-600">99%</span> of features are available to all users for free and without
|
||||
limits.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid max-w-5xl grid-cols-2 mx-auto mt-12 text-center gap-y-8 gap-x-4 sm:grid-cols-3 md:gap-x-12 md:text-left sm:mt-16">
|
||||
<div class="flex flex-col items-center gap-3 md:flex-row">
|
||||
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 9H21M7.8 3H16.2C17.8802 3 18.7202 3 19.362 3.32698C19.9265 3.6146 20.3854 4.07354 20.673 4.63803C21 5.27976 21 6.11984 21 7.8V16.2C21 17.8802 21 18.7202 20.673 19.362C20.3854 19.9265 19.9265 20.3854 19.362 20.673C18.7202 21 17.8802 21 16.2 21H7.8C6.11984 21 5.27976 21 4.63803 20.673C4.07354 20.3854 3.6146 19.9265 3.32698 19.362C3 18.7202 3 17.8802 3 16.2V7.8C3 6.11984 3 5.27976 3.32698 4.63803C3.6146 4.07354 4.07354 3.6146 4.63803 3.32698C5.27976 3 6.11984 3 7.8 3Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
|
||||
Unlimited forms
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-3 md:flex-row">
|
||||
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9 3.5V2M5.06066 5.06066L4 4M5.06066 13L4 14.0607M13 5.06066L14.0607 4M3.5 9H2M8.5 8.5L12.6111 21.2778L15.5 18.3889L19.1111 22L22 19.1111L18.3889 15.5L21.2778 12.6111L8.5 8.5Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
|
||||
Unlimited submissions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-3 md:flex-row">
|
||||
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M17.8 10C18.9201 10 19.4802 10 19.908 9.78201C20.2843 9.59027 20.5903 9.28431 20.782 8.90798C21 8.48016 21 7.92011 21 6.8V6.2C21 5.0799 21 4.51984 20.782 4.09202C20.5903 3.7157 20.2843 3.40973 19.908 3.21799C19.4802 3 18.9201 3 17.8 3L6.2 3C5.0799 3 4.51984 3 4.09202 3.21799C3.71569 3.40973 3.40973 3.71569 3.21799 4.09202C3 4.51984 3 5.07989 3 6.2L3 6.8C3 7.9201 3 8.48016 3.21799 8.90798C3.40973 9.28431 3.71569 9.59027 4.09202 9.78201C4.51984 10 5.07989 10 6.2 10L17.8 10Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path
|
||||
d="M17.8 21C18.9201 21 19.4802 21 19.908 20.782C20.2843 20.5903 20.5903 20.2843 20.782 19.908C21 19.4802 21 18.9201 21 17.8V17.2C21 16.0799 21 15.5198 20.782 15.092C20.5903 14.7157 20.2843 14.4097 19.908 14.218C19.4802 14 18.9201 14 17.8 14L6.2 14C5.0799 14 4.51984 14 4.09202 14.218C3.71569 14.4097 3.40973 14.7157 3.21799 15.092C3 15.5198 3 16.0799 3 17.2L3 17.8C3 18.9201 3 19.4802 3.21799 19.908C3.40973 20.2843 3.71569 20.5903 4.09202 20.782C4.51984 21 5.07989 21 6.2 21H17.8Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
|
||||
Unlimited fields
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-3 md:flex-row">
|
||||
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9 11L12 14L22 4M16 3H7.8C6.11984 3 5.27976 3 4.63803 3.32698C4.07354 3.6146 3.6146 4.07354 3.32698 4.63803C3 5.27976 3 6.11984 3 7.8V16.2C3 17.8802 3 18.7202 3.32698 19.362C3.6146 19.9265 4.07354 20.3854 4.63803 20.673C5.27976 21 6.11984 21 7.8 21H16.2C17.8802 21 18.7202 21 19.362 20.673C19.9265 20.3854 20.3854 19.9265 20.673 19.362C21 18.7202 21 17.8802 21 16.2V12"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
|
||||
Multiple input types
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-3 md:flex-row">
|
||||
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M22 11V8.2C22 7.0799 22 6.51984 21.782 6.09202C21.5903 5.71569 21.2843 5.40973 20.908 5.21799C20.4802 5 19.9201 5 18.8 5H5.2C4.0799 5 3.51984 5 3.09202 5.21799C2.71569 5.40973 2.40973 5.71569 2.21799 6.09202C2 6.51984 2 7.0799 2 8.2V11.8C2 12.9201 2 13.4802 2.21799 13.908C2.40973 14.2843 2.71569 14.5903 3.09202 14.782C3.51984 15 4.0799 15 5.2 15H11M12 10H12.005M17 10H17.005M7 10H7.005M19.25 17V15.25C19.25 14.2835 18.4665 13.5 17.5 13.5C16.5335 13.5 15.75 14.2835 15.75 15.25V17M12.25 10C12.25 10.1381 12.1381 10.25 12 10.25C11.8619 10.25 11.75 10.1381 11.75 10C11.75 9.86193 11.8619 9.75 12 9.75C12.1381 9.75 12.25 9.86193 12.25 10ZM17.25 10C17.25 10.1381 17.1381 10.25 17 10.25C16.8619 10.25 16.75 10.1381 16.75 10C16.75 9.86193 16.8619 9.75 17 9.75C17.1381 9.75 17.25 9.86193 17.25 10ZM7.25 10C7.25 10.1381 7.13807 10.25 7 10.25C6.86193 10.25 6.75 10.1381 6.75 10C6.75 9.86193 6.86193 9.75 7 9.75C7.13807 9.75 7.25 9.86193 7.25 10ZM15.6 21H19.4C19.9601 21 20.2401 21 20.454 20.891C20.6422 20.7951 20.7951 20.6422 20.891 20.454C21 20.2401 21 19.9601 21 19.4V18.6C21 18.0399 21 17.7599 20.891 17.546C20.7951 17.3578 20.6422 17.2049 20.454 17.109C20.2401 17 19.9601 17 19.4 17H15.6C15.0399 17 14.7599 17 14.546 17.109C14.3578 17.2049 14.2049 17.3578 14.109 17.546C14 17.7599 14 18.0399 14 18.6V19.4C14 19.9601 14 20.2401 14.109 20.454C14.2049 20.6422 14.3578 20.7951 14.546 20.891C14.7599 21 15.0399 21 15.6 21Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
|
||||
Form password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-3 md:flex-row">
|
||||
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M18 15C16.3431 15 15 16.3431 15 18C15 19.6569 16.3431 21 18 21C19.6569 21 21 19.6569 21 18C21 16.3431 19.6569 15 18 15ZM18 15V8C18 7.46957 17.7893 6.96086 17.4142 6.58579C17.0391 6.21071 16.5304 6 16 6H13M6 9C7.65685 9 9 7.65685 9 6C9 4.34315 7.65685 3 6 3C4.34315 3 3 4.34315 3 6C3 7.65685 4.34315 9 6 9ZM6 9V21"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
|
||||
Webhooks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-3 md:flex-row">
|
||||
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M21 8H3M16 2V5M8 2V5M12 18V12M9 15H15M7.8 22H16.2C17.8802 22 18.7202 22 19.362 21.673C19.9265 21.3854 20.3854 20.9265 20.673 20.362C21 19.7202 21 18.8802 21 17.2V8.8C21 7.11984 21 6.27976 20.673 5.63803C20.3854 5.07354 19.9265 4.6146 19.362 4.32698C18.7202 4 17.8802 4 16.2 4H7.8C6.11984 4 5.27976 4 4.63803 4.32698C4.07354 4.6146 3.6146 5.07354 3.32698 5.63803C3 6.27976 3 7.11984 3 8.8V17.2C3 18.8802 3 19.7202 3.32698 20.362C3.6146 20.9265 4.07354 21.3854 4.63803 21.673C5.27976 22 6.11984 22 7.8 22Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
|
||||
Closing date
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center col-span-2 gap-3 md:flex-row sm:col-span-1">
|
||||
<svg aria-hidden="true" class="w-6 h-6 shrink-0 stroke-blue-600" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 6C12.5523 6 13 5.55228 13 5C13 4.44772 12.5523 4 12 4C11.4477 4 11 4.44772 11 5C11 5.55228 11.4477 6 12 6Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path
|
||||
d="M12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path
|
||||
d="M12 20C12.5523 20 13 19.5523 13 19C13 18.4477 12.5523 18 12 18C11.4477 18 11 18.4477 11 19C11 19.5523 11.4477 20 12 20Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path
|
||||
d="M19 6C19.5523 6 20 5.55228 20 5C20 4.44772 19.5523 4 19 4C18.4477 4 18 4.44772 18 5C18 5.55228 18.4477 6 19 6Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path
|
||||
d="M19 13C19.5523 13 20 12.5523 20 12C20 11.4477 19.5523 11 19 11C18.4477 11 18 11.4477 18 12C18 12.5523 18.4477 13 19 13Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path
|
||||
d="M19 20C19.5523 20 20 19.5523 20 19C20 18.4477 19.5523 18 19 18C18.4477 18 18 18.4477 18 19C18 19.5523 18.4477 20 19 20Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path
|
||||
d="M5 6C5.55228 6 6 5.55228 6 5C6 4.44772 5.55228 4 5 4C4.44772 4 4 4.44772 4 5C4 5.55228 4.44772 6 5 6Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path
|
||||
d="M5 13C5.55228 13 6 12.5523 6 12C6 11.4477 5.55228 11 5 11C4.44772 11 4 11.4477 4 12C4 12.5523 4.44772 13 5 13Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path
|
||||
d="M5 20C5.55228 20 6 19.5523 6 19C6 18.4477 5.55228 18 5 18C4.44772 18 4 18.4477 4 19C4 19.5523 4.44772 20 5 20Z"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<p class="text-base font-semibold sm:text-lg lg:text-xl text-gray-950">
|
||||
And much more...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-3xl p-6 mx-auto mt-12 sm:mt-16 bg-yellow-50 ring-1 ring-inset ring-yellow-200 rounded-2xl">
|
||||
<div class="flex items-start gap-4">
|
||||
<svg aria-hidden="true" class="w-8 h-8 shrink-0 stroke-yellow-500" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 21L11.8999 20.8499C11.2053 19.808 10.858 19.287 10.3991 18.9098C9.99286 18.5759 9.52476 18.3254 9.02161 18.1726C8.45325 18 7.82711 18 6.57482 18H5.2C4.07989 18 3.51984 18 3.09202 17.782C2.71569 17.5903 2.40973 17.2843 2.21799 16.908C2 16.4802 2 15.9201 2 14.8V6.2C2 5.07989 2 4.51984 2.21799 4.09202C2.40973 3.71569 2.71569 3.40973 3.09202 3.21799C3.51984 3 4.07989 3 5.2 3H5.6C7.84021 3 8.96031 3 9.81596 3.43597C10.5686 3.81947 11.1805 4.43139 11.564 5.18404C12 6.03968 12 7.15979 12 9.4M12 21V9.4M12 21L12.1001 20.8499C12.7947 19.808 13.142 19.287 13.6009 18.9098C14.0071 18.5759 14.4752 18.3254 14.9784 18.1726C15.5467 18 16.1729 18 17.4252 18H18.8C19.9201 18 20.4802 18 20.908 17.782C21.2843 17.5903 21.5903 17.2843 21.782 16.908C22 16.4802 22 15.9201 22 14.8V6.2C22 5.07989 22 4.51984 21.782 4.09202C21.5903 3.71569 21.2843 3.40973 20.908 3.21799C20.4802 3 19.9201 3 18.8 3H18.4C16.1598 3 15.0397 3 14.184 3.43597C13.4314 3.81947 12.8195 4.43139 12.436 5.18404C12 6.03968 12 7.15979 12 9.4"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-yellow-600">
|
||||
Nonprofit & Student Discount — 50%
|
||||
</p>
|
||||
<p class="mt-1 text-base font-medium leading-7 text-yellow-600">
|
||||
Whether your nonprofit is large or small, OpnForm’s online Form Builder helps your organization help
|
||||
others. It takes just a few minutes to create and publish your forms online. As an exclusive benefit,
|
||||
we offer nonprofits & students a 50-percent discount!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="py-12 bg-gray-50 border-t border-gray-200 sm:py-16 lg:py-20 xl:py-24">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<h2 class="text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl lg:leading-tight">
|
||||
Got any question?
|
||||
</h2>
|
||||
<p class="max-w-2xl mx-auto mt-4 text-base font-medium leading-7 text-gray-600 sm:text-lg sm:leading-8">
|
||||
We've compiled a list of the most common questions we get asked.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl
|
||||
class="grid max-w-5xl grid-cols-1 mx-auto mt-12 gap-y-12 sm:grid-cols-2 sm:mt-16 sm:gap-x-8 sm:gap-y-12 lg:gap-x-10">
|
||||
<div v-for="q in [
|
||||
{
|
||||
'question':'Is there a free version of OpnForm available?',
|
||||
'answer':'Yes, OpnForm offers a free version with access to 99% of features, including unlimited forms, submissions, fields, and more. Our goal is to provide robust functionality for all users without limitations.',
|
||||
},
|
||||
{
|
||||
'question':'What does the Pro Plan include?',
|
||||
'answer':'The OpnForm Pro Plan is designed to meet the advanced needs of teams and creators. It includes features like form confirmation emails, Slack and Discord notifications, editable submissions, custom domain, custom code integration, larger file uploads, removal of OpnForm branding, priority support, and more.',
|
||||
},
|
||||
{
|
||||
'question':'Can I try the Pro Plan before subscribing?',
|
||||
'answer':'Absolutely! We offer a free 3-day trial of the OpnForm Pro Plan. This allows you to explore all the empowering features and experience the value it brings to your form-building process. The trial is automatically applied.',
|
||||
},
|
||||
{
|
||||
'question':'Is there a discount for annual plans?',
|
||||
'answer':'Yes, we offer a 20% discount for annual Pro Plan subscriptions. By choosing the yearly billing option, you can enjoy the same great features at a reduced cost.',
|
||||
},
|
||||
{
|
||||
'question':'How does the nonprofit and student discount work?',
|
||||
'answer':'OpnForm is committed to supporting nonprofits and students. We provide an exclusive 50% discount on the Pro Plan for nonprofit organizations and students. This discount helps you make the most of our form builder while staying within your budget.',
|
||||
},
|
||||
{
|
||||
'question':'Can I cancel or change my plan at any time?',
|
||||
'answer':'Yes, you have the flexibility to upgrade, downgrade, or cancel your OpnForm Pro Plan at any time. Changes will take effect immediately, and you\'ll only be billed based on the plan you\'re currently on.',
|
||||
},
|
||||
]" :key="q.question">
|
||||
<dt class="text-base font-semibold leading-7 text-gray-950 sm:text-lg sm:leading-8">
|
||||
{{ q.question }}
|
||||
</dt>
|
||||
<dd class="mt-2 text-base font-medium leading-7 text-gray-600">
|
||||
{{ q.answer }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-12 text-center sm:mt-16">
|
||||
<p class="text-base font-medium text-gray-950">
|
||||
Didn't find the answer? <a href="#" @click.prevent="contactUs"
|
||||
class="font-semibold text-blue-600 hover:underline">Contact us</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<open-form-footer/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import PricingTable from '../components/pages/pricing/PricingTable.vue'
|
||||
|
||||
export default {
|
||||
components: {PricingTable},
|
||||
layout: 'default',
|
||||
|
||||
setup () {
|
||||
useOpnSeoMeta({
|
||||
title: 'Pricing',
|
||||
description: 'All of our core features are free, and there is no quantity limit. You can also created more advanced and customized forms with OpnForms Pro.'
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: [
|
||||
function (to, from) {
|
||||
// Custom inline middleware
|
||||
if (!useRuntimeConfig().public.paidPlansEnabled) { // If no paid plan so no need this page
|
||||
return navigateTo('/', { redirectCode: 301 })
|
||||
}
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
return {
|
||||
user : computed(() => authStore.user),
|
||||
authenticated : computed(() => authStore.check)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
contactUs() {
|
||||
window.$crisp.push(['do', 'chat:show'])
|
||||
window.$crisp.push(['do', 'chat:open'])
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
31
client/pages/privacy-policy.vue
Normal file
31
client/pages/privacy-policy.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mt-6 flex flex-col">
|
||||
<div class="w-full md:max-w-3xl md:mx-auto px-4 md:pt-16">
|
||||
<h1 class="sm:text-5xl">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<NotionPage :block-map="blockMap" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useNotionPagesStore} from "~/stores/notion_pages.js";
|
||||
import {computed} from "vue";
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Privacy Policy'
|
||||
})
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
|
||||
const notionPageStore = useNotionPagesStore()
|
||||
await notionPageStore.load('9c97349ceda7455aab9b341d1ff70f79')
|
||||
|
||||
const loading = computed(() => notionPageStore.loading)
|
||||
const blockMap = computed(() => notionPageStore.getByKey('9c97349ceda7455aab9b341d1ff70f79'))
|
||||
</script>
|
||||
87
client/pages/register.vue
Normal file
87
client/pages/register.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex mt-6 mb-10">
|
||||
<div class="w-full md:max-w-6xl mx-auto px-4 flex items-center md:flex-row-reverse flex-wrap">
|
||||
<div class="w-full lg:w-1/2 md:p-6">
|
||||
<app-sumo-register class="mb-10 p-6 lg:hidden" />
|
||||
<div class="border rounded-md p-6 shadow-md sticky top-4">
|
||||
<h2 class="font-semibold text-2xl">
|
||||
Create an account
|
||||
</h2>
|
||||
<small>Sign up in less than 2 minutes.</small>
|
||||
<register-form />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full hidden lg:block lg:w-1/2 md:p-6 mt-8 md:mt-0 ">
|
||||
<app-sumo-register class="mb-10" />
|
||||
<h1 class="font-bold">
|
||||
Create beautiful forms and share them anywhere
|
||||
</h1>
|
||||
<p class="text-gray-900 my-4 text-lg">
|
||||
It takes seconds, you don't need to know how to code and it's free.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<p class="px-3 pb-3 text-sm text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Unlimited forms
|
||||
</p>
|
||||
<p class="px-3 pb-3 text-sm text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Unlimited fields
|
||||
</p>
|
||||
<p class="px-3 pb-3 text-sm text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Unlimited submissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RegisterForm from "~/components/pages/auth/components/RegisterForm.vue"
|
||||
import AppSumoRegister from "~/components/vendor/appsumo/AppSumoRegister.vue"
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AppSumoRegister,
|
||||
RegisterForm
|
||||
},
|
||||
|
||||
setup() {
|
||||
useOpnSeoMeta({
|
||||
title: 'Register'
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: "guest"
|
||||
})
|
||||
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
}),
|
||||
|
||||
computed: {},
|
||||
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
90
client/pages/settings.vue
Normal file
90
client/pages/settings.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="bg-white">
|
||||
<div class="flex bg-gray-50">
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4">
|
||||
<div class="pt-4 pb-0">
|
||||
<div class="flex">
|
||||
<h2 class="flex-grow text-gray-900">
|
||||
My Account
|
||||
</h2>
|
||||
</div>
|
||||
<ul class="flex text-gray-500">
|
||||
<li>{{ user.email }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center">
|
||||
<li v-for="(tab, i) in tabsList" :key="i+1" class="mr-6">
|
||||
<nuxt-link :to="{ name: tab.route }"
|
||||
class="hover:no-underline inline-block py-4 rounded-t-lg border-b-2 text-gray-500 hover:text-gray-600"
|
||||
active-class="text-blue-600 hover:text-blue-900 dark:text-blue-500 dark:hover:text-blue-500 border-blue-600 dark:border-blue-500"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex bg-white">
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4">
|
||||
<div class="mt-8 pb-0">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Settings'
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const user = computed(() => authStore.user)
|
||||
const tabsList = computed(() => {
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Profile',
|
||||
route: 'settings-profile'
|
||||
},
|
||||
{
|
||||
name: 'Workspace Settings',
|
||||
route: 'settings-workspace'
|
||||
},
|
||||
{
|
||||
name: 'Password',
|
||||
route: 'settings-password'
|
||||
},
|
||||
{
|
||||
name: 'Delete Account',
|
||||
route: 'settings-account'
|
||||
}
|
||||
]
|
||||
|
||||
if (user.value.is_subscribed) {
|
||||
tabs.splice(1, 0, {
|
||||
name: 'Billing',
|
||||
route: 'settings-billing'
|
||||
})
|
||||
}
|
||||
|
||||
if (user.value.admin) {
|
||||
tabs.push({
|
||||
name: 'Admin',
|
||||
route: 'settings-admin'
|
||||
})
|
||||
}
|
||||
|
||||
return tabs
|
||||
})
|
||||
</script>
|
||||
48
client/pages/settings/account.vue
Normal file
48
client/pages/settings/account.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="font-semibold text-2xl text-gray-900">Danger zone</h3>
|
||||
<p class="text-gray-600 text-sm mt-2">
|
||||
This will permanently delete your entire account. All your forms, submissions and workspaces will be deleted.
|
||||
<span class="text-red-500">
|
||||
This cannot be undone.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button :loading="loading" class="mt-4" color="red" @click="useAlert().confirm('Do you really want to delete your account?',deleteAccount)">
|
||||
Delete account
|
||||
</v-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
let loading = false
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Account'
|
||||
})
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
|
||||
const deleteAccount = () => {
|
||||
loading = true
|
||||
opnFetch('/user', {method:'DELETE'}).then(async (data) => {
|
||||
loading = false
|
||||
useAlert().success(data.message)
|
||||
|
||||
// Log out the user.
|
||||
await authStore.logout()
|
||||
|
||||
// Redirect to login.
|
||||
router.push({ name: 'login' })
|
||||
}).catch((error) => {
|
||||
useAlert().error(error.response.data.message)
|
||||
loading = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
80
client/pages/settings/admin.vue
Normal file
80
client/pages/settings/admin.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="font-semibold text-2xl text-gray-900">Admin settings</h3>
|
||||
<small class="text-gray-600">Manage settings.</small>
|
||||
|
||||
|
||||
<h3 class="mt-3 text-lg font-semibold mb-4">
|
||||
Tools
|
||||
</h3>
|
||||
<div class="flex flex-wrap mb-5">
|
||||
<a :href="statsUrl" target="_blank">
|
||||
<v-button class="mx-1" color="gray" shade="lighter">
|
||||
Stats
|
||||
</v-button>
|
||||
</a>
|
||||
<a :href="horizonUrl" target="_blank">
|
||||
<v-button class="mx-1" color="gray" shade="lighter">
|
||||
Horizon
|
||||
</v-button>
|
||||
</a>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-4">
|
||||
Impersonate User
|
||||
</h3>
|
||||
<form @submit.prevent="impersonate" @keydown="form.onKeydown($event)">
|
||||
<!-- Password -->
|
||||
<text-input name="identifier" :form="form" label="Identifier"
|
||||
:required="true" help="User Id, User Email or Form Slug"
|
||||
/>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button :loading="loading" class="mt-4">Impersonate User</v-button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
definePageMeta({
|
||||
middleware: "admin"
|
||||
})
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Admin'
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
const router = useRouter()
|
||||
let form = useForm({
|
||||
identifier: ''
|
||||
})
|
||||
let loading = false
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const statsUrl = runtimeConfig.public.apiBase + '/stats'
|
||||
const horizonUrl = runtimeConfig.public.apiBase + '/horizon'
|
||||
|
||||
const impersonate = () => {
|
||||
loading = true
|
||||
authStore.startImpersonating()
|
||||
opnFetch('/admin/impersonate/' + encodeURI(form.identifier)).then(async (data) => {
|
||||
loading = false
|
||||
|
||||
// Save the token.
|
||||
authStore.saveToken(data.token, false)
|
||||
|
||||
// Fetch the user.
|
||||
await authStore.fetchUser()
|
||||
|
||||
// Redirect to the dashboard.
|
||||
workspacesStore.set([])
|
||||
router.push({ name: 'home' })
|
||||
}).catch((error) => {
|
||||
useAlert().error(error.response.data.message)
|
||||
loading = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
49
client/pages/settings/billing.vue
Normal file
49
client/pages/settings/billing.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="font-semibold text-2xl text-gray-900">
|
||||
Billing details
|
||||
</h3>
|
||||
|
||||
<template v-if="user.has_customer_id">
|
||||
<small class="text-gray-600">Manage your billing. Download invoices, update your plan, or cancel it at any
|
||||
time.</small>
|
||||
|
||||
<div class="mt-4">
|
||||
<v-button color="gray" shade="light" :loading="billingLoading" @click.prevent="openBillingDashboard">
|
||||
Manage Subscription
|
||||
</v-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<app-sumo-billing class="mt-4" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppSumoBilling from '../../components/vendor/appsumo/AppSumoBilling.vue'
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Billing'
|
||||
})
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
let user = computed(() => authStore.user)
|
||||
let billingLoading = false
|
||||
|
||||
const openBillingDashboard = () => {
|
||||
billingLoading = true
|
||||
opnFetch('/subscription/billing-portal').then((data) => {
|
||||
const url = data.portal_url
|
||||
window.location = url
|
||||
}).catch((error) => {
|
||||
useAlert().error(error.response.data.message)
|
||||
}).finally(() => {
|
||||
billingLoading = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
7
client/pages/settings/index.vue
Normal file
7
client/pages/settings/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
redirect: to => {
|
||||
return { name: 'settings-profile'}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
46
client/pages/settings/password.vue
Normal file
46
client/pages/settings/password.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="font-semibold text-2xl text-gray-900">
|
||||
Password
|
||||
</h3>
|
||||
<small class="text-gray-600">Manage your password.</small>
|
||||
|
||||
<form class="mt-3" @submit.prevent="update" @keydown="form.onKeydown($event)">
|
||||
<!-- Password -->
|
||||
<text-input native-type="password"
|
||||
name="password" :form="form" label="Password" :required="true"
|
||||
/>
|
||||
|
||||
<!-- Password Confirmation-->
|
||||
<text-input native-type="password"
|
||||
name="password_confirmation" :form="form" label="Confirm Password" :required="true"
|
||||
/>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button :loading="form.busy" class="mt-4">
|
||||
Update password
|
||||
</v-button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
useOpnSeoMeta({
|
||||
title: 'Password'
|
||||
})
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
|
||||
let form = useForm({
|
||||
password: '',
|
||||
password_confirmation: ''
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
form.patch('/settings/password').then((response) => {
|
||||
form.reset()
|
||||
useAlert().success('Password updated.')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
52
client/pages/settings/profile.vue
Normal file
52
client/pages/settings/profile.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="font-semibold text-2xl text-gray-900">
|
||||
Profile details
|
||||
</h3>
|
||||
<small class="text-gray-600">Update your username and manage your account details.</small>
|
||||
|
||||
<form class="mt-3" @submit.prevent="update" @keydown="form.onKeydown($event)">
|
||||
<!-- Name -->
|
||||
<text-input name="name" :form="form" label="Name" :required="true" />
|
||||
|
||||
<!-- Email -->
|
||||
<text-input name="email" :form="form" label="Email" :required="true" />
|
||||
|
||||
<!-- Submit Button -->
|
||||
<v-button :loading="form.busy" class="mt-4">
|
||||
Save changes
|
||||
</v-button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const authStore = useAuthStore()
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Profile'
|
||||
})
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
|
||||
let form = useForm({
|
||||
name: '',
|
||||
email: ''
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
form.patch('/settings/profile').then((response) => {
|
||||
authStore.updateUser(response)
|
||||
useAlert().success('Your info has been updated!')
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
// Fill the form with user data.
|
||||
form.keys().forEach(key => {
|
||||
form[key] = user.value[key]
|
||||
})
|
||||
})
|
||||
</script>
|
||||
207
client/pages/settings/workspace.vue
Normal file
207
client/pages/settings/workspace.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-y-4 flex-wrap-reverse">
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold text-2xl text-gray-900">
|
||||
Workspace settings
|
||||
</h3>
|
||||
<small class="text-gray-600">Manage your workspaces.</small>
|
||||
</div>
|
||||
<v-button color="outline-blue" :loading="loading" @click="workspaceModal=true">
|
||||
<svg class="inline -mt-1 mr-1 h-4 w-4" viewBox="0 0 14 14" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M6.99996 1.16699V12.8337M1.16663 7.00033H12.8333" stroke="currentColor" stroke-width="1.67"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Create new workspace
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="w-full text-blue-500 text-center">
|
||||
<Loader class="h-10 w-10 p-5" />
|
||||
</div>
|
||||
<div v-else-if="workspace">
|
||||
<div class="mt-4 flex group bg-white items-center">
|
||||
<div class="flex space-x-4 flex-grow items-center">
|
||||
<img v-if="isUrl(workspace.icon)" :src="workspace.icon" :alt="workspace.name + ' icon'"
|
||||
class="rounded-full h-12 w-12"
|
||||
/>
|
||||
<div v-else class="rounded-2xl bg-gray-100 h-12 w-12 text-2xl pt-2 text-center overflow-hidden"
|
||||
v-text="workspace.icon"
|
||||
/>
|
||||
<div class="space-y-4 py-1">
|
||||
<div class="font-bold truncate">
|
||||
{{ workspace.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="customDomainsEnabled">
|
||||
<text-area-input v-model="customDomains" name="custom_domain" class="mt-4" :required="false"
|
||||
:disabled="!workspace.is_pro"
|
||||
label="Workspace Custom Domains" wrapper-class="" placeholder="yourdomain.com - 1 per line"
|
||||
/>
|
||||
<p class="text-gray-500 text-sm">
|
||||
Read our <a href="#"
|
||||
@click.prevent="crisp.openHelpdeskArticle('how-to-use-my-own-domain-9m77g7')"
|
||||
>custom domain instructions</a> to learn how to use your own domain.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-wrap justify-between gap-2 mt-4">
|
||||
<v-button v-if="customDomainsEnabled" class="w-full sm:w-auto" :loading="customDomainsLoading"
|
||||
@click="saveChanges">
|
||||
<svg class="w-4 h-4 text-white inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17 21V13H7V21M7 3V8H15M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H16L21 8V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Save Domains
|
||||
</v-button>
|
||||
<v-button v-if="workspaces.length > 1" color="white" class="group w-full sm:w-auto" :loading="loading"
|
||||
@click="deleteWorkspace(workspace.id)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -mt-1 inline group-hover:text-red-700" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Remove workspace
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace modal -->
|
||||
<modal :show="workspaceModal" max-width="lg" @close="workspaceModal=false">
|
||||
<template #icon>
|
||||
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 8V16M8 12H16M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template #title>
|
||||
Create Workspace
|
||||
</template>
|
||||
<div class="px-4">
|
||||
<form @submit.prevent="createWorkspace" @keydown="form.onKeydown($event)">
|
||||
<div>
|
||||
<text-input name="name" class="mt-4" :form="form" :required="true"
|
||||
label="Workspace Name"
|
||||
/>
|
||||
<text-input name="emoji" class="mt-4" :form="form" :required="false"
|
||||
label="Emoji"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-6">
|
||||
<v-button :loading="form.busy" class="w-full my-3">
|
||||
Save
|
||||
</v-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {watch} from "vue";
|
||||
import {fetchAllWorkspaces} from "~/stores/workspaces.js";
|
||||
|
||||
const crisp = useCrisp()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
const workspaces = computed(() => workspacesStore.getAll)
|
||||
let loading = computed(() => workspacesStore.loading)
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Workspaces'
|
||||
})
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
|
||||
let form = useForm({
|
||||
name: '',
|
||||
emoji: ''
|
||||
})
|
||||
let workspaceModal = ref(false)
|
||||
let customDomains = ''
|
||||
let customDomainsLoading = ref(false)
|
||||
|
||||
let workspace = computed(() => workspacesStore.getCurrent)
|
||||
let customDomainsEnabled = computed(() => useRuntimeConfig().public.customDomainsEnabled)
|
||||
|
||||
watch(() => workspace, () => {
|
||||
initCustomDomains()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchAllWorkspaces()
|
||||
initCustomDomains()
|
||||
})
|
||||
|
||||
const saveChanges = () => {
|
||||
if (customDomainsLoading.value) return
|
||||
customDomainsLoading.value = true
|
||||
// Update the workspace custom domain
|
||||
opnFetch('/open/workspaces/' + workspace.value.id + '/custom-domains', {
|
||||
method: 'PUT',
|
||||
custom_domains: customDomains.split('\n')
|
||||
.map(domain => domain ? domain.trim() : null)
|
||||
.filter(domain => domain && domain.length > 0)
|
||||
}).then((data) => {
|
||||
workspacesStore.addOrUpdate(data)
|
||||
useAlert().success('Custom domains saved.')
|
||||
}).catch((error) => {
|
||||
useAlert().error('Failed to update custom domains: ' + error.response.data.message)
|
||||
}).finally(() => {
|
||||
customDomainsLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const initCustomDomains = () => {
|
||||
if (!workspace || !workspace.value.custom_domains) return
|
||||
customDomains = workspace.value.custom_domains.join('\n')
|
||||
}
|
||||
|
||||
const deleteWorkspace = (workspaceId) => {
|
||||
if (workspaces.length <= 1) {
|
||||
useAlert().error('You cannot delete your only workspace.')
|
||||
return
|
||||
}
|
||||
useAlert().confirm('Do you really want to delete this workspace? All forms created in this workspace will be removed.', () => {
|
||||
opnFetch('/open/workspaces/' + workspaceId, {method: 'DELETE'}).then((data) => {
|
||||
useAlert().success('Workspace successfully removed.')
|
||||
workspacesStore.remove(workspaceId)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const isUrl = (str) => {
|
||||
const pattern = new RegExp('^(https?:\\/\\/)?' + // protocol
|
||||
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
|
||||
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
|
||||
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
|
||||
'(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
|
||||
'(\\#[-a-z\\d_]*)?$', 'i') // fragment locator
|
||||
return !!pattern.test(str)
|
||||
}
|
||||
const createWorkspace = () => {
|
||||
form.post('/open/workspaces/create').then((response) => {
|
||||
fetchAllWorkspaces()
|
||||
workspaceModal.value = false
|
||||
useAlert().success('Workspace successfully created.')
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
33
client/pages/subscriptions/error.vue
Normal file
33
client/pages/subscriptions/error.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template />
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
export default {
|
||||
components: { },
|
||||
layout: 'default',
|
||||
middleware: 'auth',
|
||||
|
||||
setup () {
|
||||
useOpnSeoMeta({
|
||||
title: 'Error'
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
return {
|
||||
authenticated : computed(() => authStore.check),
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
}),
|
||||
|
||||
mounted () {
|
||||
this.$router.push({ name: 'pricing' })
|
||||
useAlert().error('Unfortunately we could not confirm your subscription. Please try again and contact us if the issue persists.')
|
||||
},
|
||||
|
||||
computed: {}
|
||||
}
|
||||
</script>
|
||||
77
client/pages/subscriptions/success.vue
Normal file
77
client/pages/subscriptions/success.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<div class="w-full md:max-w-3xl md:mx-auto px-4 mb-10 md:pb-20 md:pt-16 text-center flex-grow">
|
||||
<h1 class="text-4xl font-semibold">
|
||||
Thank you!
|
||||
</h1>
|
||||
<h4 class="text-xl mt-6">
|
||||
We're checking the status of your subscription please wait a moment...
|
||||
</h4>
|
||||
<div class="text-center">
|
||||
<Loader class="h-6 w-6 text-nt-blue mx-auto mt-20" />
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
export default {
|
||||
layout: 'default',
|
||||
middleware: 'auth',
|
||||
|
||||
setup () {
|
||||
useOpnSeoMeta({
|
||||
title: 'Subscription Success'
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
return {
|
||||
authStore,
|
||||
authenticated : computed(() => authStore.check),
|
||||
user : computed(() => authStore.user)
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
interval: null
|
||||
}),
|
||||
|
||||
mounted () {
|
||||
this.redirectIfSubscribed()
|
||||
this.interval = setInterval(() => this.checkSubscription(), 5000)
|
||||
},
|
||||
|
||||
beforeUnmount () {
|
||||
clearInterval(this.interval)
|
||||
},
|
||||
|
||||
methods: {
|
||||
async checkSubscription () {
|
||||
// Fetch the user.
|
||||
await this.authStore.fetchUser()
|
||||
this.redirectIfSubscribed()
|
||||
},
|
||||
redirectIfSubscribed () {
|
||||
if (this.user.is_subscribed) {
|
||||
useAmplitude().logEvent('subscribed', { plan: this.user.has_enterprise_subscription ? 'enterprise' : 'pro' })
|
||||
this.$crisp.push(['set', 'session:event', [[['subscribed', { plan: this.user.has_enterprise_subscription ? 'enterprise' : 'pro' }, 'blue']]]])
|
||||
this.$router.push({ name: 'home' })
|
||||
|
||||
if (this.user.has_enterprise_subscription) {
|
||||
useAlert().success('Awesome! Your subscription to OpnForm is now confirmed! You now have access to all Enterprise ' +
|
||||
'features. No need to invite your teammates, just ask them to create a OpnForm account and to connect the same Notion workspace. Feel free to contact us if you have any question 🙌')
|
||||
} else {
|
||||
useAlert().success('Awesome! Your subscription to OpnForm is now confirmed! You now have access to all Pro ' +
|
||||
'features. Feel free to contact us if you have any question 🙌')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {}
|
||||
}
|
||||
</script>
|
||||
308
client/pages/templates/[slug].vue
Normal file
308
client/pages/templates/[slug].vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<breadcrumb :path="breadcrumbs" v-if="template">
|
||||
<template #left>
|
||||
<div v-if="canEditTemplate" class="ml-5">
|
||||
<v-button color="gray" size="small" @click.prevent="showFormTemplateModal=true">
|
||||
Edit Template
|
||||
</v-button>
|
||||
<form-template-modal v-if="form" :form="form" :template="template" :show="showFormTemplateModal"
|
||||
@close="showFormTemplateModal=false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #right>
|
||||
<v-button v-if="canEditTemplate" v-track.copy_template_button_clicked size="small" color="white" class="mr-5"
|
||||
@click.prevent="copyTemplateUrl"
|
||||
>
|
||||
Copy Template URL
|
||||
</v-button>
|
||||
<v-button v-track.use_template_button_clicked size="small" class="mr-5"
|
||||
:to="createFormWithTemplateUrl"
|
||||
>
|
||||
Use this template
|
||||
</v-button>
|
||||
</template>
|
||||
</breadcrumb>
|
||||
|
||||
<p v-if="template === null || !template" class="text-center my-4">
|
||||
We could not find this template.
|
||||
</p>
|
||||
<template v-else>
|
||||
<section class="pt-12 bg-gray-50 sm:pt-16 border-b pb-[250px] relative">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div class="flex flex-col items-center justify-center max-w-4xl gap-8 mx-auto md:gap-12 md:flex-row">
|
||||
<div class="aspect-[4/3] shrink-0 rounded-lg shadow-sm overflow-hidden group max-w-xs">
|
||||
<img class="object-cover w-full h-full transition-all duration-200 group-hover:scale-110"
|
||||
:src="template.image_url" alt="Template cover image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 text-center md:text-left relative">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
|
||||
{{ template.name }}
|
||||
</h1>
|
||||
<p class="mt-2 text-lg font-normal text-gray-600">
|
||||
{{ cleanQuotes(template.short_description) }}
|
||||
</p>
|
||||
<template-tags :template="template" :display-all="true"
|
||||
class="flex flex-wrap items-center justify-center gap-3 mt-4 md:justify-start"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="relative px-4 mx-auto sm:px-6 lg:px-8 -mt-[210px]">
|
||||
<div class="max-w-7xl">
|
||||
<div
|
||||
class="max-w-2xl p-4 mx-auto bg-white shadow-lg sm:p-6 lg:p-8 rounded-xl ring-1 ring-inset ring-gray-200 isolate"
|
||||
>
|
||||
<p class="text-sm font-medium text-center text-gray-500 -mt-2 mb-2">
|
||||
Template Preview
|
||||
</p>
|
||||
<open-complete-form ref="open-complete-form" :form="form" :creating="true"
|
||||
class="mb-4 p-4 bg-gray-50 border border-gray-200 border-dashed rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 translate-y-full inset-x-0">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl -mt-[20px]">
|
||||
<div class="flex items-center justify-center">
|
||||
<v-button v-track.use_template_button_clicked class="mx-auto w-full max-w-[300px]"
|
||||
:to="createFormWithTemplateUrl">
|
||||
Use this template
|
||||
</v-button>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="text-left mx-auto text-gray-500 text-xs mt-4">
|
||||
✓ Core features 100% free<br>
|
||||
✓ No credit card required<br>
|
||||
✓ No submissions limit on Free plan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pt-20 pb-12 bg-white sm:pb-16">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div class="max-w-2xl mx-auto mt-16 space-y-12 sm:mt-16 sm:space-y-16">
|
||||
<div class="nf-text" v-html="template.description"/>
|
||||
|
||||
<template v-if="template.questions.length > 0">
|
||||
<hr class="mt-12 border-gray-200">
|
||||
<div>
|
||||
<div class="text-center">
|
||||
<h3 class="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Frequently asked questions
|
||||
</h3>
|
||||
<p class="mt-2 text-base font-normal text-gray-600">
|
||||
Everything you need to know about this template.
|
||||
</p>
|
||||
</div>
|
||||
<dl class="mt-12 space-y-10">
|
||||
<div v-for="(ques,ques_key) in template.questions" :key="ques_key" class="space-y-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ ques.question }}
|
||||
</dt>
|
||||
<dd class="mt-2 leading-6 text-gray-600 dark:text-gray-400" v-html="ques.answer"/>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="relatedTemplates && relatedTemplates.length > 0"
|
||||
class="py-12 bg-white border-t border-gray-200 sm:py-16">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Related templates
|
||||
</h4>
|
||||
<v-button :to="{name:'templates'}" color="white" size="small" :arrow="true">
|
||||
View All
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 mt-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 sm:gap-y-12">
|
||||
<single-template v-for="related in relatedTemplates" :key="related.id" :template="related"/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="py-12 bg-white border-t border-gray-200 sm:py-16">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<div class="text-center">
|
||||
<h4 class="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
How OpnForm works
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 mt-12 md:grid-cols-2 gap-x-8 gap-y-12">
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 text-center lg:items-start sm:text-left sm:items-start xl:flex-row"
|
||||
>
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-10 h-10 text-base font-bold bg-white rounded-full shadow-sm ring-1 ring-inset ring-gray-200 text-blue-500 shrink-0"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="text-base font-bold leading-tight text-gray-900">
|
||||
Copy the template and change it the way you like
|
||||
</h5>
|
||||
<p class="mt-2 text-sm font-normal text-gray-600">
|
||||
<NuxtLink :to="createFormWithTemplateUrl">
|
||||
Click here to copy this template
|
||||
</NuxtLink>
|
||||
and start customizing it. Change the questions, add new ones, choose colors and
|
||||
more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 text-center lg:items-start sm:text-left sm:items-start xl:flex-row"
|
||||
>
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-10 h-10 text-base font-bold bg-white rounded-full shadow-sm ring-1 ring-inset ring-gray-200 text-blue-500 shrink-0"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="text-base font-bold leading-tight text-gray-900">
|
||||
Embed the form or share it via a link
|
||||
</h5>
|
||||
<p class="mt-2 text-sm font-normal text-gray-600">
|
||||
You can directly share your form link, or embed the form on your website. It's magic! 🪄
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- add video here -->
|
||||
<!-- <div class="max-w-5xl mx-auto mt-12 shadow-sm rounded-xl bg-blue-50 aspect-video" />-->
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<open-form-footer class="mt-8 border-t"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from 'vue'
|
||||
import OpenCompleteForm from '../../components/open/forms/OpenCompleteForm.vue'
|
||||
import Breadcrumb from '~/components/global/Breadcrumb.vue'
|
||||
import SingleTemplate from '../../components/pages/templates/SingleTemplate.vue'
|
||||
import {fetchTemplate} from "~/stores/templates.js"
|
||||
import FormTemplateModal from '~/components/open/forms/components/templates/FormTemplateModal.vue'
|
||||
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
|
||||
const {copy} = useClipboard()
|
||||
const authStore = useAuthStore()
|
||||
const templatesStore = useTemplatesStore()
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => route.params.slug)
|
||||
|
||||
const template = computed(() => templatesStore.getByKey(slug.value))
|
||||
const form = computed(() => template.value.structure)
|
||||
|
||||
// Fetch the template
|
||||
if (!template.value) {
|
||||
const {data} = await fetchTemplate(slug.value)
|
||||
templatesStore.save(data.value)
|
||||
}
|
||||
|
||||
// Fetch related templates
|
||||
const {data: relatedTemplatesData} = await useAsyncData('related-templates', () => {
|
||||
return Promise.all(template.value.related_templates.map((slug) => {
|
||||
if (templatesStore.getByKey(slug)) {
|
||||
return Promise.resolve(templatesStore.getByKey(slug))
|
||||
}
|
||||
return fetchTemplate(slug).then((res) => res.data.value)
|
||||
}))
|
||||
})
|
||||
templatesStore.save(relatedTemplatesData.value)
|
||||
templatesStore.initTypesAndIndustries()
|
||||
|
||||
// State
|
||||
const showFormTemplateModal = ref(false)
|
||||
|
||||
// Computed
|
||||
const breadcrumbs = computed(() => {
|
||||
if (!template.value) {
|
||||
return [{route: {name: 'templates'}, label: 'Templates'}]
|
||||
}
|
||||
return [{route: {name: 'templates'}, label: 'Templates'}, {label: template.value.name}]
|
||||
})
|
||||
const relatedTemplates = computed(() => templatesStore.getByKey(template?.value?.related_templates))
|
||||
const canEditTemplate = computed(() => authStore.check && template.value && (authStore.user.admin || authStore.user.template_editor || template.creator_id === authStore.user.id))
|
||||
const createFormWithTemplateUrl = computed(() => {
|
||||
return {name: (authStore.check) ? 'forms-create' : 'forms-create-guest', query: {template: template?.value?.slug}}
|
||||
})
|
||||
|
||||
// methods
|
||||
const cleanQuotes = (str) => {
|
||||
// Remove starting and ending quotes if any
|
||||
return (str) ? str.replace(/^"/, '').replace(/"$/, '') : ''
|
||||
}
|
||||
|
||||
const copyTemplateUrl = () => {
|
||||
copy(template.value.share_url)
|
||||
useAlert().success('Copied!')
|
||||
}
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: () => {
|
||||
if (!template || !template.value) return 'Form Template'
|
||||
return template.value.name
|
||||
},
|
||||
description() {
|
||||
if (!template || !template.value) return null
|
||||
// take the first 140 characters of the description
|
||||
return template.value.short_description?.substring(0, 140) + '... | Customize any template and create your own form in minutes.'
|
||||
},
|
||||
ogImage() {
|
||||
if (!template || !template.value) return null
|
||||
return template.value.image_url
|
||||
},
|
||||
robots: () => {
|
||||
if (!template || !template.value) return null
|
||||
return template.value.publicly_listed ? null : 'noindex'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
.nf-text {
|
||||
@apply space-y-4;
|
||||
h2 {
|
||||
@apply text-sm font-normal tracking-widest text-gray-500 uppercase;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply font-normal leading-7 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
|
||||
ol {
|
||||
@apply list-decimal list-inside;
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply list-disc list-inside;
|
||||
}
|
||||
}
|
||||
|
||||
.aspect-video {
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
</style>
|
||||
39
client/pages/templates/index.vue
Normal file
39
client/pages/templates/index.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full border-t">
|
||||
<section class="py-12 sm:py-16 bg-gray-50 border-b border-gray-200">
|
||||
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div class="text-center max-w-xl mx-auto">
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-gray-900">
|
||||
Form Templates
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-4 text-lg font-normal">
|
||||
Our collection of beautiful templates to create your own forms!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<templates-list :templates="templates" :loading="loading"/>
|
||||
|
||||
<open-form-footer class="mt-8 border-t"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {loadAllTemplates} from "~/stores/templates.js";
|
||||
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Form Templates',
|
||||
description: 'Our collection of beautiful templates to create your own forms!'
|
||||
})
|
||||
|
||||
const templatesStore = useTemplatesStore()
|
||||
loadAllTemplates(templatesStore)
|
||||
|
||||
const loading = computed(() => templatesStore.loading)
|
||||
const templates = computed(() => templatesStore.getAll)
|
||||
</script>
|
||||
118
client/pages/templates/industries/[slug].vue
Normal file
118
client/pages/templates/industries/[slug].vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<breadcrumb :path="breadcrumbs"/>
|
||||
|
||||
<p v-if="industry === null || !industry" class="text-center my-4">
|
||||
We could not find this industry.
|
||||
</p>
|
||||
<template v-else>
|
||||
<section class="py-12 sm:py-16 bg-gray-50 border-b border-gray-200">
|
||||
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div class="text-center mx-auto">
|
||||
<div class="font-semibold sm:w-full text-blue-500 mb-3">
|
||||
{{ industry.name }}
|
||||
</div>
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-gray-900">
|
||||
{{ industry.meta_title }}
|
||||
</h1>
|
||||
<p class="max-w-xl mx-auto text-gray-600 mt-4 text-lg font-normal">
|
||||
{{ industry.meta_description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<templates-list :templates="templates" :filter-industries="false" :show-industries="false">
|
||||
<template #before-lists>
|
||||
<section class="py-12 bg-white border-t border-gray-200 sm:py-16">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<p class="text-gray-600 font-normal">
|
||||
{{ industry.description }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</templates-list>
|
||||
</template>
|
||||
|
||||
<open-form-footer class="mt-8 border-t"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from 'vue'
|
||||
import Breadcrumb from '~/components/global/Breadcrumb.vue'
|
||||
import {loadAllTemplates} from "~/stores/templates.js";
|
||||
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const templatesStore = useTemplatesStore()
|
||||
|
||||
loadAllTemplates(templatesStore)
|
||||
|
||||
// Computed
|
||||
const authenticated = computed(() => authStore.check)
|
||||
const user = computed(() => authStore.user)
|
||||
const templates = computed(() => templatesStore.getAll.filter((item) => {
|
||||
return (item.industries && item.industries.length > 0) ? item.industries.includes(route.params.slug) : false
|
||||
}))
|
||||
const breadcrumbs = computed(() => {
|
||||
if (!industry) {
|
||||
return [{route: {name: 'templates'}, label: 'Templates'}]
|
||||
}
|
||||
return [{route: {name: 'templates'}, label: 'Templates'}, {label: industry.value.name}]
|
||||
})
|
||||
|
||||
const industry = computed(() => templatesStore.industries.get(route.params.slug))
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: () => {
|
||||
if (!industry.value) return 'Form Templates'
|
||||
if (industry.value.meta_title.length > 60) {
|
||||
return industry.value.meta_title
|
||||
}
|
||||
return industry.value.meta_title
|
||||
},
|
||||
description: () => industry.value ? industry.value.meta_description: 'Our collection of beautiful templates to create your own forms!'
|
||||
})
|
||||
useHead({
|
||||
titleTemplate: (titleChunk) => {
|
||||
// Disable title template for longer titles
|
||||
if (industry.value
|
||||
&& industry.value.meta_title.length < 60
|
||||
&& !industry.value.meta_title.toLowerCase().includes('opnform')
|
||||
) {
|
||||
return titleChunk ? `${titleChunk} - OpnForm` : 'Form Templates - OpnForm'
|
||||
}
|
||||
return titleChunk ? titleChunk : 'Form Templates - OpnForm'
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
.nf-text {
|
||||
@apply space-y-4;
|
||||
h2 {
|
||||
@apply text-sm font-normal tracking-widest text-gray-500 uppercase;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply font-normal leading-7 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
|
||||
ol {
|
||||
@apply list-decimal list-inside;
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply list-disc list-inside;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
40
client/pages/templates/my-templates.vue
Normal file
40
client/pages/templates/my-templates.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full border-t">
|
||||
<section class="py-12 sm:py-16 bg-gray-50 border-b border-gray-200">
|
||||
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div class="text-center max-w-xl mx-auto">
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-gray-900">
|
||||
My Form Templates
|
||||
</h1>
|
||||
<p class="text-gray-600 mt-4 text-lg font-normal">
|
||||
Share your best form as templates so that others can re-use them!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<templates-list :templates="templates" :loading="loading" :show-types="false" :show-industries="false"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
})
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'My Templates',
|
||||
description: 'Our collection of beautiful templates to create your own forms!'
|
||||
})
|
||||
|
||||
let loading = ref(false)
|
||||
let templates = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
loading.value = true
|
||||
opnFetch('templates',{query: {onlymy: true}}).then((data) => {
|
||||
loading.value = false
|
||||
templates.value = data
|
||||
})
|
||||
})
|
||||
</script>
|
||||
119
client/pages/templates/types/[slug].vue
Normal file
119
client/pages/templates/types/[slug].vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<breadcrumb :path="breadcrumbs"/>
|
||||
|
||||
<p v-if="type === null || !type" class="text-center my-4">
|
||||
We could not find this type.
|
||||
</p>
|
||||
<template v-else>
|
||||
<section class="py-12 sm:py-16 bg-gray-50 border-b border-gray-200">
|
||||
<div class="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
<div class="text-center mx-auto">
|
||||
<div class="font-semibold sm:w-full text-blue-500 mb-3">
|
||||
{{ type.name }}
|
||||
</div>
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-gray-900">
|
||||
{{ type.meta_title }}
|
||||
</h1>
|
||||
<p class="max-w-xl mx-auto text-gray-600 mt-4 text-lg font-normal">
|
||||
{{ type.meta_description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<templates-list :templates="templates" :filter-types="false" :show-industries="false">
|
||||
<template #before-lists>
|
||||
<section class="py-12 bg-white border-t border-gray-200 sm:py-16">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
<p class="text-gray-600 font-normal">
|
||||
{{ type.description }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</templates-list>
|
||||
</template>
|
||||
|
||||
<open-form-footer class="mt-8 border-t"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from 'vue'
|
||||
import OpenFormFooter from '../../../components/pages/OpenFormFooter.vue'
|
||||
import Breadcrumb from '~/components/global/Breadcrumb.vue'
|
||||
import {loadAllTemplates} from "~/stores/templates.js";
|
||||
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const templatesStore = useTemplatesStore()
|
||||
|
||||
loadAllTemplates(templatesStore)
|
||||
|
||||
// Computed
|
||||
const authenticated = computed(() => authStore.check)
|
||||
const user = computed(() => authStore.user)
|
||||
const templates = computed(() => templatesStore.getAll.filter((item) => {
|
||||
return (item.types && item.types.length > 0) ? item.types.includes(route.params.slug) : false
|
||||
}))
|
||||
const breadcrumbs = computed(() => {
|
||||
if (!type) {
|
||||
return [{route: {name: 'templates'}, label: 'Templates'}]
|
||||
}
|
||||
return [{route: {name: 'templates'}, label: 'Templates'}, {label: type.value.name}]
|
||||
})
|
||||
|
||||
const type = computed(() => templatesStore.types.get(route.params.slug))
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: () => {
|
||||
if (!type.value) return 'Form Templates'
|
||||
if (type.value.meta_title.length > 60) {
|
||||
return type.value.meta_title
|
||||
}
|
||||
return type.value.meta_title
|
||||
},
|
||||
description: () => type.value ? type.value.meta_description: 'Our collection of beautiful templates to create your own forms!'
|
||||
})
|
||||
useHead({
|
||||
titleTemplate: (titleChunk) => {
|
||||
// Disable title template for longer titles
|
||||
if (type.value
|
||||
&& type.value.meta_title.length < 60
|
||||
&& !type.value.meta_title.toLowerCase().includes('opnform')
|
||||
) {
|
||||
return titleChunk ? `${titleChunk} - OpnForm` : 'Form Templates - OpnForm'
|
||||
}
|
||||
return titleChunk ? titleChunk : 'Form Templates - OpnForm'
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
.nf-text {
|
||||
@apply space-y-4;
|
||||
h2 {
|
||||
@apply text-sm font-normal tracking-widest text-gray-500 uppercase;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply font-normal leading-7 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
|
||||
ol {
|
||||
@apply list-decimal list-inside;
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply list-disc list-inside;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
31
client/pages/terms-conditions.vue
Normal file
31
client/pages/terms-conditions.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mt-6 flex flex-col">
|
||||
<div class="w-full md:max-w-3xl md:mx-auto px-4 md:pt-16">
|
||||
<h1 class="sm:text-5xl">
|
||||
Terms & Conditions
|
||||
</h1>
|
||||
<NotionPage :block-map="blockMap" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
<open-form-footer/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useNotionPagesStore} from "~/stores/notion_pages.js";
|
||||
import {computed} from "vue";
|
||||
|
||||
useOpnSeoMeta({
|
||||
title: 'Terms & Conditions'
|
||||
})
|
||||
defineRouteRules({
|
||||
swr: 3600
|
||||
})
|
||||
|
||||
const notionPageStore = useNotionPagesStore()
|
||||
await notionPageStore.load('246420da2834480ca04047b0c5a00929')
|
||||
|
||||
const loading = computed(() => notionPageStore.loading)
|
||||
const blockMap = computed(() => notionPageStore.getByKey('246420da2834480ca04047b0c5a00929'))
|
||||
</script>
|
||||
Reference in New Issue
Block a user