diff --git a/src/Router.tsx b/src/Router.tsx index 972c4db1..67484da0 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -30,6 +30,7 @@ const EditProfile = lazy(() => import('./pages/EditProfile')); const Profile = lazy(() => import('./pages/Profile')); const Mutelist = lazy(() => import('./pages/Mutelist')); const CreateAccount = lazy(() => import('./pages/CreateAccount')); +const Premium = lazy(() => import('./pages/Premium/Premium')); const NotifSettings = lazy(() => import('./pages/Settings/Notifications')); const Account = lazy(() => import('./pages/Settings/Account')); @@ -129,6 +130,8 @@ const Router: Component = () => { + + diff --git a/src/assets/icons/orange_check.svg b/src/assets/icons/orange_check.svg new file mode 100644 index 00000000..19993ebb --- /dev/null +++ b/src/assets/icons/orange_check.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/components/Buttons/ButtonFlip.tsx b/src/components/Buttons/ButtonFlip.tsx index 1f5c8962..5c50c60b 100644 --- a/src/components/Buttons/ButtonFlip.tsx +++ b/src/components/Buttons/ButtonFlip.tsx @@ -17,6 +17,8 @@ const ButtonFollow: Component<{ return props.when ? styles.flipActive : styles.flipInactive; } + const fallback = () => props.fallback || props.children + return ( {props.children} diff --git a/src/components/Buttons/ButtonPremium.tsx b/src/components/Buttons/ButtonPremium.tsx new file mode 100644 index 00000000..e204eec8 --- /dev/null +++ b/src/components/Buttons/ButtonPremium.tsx @@ -0,0 +1,27 @@ +import { Component, JSXElement, Match, Show, Switch } from 'solid-js'; +import { hookForDev } from '../../lib/devTools'; +import { Button } from "@kobalte/core"; + +import styles from './Buttons.module.scss'; + +const ButtonPremium: Component<{ + id?: string, + onClick?: (e: MouseEvent) => void, + children?: JSXElement, + disabled?: boolean, + type?: 'button' | 'submit' | 'reset' | undefined, +}> = (props) => { + return ( + + {props.children} + + ) +} + +export default hookForDev(ButtonPremium); diff --git a/src/components/Buttons/Buttons.module.scss b/src/components/Buttons/Buttons.module.scss index 49096fb2..33826de1 100644 --- a/src/components/Buttons/Buttons.module.scss +++ b/src/components/Buttons/Buttons.module.scss @@ -238,3 +238,21 @@ background-color: var(--subtile-devider); } } + +.premium { + display: flex; + justify-content: center; + align-items: center; + border: none; + border-radius: 99999px; + margin: 0px; + padding-inline: 18px; + padding-block: 10px; + font-size: 16px; + line-height: 16px; + font-weight: 600; + background: var(--premium-orange); + color: var(--text-primary-button); + width: 100%; + min-height: 36px; +} diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx index a05fe07d..bf2b1650 100644 --- a/src/components/TextInput/TextInput.tsx +++ b/src/components/TextInput/TextInput.tsx @@ -19,6 +19,9 @@ const TextInput: Component<{ autocomplete?: string, name?: string, noExtraSpace?: boolean, + inputClass?: string, + descriptionClass?: string, + errorClass?: string, }> = (props) => { return ( @@ -38,7 +41,7 @@ const TextInput: Component<{
- + {props.description} - + {props.errorMessage} diff --git a/src/index.scss b/src/index.scss index 4c6f8ebc..9bb29560 100644 --- a/src/index.scss +++ b/src/index.scss @@ -60,6 +60,8 @@ --warning-color: #FA3C3C; --success-color: #66E205; + --premium-orange: #FA3C3C; + --left-col-w: 187px; --center-col-w: 602px; --right-col-w: 348px; diff --git a/src/lib/premium.ts b/src/lib/premium.ts new file mode 100644 index 00000000..990f5bdc --- /dev/null +++ b/src/lib/premium.ts @@ -0,0 +1,16 @@ +export const checkPremiumName = (name: string, subId: string, socket: WebSocket) => { + const message = JSON.stringify([ + "REQ", + subId, + {cache: ["membership_name_available", { name }]}, + ]); + + if (socket) { + const e = new CustomEvent('send', { detail: { message, ws: socket }}); + + socket.send(message); + socket.dispatchEvent(e); + } else { + throw('no_socket'); + } +} diff --git a/src/pages/Premium/Premium.module.scss b/src/pages/Premium/Premium.module.scss new file mode 100644 index 00000000..c2693e7b --- /dev/null +++ b/src/pages/Premium/Premium.module.scss @@ -0,0 +1,227 @@ +.premiumContent { + width: 100%; + border-top: 1px solid var(--devider); + padding-top: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .premiumStepContent { + width: 432px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .title { + font-size: 16px; + font-weight: 600; + line-height: 20px; + color: var(--text-secondary); + text-transform: uppercase; + margin-bottom: 24px; + } + + .input { + width: 280px; + + .centralize { + text-align: center; + } + + .centralError { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + } + } + + .congrats { + color: var(--text-primary); + font-size: 20px; + font-weight: 600; + line-height: 28px; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + margin-bottom: 24px; + } + } +} + +.premiumSummary { + border-top: 1px solid var(--devider); + padding-top: 26px; + margin-bottom: 48px; + + display: flex; + width: 100%; + flex-direction: column; + align-items: flex-start; + + .summaryItem { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; + + font-size: 14px; + font-weight: 400; + line-height: 48px; + color: var(--text-secondary); + + .summaryName { + font-weight: 700; + color: var(--text-primary); + } + } +} + +.premiumProfile { + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + margin-bottom: 28px; + gap: 16px; + + .userInfo { + display: flex; + align-items: center; + justify-content: center; + + color: var(--text-primary); + font-size: 22px; + font-weight: 700; + line-height: 24px; + } +} + +.subOptions { + display: flex; + width: 100%; + gap: 16px; + justify-content: space-between; + align-items: center; + margin-bottom: 28px; + + .selectedOption { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + + .price { + font-size: 18px; + font-weight: 700; + line-height: 18px; + } + + .duration { + color: var(--text-tertiary); + font-size: 14px; + font-weight: 600; + line-height: 14px; + + &.hot { + color: var(--text-secondary); + } + } + } +} + +.subscribeModal { + position: fixed; + + display: flex; + flex-direction: column; + padding: 20px 24px 28px 24px; + width: 472px; + height: 616px; + border-radius: 12px; + background: var(--background-input); + + .header { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 24px; + + .userInfo { + display: flex; + justify-content: flex-start; + align-items: flex-start; + + .avatar { + display: flex; + align-items: center; + justify-content: center; + } + + .details { + display: flex; + flex-direction: column; + margin-left: 8px; + justify-content: center; + height: 44px; + + .name { + display: flex; + flex-direction: row; + align-items: center; + color: var(--text-primary); + font-size: 20px; + font-weight: 700; + line-height: 20px; + } + + .verification { + color: var(--text-tertiary); + font-size: 16px; + font-weight: 400; + line-height: 16px; + } + } + + } + + .close { + border: none; + outline: none; + padding: 0; + margin: 0; + box-shadow: none; + width: 20px; + height: 20px; + display: inline-block; + margin: 0px 0px; + background-color: var(--text-secondary); + -webkit-mask: url(../../assets/icons/close.svg) no-repeat center; + mask: url(../../assets/icons/close.svg) no-repeat center; + + &:hover { + background-color: var(--text-primary); + } + } + } +} + +.orangeCheck { + width: 24px; + height: 24px; + display: inline-block; + background-image: url(../../assets/icons/orange_check.svg); + margin-inline: 6px; + + &.small { + width: 18px; + height: 18px; + background-size: 18px; + } +} diff --git a/src/pages/Premium/Premium.tsx b/src/pages/Premium/Premium.tsx new file mode 100644 index 00000000..5e481453 --- /dev/null +++ b/src/pages/Premium/Premium.tsx @@ -0,0 +1,229 @@ +import { useIntl } from '@cookbook/solid-intl'; +import { Component, createEffect, Match, onCleanup, onMount, Show, Switch } from 'solid-js'; +import PageCaption from '../../components/PageCaption/PageCaption'; +import PageTitle from '../../components/PageTitle/PageTitle'; +import Wormhole from '../../components/Wormhole/Wormhole'; + +import { premium as t } from '../../translations'; + +import styles from './Premium.module.scss'; +import Search from '../../components/Search/Search'; +import { A, useNavigate, useParams } from '@solidjs/router'; +import TextInput from '../../components/TextInput/TextInput'; +import { createStore } from 'solid-js/store'; +import { NostrEOSE, NostrEvent, NostrEventContent, NostrEventType } from '../../types/primal'; +import { APP_ID } from '../../App'; +import { checkPremiumName } from '../../lib/premium'; +import ButtonPremium from '../../components/Buttons/ButtonPremium'; +import PremiumSummary from './PremiumSummary'; +import Avatar from '../../components/Avatar/Avatar'; +import { useAccountContext } from '../../contexts/AccountContext'; +import ButtonFlip from '../../components/Buttons/ButtonFlip'; +import Modal from '../../components/Modal/Modal'; +import VerificationCheck from '../../components/VerificationCheck/VerificationCheck'; +import { authorName, nip05Verification } from '../../stores/profile'; +import PremiumSubscriptionOptions, { PremiumOption } from './PremiumSubscriptionOptions'; +import PremiumProfile from './PremiumProfile'; +import PremiumSubscribeModal from './PremiumSubscribeModal'; + +export type PremiumStore = { + name: string, + nameAvailable: boolean, + errorMessage: string, + subOptions: PremiumOption[], + selectedSubOption: PremiumOption, + openSubscribe: boolean, +} + +const availablePremiumOptions: PremiumOption[] = [ + { id: 'short', price: 'm7', duration: 'm3' }, + { id: 'long', price: 'm6', duration: 'm12' }, +]; + +const Premium: Component = () => { + const intl = useIntl(); + const account = useAccountContext(); + const params = useParams(); + const navigate = useNavigate(); + + let nameInput: HTMLInputElement | undefined; + + let premiumSocket: WebSocket | undefined; + + const [premiumData, setPremiumData] = createStore({ + name: '', + nameAvailable: true, + errorMessage: '', + subOptions: availablePremiumOptions, + selectedSubOption: availablePremiumOptions[0], + openSubscribe: false, + }); + + const setName = (name: string) => { + setPremiumData('errorMessage', () => ''); + setPremiumData('name', () => name); + }; + + const checkName = () => { + if (!premiumSocket) return; + + const subid = `name_check_${APP_ID}`; + + if (premiumData.name.length < 3) { + setPremiumData('errorMessage', () => intl.formatMessage(t.errors.nameTooShort)); + return; + } + + const unsub = subTo(premiumSocket, subid, (type, _, content) => { + if (type === 'EVENT') { + const response: { available: boolean } = JSON.parse(content?.content || '{ "available": false}'); + + setPremiumData('nameAvailable', () => response.available ); + } + + if (type === 'EOSE') { + unsub(); + + if (premiumData.nameAvailable) { + navigate('/premium/subscribe'); + } else { + setPremiumData('errorMessage', () => intl.formatMessage(t.errors.nameUnavailable)); + } + } + }); + + checkPremiumName(premiumData.name, subid, premiumSocket); + }; + + const subscribeToPremium = () => { + setPremiumData('openSubscribe', () => true); + }; + + const subTo = (socket: WebSocket, subId: string, cb: (type: NostrEventType, subId: string, content?: NostrEventContent) => void ) => { + const listener = (event: MessageEvent) => { + const message: NostrEvent | NostrEOSE = JSON.parse(event.data); + const [type, subscriptionId, content] = message; + + if (subId === subscriptionId) { + cb(type, subscriptionId, content); + } + + }; + + socket.addEventListener('message', listener); + + return () => { + socket.removeEventListener('message', listener); + }; + }; + + onMount(() => { + console.log('MOUNT') + premiumSocket = new WebSocket('wss://wallet.primal.net/v1'); + + premiumSocket.addEventListener('close', () => { + console.log('PREMIUM SOCKET CLOSED'); + }); + }); + + onCleanup(() => { + console.log('CLEANUP') + premiumSocket?.close(); + }); + + createEffect(() => { + if (params.step === 'name') { + nameInput?.focus(); + } + }); + + createEffect(() => { + setPremiumData('name', () => account?.activeUser?.name || ''); + }); + + return ( +
+ + + + + + + + +
+
+ + Start +
+ }> + +
+ {intl.formatMessage(t.title.name)} +
+ +
+ 0 ? 'invalid' : 'valid'} + errorMessage={premiumData.errorMessage} + type="text" + inputClass={styles.centralize} + descriptionClass={styles.centralize} + errorClass={styles.centralError} + /> +
+ + + + + {intl.formatMessage(t.actions.next)} + +
+ + +
+
{intl.formatMessage(t.title.subscription)}
+
{intl.formatMessage(t.title.subscriptionSubtitle)}
+
+ + + + + + setPremiumData('selectedSubOption', () => option)} + /> + + + {intl.formatMessage(t.actions.subscribe)} + + + setPremiumData('openSubscribe', () => false)} + /> +
+ +
+
+
+ ); +} + +export default Premium; diff --git a/src/pages/Premium/PremiumProfile.tsx b/src/pages/Premium/PremiumProfile.tsx new file mode 100644 index 00000000..32f19ab9 --- /dev/null +++ b/src/pages/Premium/PremiumProfile.tsx @@ -0,0 +1,27 @@ +import { Component } from 'solid-js'; +import Avatar from '../../components/Avatar/Avatar'; +import { userName } from '../../stores/profile'; + +import { PrimalUser } from '../../types/primal'; + +import styles from './Premium.module.scss'; + + +const PremiumProfile: Component<{ profile?: PrimalUser }> = (props) => { + + return ( +
+ + +
+
{userName(props.profile)}
+
+
+
+ ); +} + +export default PremiumProfile; diff --git a/src/pages/Premium/PremiumSubscribeModal.tsx b/src/pages/Premium/PremiumSubscribeModal.tsx new file mode 100644 index 00000000..7182db46 --- /dev/null +++ b/src/pages/Premium/PremiumSubscribeModal.tsx @@ -0,0 +1,59 @@ +import { Component, Show } from 'solid-js'; +import Avatar from '../../components/Avatar/Avatar'; +import Modal from '../../components/Modal/Modal'; +import { authorName, nip05Verification, userName } from '../../stores/profile'; +import { account } from '../../translations'; + +import { PrimalUser } from '../../types/primal'; + +import styles from './Premium.module.scss'; + + +const PremiumSubscribeModal: Component<{ + profile?: PrimalUser, + open?: boolean, + onClose: () => void, +}> = (props) => { + + return ( + +
+ +
+ +
+
+ +
+
+
+ {authorName(props.profile)} +
+
+
+ + + {nip05Verification(props.profile)} + + +
+
+
+ +
+
+
+ ); +} + +export default PremiumSubscribeModal diff --git a/src/pages/Premium/PremiumSubscriptionOptions.tsx b/src/pages/Premium/PremiumSubscriptionOptions.tsx new file mode 100644 index 00000000..4ccc1a1e --- /dev/null +++ b/src/pages/Premium/PremiumSubscriptionOptions.tsx @@ -0,0 +1,46 @@ +import { useIntl } from '@cookbook/solid-intl'; +import { Component, For } from 'solid-js'; +import ButtonFlip from '../../components/Buttons/ButtonFlip'; + +import { premium as t } from '../../translations'; + +import styles from './Premium.module.scss'; + +export type PremiumOption = { + id: string, + price: 'm7' | 'm6', + duration: 'm3' | 'm12', +}; + +const PremiumSubscriptionOptions: Component<{ + options: PremiumOption[], + selectedOption: PremiumOption, + onSelect: (option: PremiumOption) => void, +}> = (props) => { + const intl = useIntl(); + + return ( +
+ + {option => + props.onSelect(option)} + > +
+
+ {intl.formatMessage(t.subOptions.prices[option.price])} +
+ +
+ {intl.formatMessage(t.subOptions.durations[option.duration])} +
+
+
+ } +
+
+ ); +} + +export default PremiumSubscriptionOptions; diff --git a/src/pages/Premium/PremiumSummary.tsx b/src/pages/Premium/PremiumSummary.tsx new file mode 100644 index 00000000..6e820226 --- /dev/null +++ b/src/pages/Premium/PremiumSummary.tsx @@ -0,0 +1,41 @@ +import { useIntl } from '@cookbook/solid-intl'; +import { Component } from 'solid-js'; + +import { premium as t } from '../../translations'; + +import styles from './Premium.module.scss'; + + +const PremiumSummary: Component<{ name: string }> = (props) => { + const intl = useIntl(); + + return ( +
+
+
Your verified nostr address
+
+ {props.name} + @primal.net +
+
+ +
+
Your custom lightning address
+
+ {props.name} + @primal.net +
+
+ +
+
Your VIP profile url on primal.net
+
+ primal.net/ + {props.name} +
+
+
+ ); +} + +export default PremiumSummary; diff --git a/src/translations.ts b/src/translations.ts index 930862a4..88a9cc6f 100644 --- a/src/translations.ts +++ b/src/translations.ts @@ -1947,3 +1947,78 @@ export const upload = { description: 'Faces emoji group title', }, }; + +export const premium = { + actions: { + next: { + id: 'pages.premium.actions.next', + defaultMessage: 'Next', + description: 'To the next step on the premium page', + }, + subscribe: { + id: 'pages.premium.actions.subscribe', + defaultMessage: 'Subscribe', + description: 'Subscribe action on the premium page', + }, + }, + title: { + general: { + id: 'pages.premium.title', + defaultMessage: 'Premium', + description: 'Title of the premium page', + }, + name: { + id: 'pages.premium.name', + defaultMessage: 'Choose a primal name', + description: 'Title of the premium find name page', + }, + subscription: { + id: 'pages.premium.subscription', + defaultMessage: 'Premium', + description: 'Title of the premium page', + }, + subscriptionSubtitle: { + id: 'pages.premium.subscriptionSubtitle', + defaultMessage: 'Premium', + description: 'Title of the premium page', + }, + }, + subOptions: { + prices: { + m7: { + id: 'pages.premium.subOption.m7', + defaultMessage: '$7/month', + description: '$7 per month', + }, + m6: { + id: 'pages.premium.subOption.m6', + defaultMessage: '$6/month', + description: '$6 per month', + }, + }, + durations: { + m3: { + id: 'pages.premium.duration.m3', + defaultMessage: '3 months', + description: '3 month duration', + }, + m12: { + id: 'pages.premium.duration.m12', + defaultMessage: '12 months', + description: '12 month duration', + }, + }, + }, + errors: { + nameTooShort: { + id: 'pages.premium.error.nameTooShort', + defaultMessage: 'Name needs to be at least 3 characters long', + description: 'Name is too short error', + }, + nameUnavailable: { + id: 'pages.premium.error.nameUnavailable', + defaultMessage: 'Sorry, that name is currently unavailable', + description: 'Name is unavailable error', + }, + } +};