diff --git a/.env.example b/.env.example index a92a2111dc3..69fc5b62fad 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,7 @@ STRIPE_SECRET_KEY="" STRIPE_PREMIUM_PRICING_ID="" STRIPE_WEBHOOK_SECRET="" NEXT_PUBLIC_PREMIUM_SUPPORT_URL="" + +VERCEL_PROJECT_ID="" +VERCEL_TEAM_ID="" +VERCEL_AUTH_TOKEN="" diff --git a/.github/workflows/vercel-preview.yml b/.github/workflows/vercel-preview.yml index 50b3778f587..3a55eceb41d 100644 --- a/.github/workflows/vercel-preview.yml +++ b/.github/workflows/vercel-preview.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: push: branches: - - feat-middleware + - feat-custom-domain jobs: deploy: diff --git a/components/Button.js b/components/Button.js index 244c8477f80..6376cfd2394 100644 --- a/components/Button.js +++ b/components/Button.js @@ -3,21 +3,22 @@ import Link from "./Link"; export default function Button({ primary = false, - disable, + disabled = false, className, overrideClassNames = false, children, ...restProps }) { - let defaultClassName = - "w-full inline-flex items-center flex-1 justify-center rounded-md border-2 border-primary-high dark:border-white hover:border-transparent px-5 py-3 text-base font-medium first-letter:bg-white transition duration-400 ease-in-out"; - !disable - ? (defaultClassName += primary + let defaultClassName = classNames( + "w-full inline-flex items-center flex-1 justify-center rounded-md border-2 border-primary-high dark:border-white hover:border-transparent px-5 py-3 text-base font-medium first-letter:bg-white transition duration-400 ease-in-out", + !disabled + ? primary ? " text-primary-medium bg-secondary-medium hover:bg-tertiary-medium" - : " text-secondary-high dark:text-secondary-high-high hover:text-white dark:hover:text-white dark:bg-primary-low hover:bg-secondary-medium dark:hover:bg-secondary-medium") - : (defaultClassName += disable - ? " border-2 border-red border shadow-sm bg-primary-low text-primary-medium cursor-not-allowed " - : " cursor-pointer"); + : " text-secondary-high dark:text-secondary-high-high hover:text-white dark:hover:text-white dark:bg-primary-low hover:bg-secondary-medium dark:hover:bg-secondary-medium" + : disabled + ? " border-2 border-red border shadow-sm bg-primary-low text-primary-medium cursor-not-allowed " + : " cursor-pointer", + ); const link = ( {children} diff --git a/components/form/Input.js b/components/form/Input.js index 29c1470701f..36f13ea35e8 100644 --- a/components/form/Input.js +++ b/components/form/Input.js @@ -51,6 +51,7 @@ const Input = forwardRef( id={name} name={name} value={value} + disabled={disabled} onKeyDown={handleKeydown} {...restProps} /> diff --git a/components/layouts/DocsLayout.js b/components/layouts/DocsLayout.js index 9e215aa6e82..6b7a74a22a2 100644 --- a/components/layouts/DocsLayout.js +++ b/components/layouts/DocsLayout.js @@ -70,6 +70,10 @@ export const navigation = [ name: "GitHub Repos with Forms", href: "/docs/how-to-guides/repos-forms", }, + { + name: "Premium Features", + href: "/docs/how-to-guides/premium", + }, ], }, { @@ -100,6 +104,15 @@ export const navigation = [ { name: "Hacktoberfest", href: "/docs/contributing/hacktoberfest" }, ], }, + { + name: "Premium", + // icon: ChartPieIcon, + children: [ + { name: "Auto", href: "/docs/premium/auto" }, + { name: "Customisation", href: "/docs/premium/customisation" }, + { name: "Custom Domain", href: "/docs/premium/domain" }, + ], + }, { name: "Other", // icon: ChartPieIcon, diff --git a/config/schemas/serverSchema.js b/config/schemas/serverSchema.js index de81a5e7be9..5df12a6696e 100644 --- a/config/schemas/serverSchema.js +++ b/config/schemas/serverSchema.js @@ -16,6 +16,9 @@ const envSchema = z.object({ NEXT_PUBLIC_VERCEL_ENV: z.string().optional(), STRIPE_SECRET_KEY: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(), + VERCEL_PROJECT_ID: z.string().optional(), + VERCEL_TEAM_ID: z.string().optional(), + VERCEL_AUTH_TOKEN: z.string().optional(), }); const serverEnv = envSchema.safeParse(process.env); diff --git a/middleware.js b/middleware.js index 8185e30e501..a3b20e7a170 100644 --- a/middleware.js +++ b/middleware.js @@ -1,8 +1,12 @@ import { getToken } from "next-auth/jwt"; import { NextResponse } from "next/server"; +// note: logger is not available in middleware, using console.log instead + export const config = { matcher: [ + "/", + // account management "/account/:path*", "/api/account/:path*", @@ -14,10 +18,60 @@ export const config = { }; export async function middleware(req) { + const protocol = process.env.NODE_ENV === "development" ? "http" : "https"; + const hostname = req.headers.get("host"); + const reqPathName = req.nextUrl.pathname; const sessionRequired = ["/account", "/api/account"]; const adminRequired = ["/admin", "/api/admin"]; const adminUsers = process.env.ADMIN_USERS.split(","); - const reqPathName = req.nextUrl.pathname; + const hostedDomain = process.env.NEXT_PUBLIC_BASE_URL.replace( + /http:\/\/|https:\/\//, + "", + ); + const hostedDomains = [hostedDomain, `www.${hostedDomain}`]; + + // if custom domain + on root path + if (!hostedDomains.includes(hostname) && reqPathName === "/") { + console.log(`custom domain used: "${hostname}"`); + + let res; + let profile; + let url = `${ + process.env.NEXT_PUBLIC_BASE_URL + }/api/search/${encodeURIComponent(hostname)}`; + try { + res = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + profile = await res.json(); + } catch (e) { + console.error(url, e); + return NextResponse.error(e); + } + + if ( + profile?.username && + profile.user.type === "premium" && + profile.settings?.domain && + profile.settings.domain === hostname + ) { + console.log( + `custom domain matched "${hostname}" for username "${profile.username}" (protocol: "${protocol}")`, + ); + // if match found rewrite to custom domain and display profile page + return NextResponse.rewrite( + new URL( + `/${profile.username}`, + `${protocol}://${profile.settings.domain}`, + ), + ); + } + + console.error(`custom domain NOT matched "${hostname}"`); + } // if not in sessionRequired or adminRequired, skip if ( diff --git a/models/Profile.js b/models/Profile.js index 40ca9238433..341a949a82d 100644 --- a/models/Profile.js +++ b/models/Profile.js @@ -144,10 +144,22 @@ const ProfileSchema = new Schema( type: Boolean, default: false, }, + domain: { + type: String, + default: "", + get: (v) => v.replaceAll("|", "."), + set: (v) => v.replaceAll(".", "|"), + validator: function (v) { + return /^[^https?:\/\/](?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}$/.test( + v, + ); + }, + message: (props) => `${props.value} is not a valid domain!`, + }, }, }, - { timestamps: true }, + { timestamps: true, toJSON: { getters: true } }, ); module.exports = - mongoose.models.Profile || mongoose.model("Profile", ProfileSchema); + mongoose.models?.Profile || mongoose.model("Profile", ProfileSchema); diff --git a/pages/[username].js b/pages/[username].js index d3adeb8c9d2..05a2b940c5b 100644 --- a/pages/[username].js +++ b/pages/[username].js @@ -50,6 +50,15 @@ export async function getServerSideProps(context) { profile.cleanBio = profile.bio; } + // override hiding navbar and footer if custom domain matches + if ( + profile.settings?.domain && + profile.settings.domain.replaceAll("|", ".") === req.headers.host + ) { + profile.settings.hideNavbar = true; + profile.settings.hideFooter = true; + } + return { props: { data: profile, diff --git a/pages/account/manage/links.js b/pages/account/manage/links.js index 79a3523b0b1..8f5c66403c8 100644 --- a/pages/account/manage/links.js +++ b/pages/account/manage/links.js @@ -104,7 +104,7 @@ export default function ManageLinks({ BASE_URL, username, links }) { {!reorder && ( + - {accountType === "premium" && ( -

- For help with your Premium account settings:{" "} - - Contact Support - -

- )} ); diff --git a/pages/account/manage/repos.js b/pages/account/manage/repos.js index eb80d9281e8..d1c27b8fffa 100644 --- a/pages/account/manage/repos.js +++ b/pages/account/manage/repos.js @@ -143,7 +143,7 @@ export default function ManageRepos({ BASE_URL, repos }) { onChange={(e) => setUrl(e.target.value)} value={url} /> - diff --git a/pages/account/onboarding/index.js b/pages/account/onboarding/index.js index ae510376ec5..3c5542cd941 100644 --- a/pages/account/onboarding/index.js +++ b/pages/account/onboarding/index.js @@ -171,7 +171,7 @@ export default function Onboarding({ profile, progress }) { diff --git a/pages/api/account/manage/settings.js b/pages/api/account/manage/settings.js index cd5eac0702a..4df5ab27588 100644 --- a/pages/api/account/manage/settings.js +++ b/pages/api/account/manage/settings.js @@ -3,6 +3,7 @@ import { getServerSession } from "next-auth/next"; import connectMongo from "@config/mongo"; import logger from "@config/logger"; +import { serverEnv } from "@config/schemas/serverSchema"; import Profile from "@models/Profile"; import logChange from "@models/middlewares/logChange"; @@ -31,6 +32,45 @@ export default async function handler(req, res) { return res.status(200).json(profile); } +async function updateDomain(username, domain = "") { + await Profile.findOneAndUpdate( + { username }, + { source: "database", settings: { domain } }, + ); +} + +async function vercelDomainStatus(username, domain) { + const log = logger.child({ username }); + let domainRes; + const domainUrl = `https://api.vercel.com/v10/domains/${domain}/config?teamId=${serverEnv.VERCEL_TEAM_ID}`; + const domainUrlError = `failed get status for custom domain "${domain}" for username: ${username}`; + let domainJson; + try { + domainRes = await fetch(domainUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${serverEnv.VERCEL_AUTH_TOKEN}`, + "Content-Type": "application/json", + }, + }); + domainJson = await domainRes.json(); + log.info( + domainJson, + `retrieve domain status for ${domain} for: ${username}`, + ); + } catch (e) { + log.error(e, domainUrlError); + return { error: domainUrlError }; + } + + if (domainJson.error) { + log.error(domainUrlError); + return { error: domainUrlError }; + } + + return domainJson; +} + export async function getSettingsApi(username) { await connectMongo(); const log = logger.child({ username }); @@ -42,7 +82,17 @@ export async function getSettingsApi(username) { return { error: "Profile not found." }; } - return JSON.parse(JSON.stringify(getProfile.settings)); + let data = { ...getProfile.settings }; + + if (getProfile.settings?.domain) { + const vercel = await vercelDomainStatus( + username, + getProfile.settings.domain, + ); + data.vercel = vercel; + } + + return JSON.parse(JSON.stringify(data)); } export async function updateSettingsApi(context, username, data) { @@ -58,10 +108,12 @@ export async function updateSettingsApi(context, username, data) { return { error: e.errors }; } + const update = { ...beforeUpdate, ...data }; + update.domain = update.domain.replaceAll(".", "|"); // TODO: use getter/setter instead try { getProfile = await Profile.findOneAndUpdate( { username }, - { source: "database", settings: data }, + { source: "database", settings: update }, { upsert: true, new: true, @@ -69,7 +121,142 @@ export async function updateSettingsApi(context, username, data) { ); log.info(`profile premium settings updated for username: ${username}`); } catch (e) { - log.error(e, `failed to updated profile premium for username: ${username}`); + const error = `failed to updated profile premium for username: ${username}`; + log.error(e, error); + return { error }; + } + let result = { ...getProfile.settings }; + + beforeUpdate.domain = beforeUpdate.domain.replaceAll("|", "."); // TODO: use getter/setter instead + if (data.domain !== beforeUpdate.domain) { + log.info( + `trying to update profile premium settings domain for username: ${username}`, + ); + + // remove previous custom domain if exists + if (beforeUpdate.domain) { + log.info( + `attempting to remove existing domain "${beforeUpdate.domain}" from the project for: ${username}`, + ); + let domainRemoveRes; + const domainRemoveUrl = `https://api.vercel.com/v9/projects/${serverEnv.VERCEL_PROJECT_ID}/domains/${beforeUpdate.domain}?teamId=${serverEnv.VERCEL_TEAM_ID}`; + try { + domainRemoveRes = await fetch(domainRemoveUrl, { + headers: { + Authorization: `Bearer ${serverEnv.VERCEL_AUTH_TOKEN}`, + }, + method: "DELETE", + }); + const domainRemoveJson = await domainRemoveRes.json(); + log.info( + domainRemoveJson, + `domain ${beforeUpdate.domain} removed for: ${username}`, + ); + } catch (e) { + updateDomain(username, beforeUpdate.domain); + const error = `failed to remove previous project custom domain for username: ${username}`; + log.error(e, error); + return { error }; + } + + log.info( + `attempting to remove existing domain "${beforeUpdate.domain}" from team for: ${username}`, + ); + let domainProjectRemoveRes; + const domainProjectRemoveUrl = `https://api.vercel.com/v6/domains/${beforeUpdate.domain}?teamId=${serverEnv.VERCEL_TEAM_ID}`; + try { + domainProjectRemoveRes = await fetch(domainProjectRemoveUrl, { + headers: { + Authorization: `Bearer ${serverEnv.VERCEL_AUTH_TOKEN}`, + }, + method: "DELETE", + }); + const domainProjectRemoveJson = await domainProjectRemoveRes.json(); + log.info( + domainProjectRemoveJson, + `domain ${beforeUpdate.domain} removed for: ${username}`, + ); + } catch (e) { + updateDomain(username, beforeUpdate.domain); + const error = `failed to remove previous team custom domain for username: ${username}`; + log.error(e, error); + return { error }; + } + } + + // add new custom domain + if (data.domain) { + log.info( + `attempting to add domain "${data.domain}" to the team for: ${username}`, + ); + let domainAddRes; + const domainAddUrl = `https://api.vercel.com/v5/domains?teamId=${serverEnv.VERCEL_TEAM_ID}`; + const domainAddUrlError = `failed to add new team custom domain "${data.domain}" for username: ${username}`; + let domainAddJson; + try { + domainAddRes = await fetch(domainAddUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${serverEnv.VERCEL_AUTH_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: data.domain }), + }); + domainAddJson = await domainAddRes.json(); + if (domainAddJson.error) { + updateDomain(username, beforeUpdate.domain); + log.error(domainAddUrlError); + return { error: domainAddUrlError }; + } + log.info( + domainAddJson, + `domain ${data.domain} added to team for: ${username}`, + ); + } catch (e) { + updateDomain(username, beforeUpdate.domain); + log.error(e, domainAddUrlError); + return { error: domainAddUrlError }; + } + + log.info( + `attempting to add domain "${data.domain}" to the project for: ${username}`, + ); + let domainProjectAddRes; + const domainProjectAddUrl = `https://api.vercel.com/v10/projects/${serverEnv.VERCEL_PROJECT_ID}/domains?teamId=${serverEnv.VERCEL_TEAM_ID}`; + const domainProjectAddJsonError = `failed to add new project custom domain "${data.domain}" for username: ${username}`; + let domainProjectAddJson; + try { + domainProjectAddRes = await fetch(domainProjectAddUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${serverEnv.VERCEL_AUTH_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: data.domain }), + }); + domainProjectAddJson = await domainProjectAddRes.json(); + if (domainProjectAddJson.error) { + updateDomain(username, beforeUpdate.domain); + log.error(domainProjectAddJsonError); + return { error: domainProjectAddJsonError }; + } + log.info( + domainProjectAddJson, + `domain ${data.domain} added to project for: ${username}`, + ); + } catch (e) { + log.error(e, domainProjectAddJsonError); + return { error: domainProjectAddJsonError }; + } + + if (getProfile.settings?.domain) { + const vercel = await vercelDomainStatus( + username, + getProfile.settings.domain, + ); + result.vercel = vercel; + } + } } // Add to Changelog @@ -86,5 +273,5 @@ export async function updateSettingsApi(context, username, data) { ); } - return JSON.parse(JSON.stringify(getProfile.settings)); + return JSON.parse(JSON.stringify(result)); } diff --git a/pages/api/profiles/[username]/index.js b/pages/api/profiles/[username]/index.js index decfe3d7a04..c3137631b7e 100644 --- a/pages/api/profiles/[username]/index.js +++ b/pages/api/profiles/[username]/index.js @@ -43,7 +43,7 @@ export async function getUserApi(req, res, username, options = {}) { } let ipLookupProm; - if (options.ip) { + if (options.ip && !options.ip.match(/127\.0\.0\.1/)) { try { ipLookupProm = fetch(`https://api.iplocation.net/?ip=${options.ip}`); } catch (e) { @@ -203,7 +203,7 @@ export async function getUserApi(req, res, username, options = {}) { const referer = new URL(options.referer); increment[`stats.referers.${referer.hostname.replaceAll(".", "|")}`] = 1; } - if (options.ip) { + if (options.ip && !options.ip.match(/127\.0\.0\.1/)) { try { const ipLookupRes = await ipLookupProm; const ipLookup = await ipLookupRes.json(); @@ -230,7 +230,7 @@ export async function getUserApi(req, res, username, options = {}) { } catch (e) { log.error( e, - `failed to increment profile stats for username: ${username}`, + `failed to increment profile total stats for username: ${username}`, ); } })(), @@ -253,7 +253,7 @@ export async function getUserApi(req, res, username, options = {}) { } catch (e) { log.error( e, - "failed to increment profile stats for username: ${username}", + "failed to increment profile daily stats for username: ${username}", ); } })(), diff --git a/pages/api/search/[domain].js b/pages/api/search/[domain].js new file mode 100644 index 00000000000..10b8cd1a3a0 --- /dev/null +++ b/pages/api/search/[domain].js @@ -0,0 +1,62 @@ +import connectMongo from "@config/mongo"; +import logger from "@config/logger"; +import { Profile } from "@models/index"; + +export default async function handler(req, res) { + const domain = req.query.domain; + if (!domain) { + return res + .status(400) + .json({ error: "Invalid request: domain is required" }); + } + + const profile = await getDomainApi(domain); + return res.status(200).json(profile); +} + +export async function getDomainApi(domain) { + domain = decodeURIComponent(domain).replaceAll(".", "|"); + await connectMongo(); + + let profile = {}; + try { + profile = await Profile.aggregate([ + { + $match: { + "settings.domain": domain, + isEnabled: true, + }, + }, + { + $lookup: { + from: "users", + localField: "user", + foreignField: "_id", + as: "user", + }, + }, + { + $project: { + user: { type: 1 }, + username: 1, + isEnabled: 1, + settings: { domain: 1 }, + }, + }, + ]).limit(1); + } catch (e) { + logger.error(e, `error finding profile for domain ${domain}`); + return {}; + } + + if (profile.length > 0) { + profile = profile[0]; + return { + username: profile.username, + settings: { domain: profile.settings?.domain.replaceAll("|", ".") }, // TODO: use getter/setter instead + user: { type: profile.user[0].type }, + }; + } + + return {}; +} diff --git a/pages/changelog.js b/pages/changelog.js index ef83aa7729f..635d6170b10 100644 --- a/pages/changelog.js +++ b/pages/changelog.js @@ -11,6 +11,13 @@ export default function Changelog() { removal: "text-red-800 bg-red-100", }; const changes = [ + { + title: "Custom domains", + description: + "You can now add your own custom domain to your profile for further customisation", + type: "addition", + date: "2023-11-07", + }, { title: "Profile pronouns", description: diff --git a/pages/docs/premium/auto.mdx b/pages/docs/premium/auto.mdx new file mode 100644 index 00000000000..dc40218ef0a --- /dev/null +++ b/pages/docs/premium/auto.mdx @@ -0,0 +1,30 @@ +import DocsLayout from "@components/layouts/DocsLayout.js"; +import ClipboardCopy from "@components/ClipboardCopy"; +import Link from "@components/Link"; +import Alert from "@components/Alert.js"; + +## Premium Features + +If you have a Premium account you have these extra features. + +### Premium Badge + +Automatically earn a Premium badge. This is shown on your Profile tags. + +![Premium Badge example](https://github.com/EddieHubCommunity/BioDrop/assets/624760/b2741e55-7359-4c73-b8bc-8364cba283e5) + +### Hidden "Create your BioDrop Profile" button + +The "Create your BioDrop Profile" button which is shown on Free accounts for visitors and users that are not logged in, is automatically hidden. + +![Create your BioDrop Profile example](https://github.com/EddieHubCommunity/BioDrop/assets/624760/c4e80774-d9f7-4ccd-ab31-ded0c9f43963) + +export default ({ children }) => ( + + {children} + +); diff --git a/pages/docs/premium/customisation.mdx b/pages/docs/premium/customisation.mdx new file mode 100644 index 00000000000..8aa1953e811 --- /dev/null +++ b/pages/docs/premium/customisation.mdx @@ -0,0 +1,22 @@ +import DocsLayout from "@components/layouts/DocsLayout.js"; +import ClipboardCopy from "@components/ClipboardCopy"; +import Link from "@components/Link"; +import Alert from "@components/Alert.js"; + +## Hide Navbar and Footer + +You can use the toggles to hide/show the Navbar and Footer from your Profile. + +To apply these changes click on the `Save` button. + +![Hide/Show Navbar and Footer](https://github.com/EddieHubCommunity/BioDrop/assets/624760/6a5cef77-f84c-403c-85f5-840b91a1379f) + +export default ({ children }) => ( + + {children} + +); diff --git a/pages/docs/premium/domain.mdx b/pages/docs/premium/domain.mdx new file mode 100644 index 00000000000..d768b8b46ae --- /dev/null +++ b/pages/docs/premium/domain.mdx @@ -0,0 +1,67 @@ +import DocsLayout from "@components/layouts/DocsLayout.js"; +import ClipboardCopy from "@components/ClipboardCopy"; +import Link from "@components/Link"; +import Alert from "@components/Alert.js"; + +## Custom Domain + + + NOTE: + When using your custom domain, the Navbar and Footer will be hidden by default. + + } +/> + +Customise even further by using your own domain for your BioDrop Profile. + +### 1. Go to your domain registrar + +(for example: Namecheap, GoDaddy, Google Domains, 123Reg etc). + +You will need to update your domain's DNS to point to BioDrop. + +You can do this by adding an `A` record for your apex domain `@` that points to `76.76.21.21`. + + + NOTE: + These changes can take up to 48 hours to have an effect. + + } +/> + +![DNS screenshot](https://github.com/EddieHubCommunity/BioDrop/assets/624760/cc5e696a-8c98-4aa2-b92d-1d5c4d68ef18) + +### 2. Add your domain to BioDrop + +In the Premium tab on the "Create & Manage your Profile", add the domain you want to use in the `input` form field. + +![Premium Page example](https://github.com/EddieHubCommunity/BioDrop/assets/624760/468d3be0-e8c2-4be2-85d5-26436c8aa0ab) + +### Common errors + +We use Vercel and if you are using the same domain on Vercel already this will cause an error. For example if you want to add these subdomains to your BioDrop Profile `bio.foo.bar` or `contact.foo.bar` + +To fix this please follow these steps: + +1. If you have already added your custom domain to BioDrop, remove the custom domain `bio.foo.bar` from BioDrop's input domain field. +2. Then on Vercel add `foo.bar` and follow the instructions on Vercel. +3. Then on BioDrop domain input field add `bio.foo.bar`. +4. You will have to set a TXT record in your DNS to prove ownership of the domain + +![Vercel domain error](https://github.com/EddieHubCommunity/BioDrop/assets/624760/f86b2d00-c583-4d24-bb13-175d678b7360) + +export default ({ children }) => ( + + {children} + +); diff --git a/pages/pricing.js b/pages/pricing.js index f0a696a7ad4..b9eb7430572 100644 --- a/pages/pricing.js +++ b/pages/pricing.js @@ -184,6 +184,17 @@ export default function Premium({ user }) { "Make your Profile standout with a Premium badge on your Profile", tiers: { Free: false, Premium: true }, }, + { + name: "Removed Button", + description: + 'Automatically removes the "Create your Profile" button from your Profile', + tiers: { Free: false, Premium: true }, + }, + { + name: "Custom Domain", + description: "Use your own domain for your Profile", + tiers: { Free: false, Premium: true }, + }, ], }, { @@ -359,7 +370,7 @@ export default function Premium({ user }) {

{tier.description}