damus.io

damus.io website
git clone git://jb55.com/damus.io
Log | Files | Refs | README | LICENSE

commit aba72d3ff5d575b10114553e836159a04d30bac6
parent 15dacecc5e3b32795872d9570927be5024bae99f
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Tue,  9 Jan 2024 00:19:46 -0800

Checkout page

This commit adds a Bitcoin lightning checkout page for Damus Purple subscriptions

Diffstat:
Mpackage-lock.json | 25++++++++++++++++++++++++-
Mpackage.json | 4+++-
Msrc/components/icons/PurpleIcon.tsx | 2+-
Asrc/components/pages/purple-checkout.tsx | 19+++++++++++++++++++
Asrc/components/sections/PurpleCheckout.tsx | 358+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/sections/PurpleFinalCTA.tsx | 2+-
Msrc/components/sections/PurpleHero.tsx | 2+-
Asrc/components/ui/Input.tsx | 26++++++++++++++++++++++++++
Asrc/components/ui/Label.tsx | 27+++++++++++++++++++++++++++
Msrc/components/ui/RoundedContainerWithGradientBorder.tsx | 4++--
Asrc/pages/purple/checkout/index.tsx | 43+++++++++++++++++++++++++++++++++++++++++++
11 files changed, 505 insertions(+), 7 deletions(-)

diff --git a/package-lock.json b/package-lock.json @@ -18,11 +18,13 @@ "lucide-react": "^0.287.0", "next": "13.5.4", "nostr-tools": "^2.1.2", + "qrcode.react": "^3.1.0", "react": "^18", "react-dom": "^18", "react-intl": "^6.4.7", "tailwind-merge": "^1.14.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "usehooks-ts": "^2.9.2" }, "devDependencies": { "@formatjs/cli": "^6.2.0", @@ -4074,6 +4076,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-3.1.0.tgz", + "integrity": "sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4894,6 +4904,19 @@ "punycode": "^2.1.0" } }, + "node_modules/usehooks-ts": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.9.2.tgz", + "integrity": "sha512-fOzPeG01rs51CGYzqgioP/zs9v1Cgpe+zcXeqJPlDHYfdfG/wjsdjBWHJi+Ph1JgQAGUrDo5sJbPlaZd+Z9lxw==", + "hasInstallScript": true, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json @@ -23,11 +23,13 @@ "lucide-react": "^0.287.0", "next": "13.5.4", "nostr-tools": "^2.1.2", + "qrcode.react": "^3.1.0", "react": "^18", "react-dom": "^18", "react-intl": "^6.4.7", "tailwind-merge": "^1.14.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "usehooks-ts": "^2.9.2" }, "devDependencies": { "@formatjs/cli": "^6.2.0", diff --git a/src/components/icons/PurpleIcon.tsx b/src/components/icons/PurpleIcon.tsx @@ -5,7 +5,7 @@ import Image from "next/image"; export function PurpleIcon({ className }: { className?: string }) { return ( <RoundedContainerWithColorGradientBorder className={cn("w-24 h-24 p-[1px]", className)}> - <Image src="logo-icon-dark.png" fill className="overflow-hidden w-full h-full object-fill shadow-xl rounded-2xl" alt="Damus Purple logo" /> + <Image src="/logo-icon-dark.png" fill className="overflow-hidden w-full h-full object-fill shadow-xl rounded-2xl" alt="Damus Purple logo" /> </RoundedContainerWithColorGradientBorder> ) }; diff --git a/src/components/pages/purple-checkout.tsx b/src/components/pages/purple-checkout.tsx @@ -0,0 +1,19 @@ +import Head from "next/head"; +import { useIntl } from 'react-intl' +import { Footer } from '@/components/sections/Footer'; +import { PurpleCheckout as CheckoutSection } from '@/components/sections/PurpleCheckout'; + + +export function PurpleCheckout() { + const intl = useIntl() + + return (<> + <Head> + <title>Damus Purple checkout</title> + </Head> + <main style={{ scrollBehavior: "smooth" }}> + <CheckoutSection /> + <Footer /> + </main> + </>) +} diff --git a/src/components/sections/PurpleCheckout.tsx b/src/components/sections/PurpleCheckout.tsx @@ -0,0 +1,358 @@ +import { ArrowLeft, ArrowUpRight, CheckCircle, ChevronRight, Copy, Globe2, Loader2, LucideZapOff, Zap, ZapIcon, ZapOff } from "lucide-react"; +import { Button } from "../ui/Button"; +import { FormattedMessage, useIntl } from "react-intl"; +import Link from "next/link"; +import { motion } from "framer-motion"; +import Image from "next/image"; +import { PurpleIcon } from "../icons/PurpleIcon"; +import { RoundedContainerWithGradientBorder } from "../ui/RoundedContainerWithGradientBorder"; +import { MeshGradient5 } from "../effects/MeshGradient.5"; +import { useEffect, useRef, useState } from "react"; +import { NostrEvent, Relay, nip19 } from "nostr-tools" +import { QRCodeSVG } from 'qrcode.react'; +import { useInterval } from 'usehooks-ts' + + +export function PurpleCheckout() { + const intl = useIntl() + const [lnCheckout, setLNCheckout] = useState<LNCheckout | null>(null) // The checkout object from the server + const [productTemplates, setProductTemplates] = useState<ProductTemplates | null>(null) // The different product options + const [pubkey, setPubkey] = useState<string | null>(null) // The pubkey of the user, if verified + const [profile, setProfile] = useState<Profile | undefined | null>(undefined) // The profile info fetched from the Damus relay + const [continueShowQRCodes, setContinueShowQRCodes] = useState<boolean>(false) // Whether the user wants to show a QR code for the final step + + // MARK: - Functions + + const fetchProfile = async () => { + if (!pubkey) { + return + } + const profile = await getProfile(pubkey) + setProfile(profile) + } + + const fetchProductTemplates = async () => { + const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/products", { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + }) + const data = await response.json() + setProductTemplates(data) + } + + const refreshLNCheckout = async (id?: string) => { + if (!lnCheckout && !id) { + return + } + const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout/" + (id || lnCheckout?.id), { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + }) + const data: LNCheckout = await response.json() + setLNCheckout(data) + } + + const selectProduct = async (productTemplateName: string) => { + const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/ln-checkout", { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ product_template_name: productTemplateName }) + }) + const data: LNCheckout = await response.json() + setLNCheckout(data) + } + + // MARK: - Effects and hooks + + // Keep polling the LN checkout state when verifying pubkey or paying for the invoice + useInterval(refreshLNCheckout, lnCheckout && (!lnCheckout.verified_pubkey || !lnCheckout.invoice?.paid) ? 1000 : null) + + useEffect(() => { + if (lnCheckout && lnCheckout.verified_pubkey) { + // Load the profile if the user has verified their pubkey + setPubkey(lnCheckout.verified_pubkey) + } + // Set the query parameter on the URL to be the lnCheckout ID to avoid losing it on page refresh + if (lnCheckout) { + const url = new URL(window.location.href) + url.searchParams.set("id", lnCheckout.id) + window.history.replaceState({}, "", url.toString()) + } + }, [lnCheckout]) + + // Load the profile when the pubkey changes + useEffect(() => { + if (pubkey) { + fetchProfile() + } + }, [pubkey]) + + // Load the products and the LN checkout (if there is one) on page load + useEffect(() => { + fetchProductTemplates() + // Check if there is a lnCheckout ID in the URL query parameters. If so, fetch the lnCheckout + const url = new URL(window.location.href) + const id = url.searchParams.get("id") + if (id) { + console.log("Found lnCheckout ID in URL query parameters. Fetching lnCheckout...") + refreshLNCheckout(id) + } + }, []) + + // MARK: - Render + + return (<> + <div + className="bg-black overflow-hidden relative" + > + <div className="absolute z-0 w-full h-full pointer-events-none"> + <Image src="/stars-bg.webp" fill className="absolute top-0 left-0 object-cover lg:object-contain object-center w-full h-full" alt="" aria-hidden="true" /> + <MeshGradient5 className="translate-y-1/4 translate-x-32" /> + </div> + <div className="container z-10 mx-auto px-6 pt-12 h-full min-h-screen flex flex-col justify-center"> + <div className="flex flex-col items-center justify-center h-full grow"> + <RoundedContainerWithGradientBorder className="w-full max-w-md h-full mb-4" innerContainerClassName="w-full"> + <div className="flex gap-x-4 items-center mb-12 mx-auto justify-center"> + <PurpleIcon className="w-16 h-16" /> + <motion.h2 className="text-6xl text-center text-transparent bg-clip-text bg-gradient-to-r from-damuspink-500 from-30% to-damuspink-600 to-100% font-semibold break-keep tracking-tight"> + Purple + </motion.h2> + </div> + <h2 className="text-2xl text-left text-purple-200 font-semibold break-keep mb-4"> + {intl.formatMessage({ id: "purple.checkout.title", defaultMessage: "Checkout" })} + </h2> + <StepHeader + stepNumber={1} + title={intl.formatMessage({ id: "purple.checkout.step-1", defaultMessage: "Choose your plan" })} + done={lnCheckout?.product_template_name != null} + active={true} + /> + <div className="mt-3 mb-4 flex gap-2 items-center"> + {productTemplates ? Object.entries(productTemplates).map(([name, productTemplate]) => ( + <button + key={name} + className={`flex flex-col items-center justify-center p-3 border rounded-lg ${name == lnCheckout?.product_template_name ? "border-green-500" : "border-purple-200/50"} disabled:opacity-50 disabled:cursor-not-allowed`} + onClick={() => selectProduct(name)} + disabled={lnCheckout?.verified_pubkey != null} + > + <div className="text-purple-200/50 font-normal text-sm"> + {productTemplate.description} + </div> + <div className="mt-1 text-purple-100/90 font-semibold text-lg"> + {productTemplate.amount_msat / 1000} sats + </div> + </button> + )) : ( + <div className="flex flex-col items-center justify-center"> + <div className="text-purple-200/50 font-normal text-sm flex items-center"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + Loading... + </div> + </div> + )} + </div> + <StepHeader + stepNumber={2} + title={intl.formatMessage({ id: "purple.checkout.step-2", defaultMessage: "Verify your npub" })} + done={lnCheckout?.verified_pubkey != null} + active={lnCheckout?.product_template_name != null} + /> + {lnCheckout && !lnCheckout.verified_pubkey && <> + <QRCodeSVG value={"damus:purple:verify?id=" + lnCheckout.id} className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6" /> + <Link href={"damus:purple:verify?id=" + lnCheckout.id} className="w-full md:w-auto opacity-70 hover:opacity-100 transition"> + <Button variant="link" className="w-full text-sm"> + {intl.formatMessage({ id: "purple.checkout.open-in-app", defaultMessage: "Open in Damus" })} + <ChevronRight className="ml-2" /> + </Button> + </Link> + </> + } + {profile && + <div className="mt-2 mb-4 flex flex-col items-center"> + <div className="text-purple-200/50 font-normal text-sm"> + {lnCheckout?.verified_pubkey && !lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.purchasing-for", defaultMessage: "Verified. Purchasing Damus Purple for:" })} + {lnCheckout?.invoice?.paid && intl.formatMessage({ id: "purple.checkout.purchased-for", defaultMessage: "Purchased Damus Purple for:" })} + </div> + <div className="mt-4 flex flex-col gap-1 items-center justify-center"> + <Image src={profile.picture || "https://robohash.org/" + profile.pubkey} width={64} height={64} className="rounded-full" alt={profile.name} /> + <div className="text-purple-100/90 font-semibold text-lg"> + {profile.name} + </div> + </div> + </div> + } + <StepHeader + stepNumber={3} + title={intl.formatMessage({ id: "purple.checkout.step-3", defaultMessage: "Lightning payment" })} + done={lnCheckout?.invoice?.paid === true} + active={lnCheckout?.verified_pubkey != null} + /> + {lnCheckout?.invoice?.bolt11 && !lnCheckout?.invoice?.paid && + <> + <QRCodeSVG value={"lightning:" + lnCheckout.invoice.bolt11} className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6" /> + {/* Shows the bolt11 in for copy-paste with a copy and paste button */} + <div className="flex items-center justify-between rounded-md bg-purple-200/20"> + <div className="w-full text-sm text-purple-200/50 font-normal px-4 py-2 overflow-x-scroll"> + {lnCheckout.invoice.bolt11} + </div> + <button + className="text-sm text-purple-200/50 font-normal px-4 py-2 active:text-purple-200/30 hover:text-purple-200/80 transition" + onClick={() => navigator.clipboard.writeText(lnCheckout?.invoice?.bolt11 || "")} + > + <Copy /> + </button> + </div> + <Link href={"lightning:" + lnCheckout.invoice.bolt11} className="w-full md:w-auto opacity-70 hover:opacity-100 transition mt-4"> + <Button variant="link" className="w-full text-sm"> + {intl.formatMessage({ id: "purple.checkout.open-in-wallet", defaultMessage: "Open in wallet" })} + <ArrowUpRight className="text-damuspink-600 ml-2" /> + </Button> + </Link> + <div className="mt-6 text-purple-200/50 font-normal text-sm text-center flex justify-center"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + {intl.formatMessage({ id: "purple.checkout.waiting-for-payment", defaultMessage: "Waiting for payment" })} + </div> + </> + } + {lnCheckout?.invoice?.paid && ( + <div className="flex flex-col items-center justify-center gap-3 mt-6"> + <CheckCircle className="w-16 h-16 text-green-500" /> + <div className="mt-3 mb-6 text-sm text-center text-green-500 font-bold"> + {intl.formatMessage({ id: "purple.checkout.payment-received", defaultMessage: "Payment received" })} + </div> + <Link + href={`damus:purple:welcome?id=${lnCheckout.id}`} + className="w-full text-sm flex justify-center" + > + <Button variant="default" className="w-full text-sm"> + {intl.formatMessage({ id: "purple.checkout.continue", defaultMessage: "Continue in the app to set it up" })} + <ChevronRight className="ml-1" /> + </Button> + </Link> + <button className="w-full text-sm text-damuspink-500 flex justify-center" onClick={() => setContinueShowQRCodes(!continueShowQRCodes)}> + {!continueShowQRCodes ? + intl.formatMessage({ id: "purple.checkout.continue.show-qr", defaultMessage: "Show QR code" }) + : intl.formatMessage({ id: "purple.checkout.continue.hide-qr", defaultMessage: "Hide QR code" }) + } + </button> + {continueShowQRCodes && ( + <> + <QRCodeSVG value={"damus:purple:welcome?id=" + lnCheckout.id} className="mt-6 w-[300px] h-[300px] max-w-full max-h-full mx-auto mb-6" /> + </> + )} + </div> + )} + </RoundedContainerWithGradientBorder> + <Link href="/purple" className="w-full md:w-auto opacity-70 hover:opacity-100 transition"> + <Button variant="link" className="w-full text-sm"> + <ArrowLeft className="text-damuspink-600 mr-2" /> + {intl.formatMessage({ id: "purple.checkout.back", defaultMessage: "Go back" })} + </Button> + </Link> + </div> + </div> + </div > + </>) +} + +// MARK: - Helper components + +function StepHeader({ stepNumber, title, done, active }: { stepNumber: number, title: string, done: boolean, active: boolean }) { + return (<> + <div className={`flex items-center mb-2 ${active ? "" : "opacity-50"}`}> + <div className={`flex items-center justify-center w-8 h-8 rounded-full ${done ? "bg-green-500" : active ? "bg-purple-600" : "bg-gray-500"} text-white text-sm font-semibold`}> + {done ? <CheckCircle className="w-4 h-4" /> : stepNumber} + </div> + <div className="ml-2 text-lg text-purple-200 font-semibold"> + {title} + </div> + </div> + </>) +} + +// MARK: - Types + +interface LNCheckout { + id: string, + verified_pubkey?: string, + product_template_name?: string, + invoice?: { + bolt11: string, + paid: boolean, + } +} + +interface ProductTemplate { + description: string, + amount_msat: number, + expiry: number, +} + +type ProductTemplates = Record<string, ProductTemplate> + +interface Profile { + pubkey: string + name: string + picture: string + about: string +} + +// MARK: - Helper functions + +const getProfile = async (pubkey: string): Promise<Profile | null> => { + const profile_event: NostrEvent | null = await getProfileEvent(pubkey) + if (!profile_event) { + return null + } + try { + const profile_data = JSON.parse(profile_event.content) + const profile = { + pubkey: profile_event.pubkey, + name: profile_data.name, + picture: profile_data.picture, + about: profile_data.about, + } + return profile + } + catch (e) { + return null + } +} + +const getProfileEvent = async (pubkey: string): Promise<NostrEvent | null> => { + const relay = await Relay.connect('wss://relay.damus.io') + + const events: Array<NostrEvent> = [] + return new Promise((resolve, reject) => { + + // let's query for an event that exist + const sub = relay.subscribe([ + { + authors: [pubkey], + kinds: [0], + }, + ], { + onevent(event: NostrEvent) { + events.push(event) + }, + oneose() { + // Get the most recent event, based on `created_at` field + if (events.length === 0) { + // No events found + resolve(null) + } + else { + const most_recent_event = events.sort((a, b) => a.created_at - b.created_at)[0] + resolve(most_recent_event) + } + relay.close() + } + }) + }); +} diff --git a/src/components/sections/PurpleFinalCTA.tsx b/src/components/sections/PurpleFinalCTA.tsx @@ -39,7 +39,7 @@ export function PurpleFinalCTA({ className }: { className?: string }) { style={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 1.5, duration: 1 } }} > - <Link href="#" className="w-full md:w-auto"> + <Link href="/purple/checkout" className="w-full md:w-auto"> <Button variant="default" className="w-full"> {intl.formatMessage({ id: "purple.final_cta.subscribe", defaultMessage: "Subscribe" })} </Button> diff --git a/src/components/sections/PurpleHero.tsx b/src/components/sections/PurpleHero.tsx @@ -37,7 +37,7 @@ export function PurpleHero() { style={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 1.5, duration: 1 } }} > - <Link href="#" className="w-full md:w-auto"> + <Link href="/purple/checkout" className="w-full md:w-auto"> <Button variant="default" className="w-full"> {intl.formatMessage({ id: "purple.hero.subscribe", defaultMessage: "Subscribe" })} </Button> diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx @@ -0,0 +1,26 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes<HTMLInputElement> { } + +const Input = React.forwardRef<HTMLInputElement, InputProps>( + ({ className, type, ...props }, ref) => { + return ( + <input + type={type} + className={cn( + "flex h-9 w-full rounded-md border border-white/30 bg-transparent text-white px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-white/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-damuspink-500 disabled:cursor-not-allowed disabled:opacity-50", + className + )} + ref={ref} + {...props} + /> + ) + } +) +Input.displayName = "Input" + +export { Input } + diff --git a/src/components/ui/Label.tsx b/src/components/ui/Label.tsx @@ -0,0 +1,27 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & + VariantProps<typeof labelVariants> +>(({ className, ...props }, ref) => ( + <LabelPrimitive.Root + ref={ref} + className={cn(labelVariants(), className)} + {...props} + /> +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } + diff --git a/src/components/ui/RoundedContainerWithGradientBorder.tsx b/src/components/ui/RoundedContainerWithGradientBorder.tsx @@ -1,9 +1,9 @@ import { cn } from "@/lib/utils" -export function RoundedContainerWithGradientBorder({ className, allItemsClassName, children }: { className?: string, allItemsClassName?: string, children: React.ReactNode }) { +export function RoundedContainerWithGradientBorder({ className, allItemsClassName, innerContainerClassName, children }: { className?: string, allItemsClassName?: string, innerContainerClassName?: string, children: React.ReactNode }) { return ( <div className={cn("relative w-24 h-24 flex justify-center items-center backdrop-blur-sm", allItemsClassName, className)}> - <div className="z-10 relative p-6 w-full h-full"> + <div className={cn("z-10 relative p-6 w-full h-full", innerContainerClassName)}> {children} </div> <div className={cn("absolute z-0 w-full h-full p-[1px] rounded-2xl bg-gradient-to-tr from-gray-800 via-gray-400 to-gray-800 opacity-40 shadow-lg", allItemsClassName)}> diff --git a/src/pages/purple/checkout/index.tsx b/src/pages/purple/checkout/index.tsx @@ -0,0 +1,43 @@ +import { Inter } from 'next/font/google' +import { IntlProvider, useIntl } from 'react-intl' +import English from "@/../content/compiled-locales/en.json"; +import Japanese from "@/../content/compiled-locales/ja.json"; +import { useEffect } from 'react'; +import { useState } from 'react'; +import { Purple } from '@/components/pages/purple'; +import { PurpleCheckout } from '@/components/pages/purple-checkout'; + +export default function HomePage() { + // Automatically detect the user's locale based on their browser settings + const [language, setLanguage] = useState("en"); + const [messages, setMessages] = useState(English); + + useEffect(() => { + setLanguage(navigator.language); + }, []); + + useEffect(() => { + switch (language) { + case "en": + setMessages(English); + break; + case "ja": + // TODO: Add Japanese translations and then switch to "Japanese" below + setMessages(English); + break; + default: + setMessages(English); + break; + } + }, [language]); + + return (<> + <IntlProvider + locale={language} + messages={messages} + onError={() => null}> + <PurpleCheckout /> + </IntlProvider> + </>) +} +