@@ -441,61 +356,109 @@ function MobileMenu() {
aria-modal="true"
aria-label="Menu"
tabIndex={-1}
- className="bg-card border-border relative mx-4 mt-3 h-fit w-full max-w-[420px] rounded-[28px] border px-5 pt-4 pb-5 shadow-2xl outline-none"
+ className="bg-card text-card-foreground relative w-full rounded-b-[1.75rem] px-5 pt-3 pb-4 shadow-[0_24px_48px_-24px_rgba(24,36,48,0.35)] outline-none"
>
-
-
+
+
-
- {marketingLinks.map((link) => (
-
- {link.label}
-
- ))}
-
-
-
-
-
-
- Start your 14-day free trial
-
-
-
-
-
-
- Log In
-
-
-
+
+ {user ? (
+ <>
+ {subscriptionTier === 'premium' ? null : (
+
+
+
+ {subscriptionTier === 'basic'
+ ? 'Upgrade to Premium Account'
+ : 'Start 14-day FREE Trial'}
+
+
+ )}
+
+
+
+ Recipients
+
+
+
+ Settings
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ Start 14-day FREE Trial
+
+
+
+
+
+ Log In
+
+
+ >
+ )}
+
+
+
+
+
+ {themeLabel}
+
+
+
-
-
-
-
-
- {themeLabel}
-
-
) : null}
diff --git a/app/routes/_app+/_marketing+/index.tsx b/app/routes/_app+/_marketing+/index.tsx
index 53e958e6..9bc7122d 100644
--- a/app/routes/_app+/_marketing+/index.tsx
+++ b/app/routes/_app+/_marketing+/index.tsx
@@ -1,352 +1,435 @@
-import { Link, type MetaFunction } from 'react-router'
+import { Form, Link, type MetaFunction } from 'react-router'
+import { FlowerSticker } from '#app/components/marketing/sticker.tsx'
+import { WaveEdge } from '#app/components/marketing/wave.tsx'
import { Button } from '#app/components/ui/button.js'
-import { Icon, type IconName } from '#app/components/ui/icon.tsx'
+import { Icon } from '#app/components/ui/icon.tsx'
+import { inputClassName } from '#app/components/ui/input.tsx'
+import { cn } from '#app/utils/misc.tsx'
import { useOptionalUser } from '#app/utils/user.js'
export const meta: MetaFunction = () => [
- { title: 'GratiText | Thoughtful gratitude texts, on your schedule' },
+ { title: 'GratiText | Thoughtful Connections Made Simple' },
]
-const steps: Array<{ title: string; description: string; icon: IconName }> = [
+export const handle = { footerTint: 'pricing' }
+
+const steps = [
{
- title: 'Create your account',
+ title: 'Sign Up',
description:
- 'Sign up with your phone number. Your first 14 days are free, no credit card needed.',
- icon: 'avatar',
+ 'Create an account and start sending thoughtful messages to your loved ones.',
},
{
- title: 'Add a loved one',
- description:
- 'Enter the name and phone number of someone you want to stay close to.',
- icon: 'phone',
+ title: 'Add a Loved One',
+ description: 'Add the phone number of your loved one to get started.',
},
{
- title: 'Pick a schedule',
- description:
- 'Choose the day and time your notes should arrive, in their time zone.',
- icon: 'clock',
+ title: 'Select a Schedule',
+ description: 'Choose how often you want to send messages.',
},
{
- title: 'Write in your own words',
+ title: 'Write a Personal Message',
description:
- 'Queue up personal notes of thanks whenever inspiration strikes.',
- icon: 'pencil-1',
+ 'Craft meaningful messages that will be sent to your loved ones.',
},
{
- title: 'We deliver on time',
+ title: 'Message Sent',
description:
- 'Each note is texted automatically at the moment you scheduled it.',
- icon: 'send',
+ 'Our system sends your messages to your loved ones automatically on the schedule.',
},
{
- title: 'Never run dry',
+ title: 'Reminders',
description:
- 'We remind you when a recipient has no message waiting, so no send date is missed.',
- icon: 'exclamation-circle-outline',
+ 'We remind you when a loved one has no message waiting, so no send date is missed.',
},
]
const plans = [
{
name: 'Basic',
- price: '$4.99',
- cadence: 'per month',
- summary: 'One heartfelt note a day.',
- features: [
- '1 message per day',
- 'As many recipients as you like',
- 'Reminders when a queue runs empty',
- ],
- priceClassName: 'text-price-basic',
- variant: 'secondary' as const,
+ summary: '1 message per day',
+ dollars: '4',
+ cents: '99',
+ featured: false,
},
{
name: 'Premium',
- price: '$14.99',
- cadence: 'per month',
- summary: 'Room for the whole family and your closest friends.',
- features: [
- 'Up to 10 messages per day',
- 'As many recipients as you like',
- 'Reminders when a queue runs empty',
- ],
- priceClassName: 'text-price-premium',
- variant: 'default' as const,
- highlighted: true,
+ summary: '10 messages per day',
+ dollars: '14',
+ cents: '99',
+ featured: true,
+ },
+]
+
+/**
+ * Paper plane and its dashed flight path, traced from the design at 1440px.
+ * Coordinates are relative to the "How it works" container: x from the
+ * container's left padding edge, y from the top of the heading. The path
+ * starts at the plane's tail so the two always meet.
+ */
+const PLANE = { x: 1009, y: 136, width: 117, height: 93 }
+const TRAIL_PATH =
+ 'M1049 204C1045 203 1040 198 1025 200C1011 202 982 209 962 216C942 223 921 230 904 242C887 254 870 271 862 289C855 308 854 333 859 353C864 373 878 401 893 408C908 415 941 410 950 397C959 385 956 352 948 333C940 315 921 298 904 286C887 274 865 266 844 260C824 254 803 250 781 249C760 248 736 250 715 254C694 258 673 264 653 273C633 282 614 293 597 306C580 319 564 334 549 350C534 366 520 383 508 402C496 421 486 441 477 462C468 483 461 505 455 527C449 549 446 571 442 593C438 615 437 639 432 661C428 683 423 706 415 727C407 748 399 768 386 786C374 804 358 822 340 835C323 848 303 859 281 865C260 871 229 871 211 870C193 870 189 868 172 862C155 856 131 846 111 836C92 826 74 812 55 800C37 788 19 776 0 765C-19 754 -41 741 -57 733C-73 725 -91 720 -98 717'
+// The drawing spans from 120px left of the container to its right edge.
+const TRAIL_VIEWBOX = { x: -120, y: 100, width: 1336, height: 800 }
+
+const smileys = [
+ {
+ src: '/images/smiley-pink.png',
+ className:
+ '-top-8 left-[20%] w-14 md:-top-[6.25rem] md:left-[12.5%] md:w-[5.25rem]',
+ },
+ {
+ src: '/images/smiley-lilac.png',
+ className:
+ '-top-12 right-[32%] w-11 md:-top-[7.5rem] md:right-[15.5%] md:w-20',
+ },
+ {
+ src: '/images/smiley-blue.png',
+ className: 'top-7 -left-3 w-14 md:top-[7.5rem] md:-left-5 md:w-20',
+ },
+ {
+ src: '/images/smiley-orange.png',
+ className: '-top-2 right-0 w-14 md:top-16 md:-right-8 md:w-[5.25rem]',
},
]
export default function Index() {
const user = useOptionalUser()
- const primaryCta = user
- ? { to: '/recipients', label: 'Open your recipients' }
- : { to: '/signup', label: 'Start your 14-day free trial' }
- const planCta = user ? '/settings/profile/subscription' : '/signup'
+ const getStartedTo = user ? '/recipients' : '/signup'
+ const planTo = user ? '/settings/profile/subscription' : '/signup'
return (
-
-
-
-
- Gratitude, delivered by text
-
-
- Thoughtful connections, made simple
-
-
- Write heartfelt notes to the people you love and GratiText texts
- them on the schedule you choose. Your words, in your voice, arriving
- right on time.
-
-
-
+
+
+
+
- {primaryCta.label}
-
+ Thoughtful
+
+ Connections
+
+ Made Simple
+
+
+ Strengthen your relationships with regular personalized messages
+ of love and gratitude.
+
-
- See how it works
-
-
+
+ Start 14-day FREE trial
+ Get started
+
- {user ? null : (
-
- No credit card required. Cancel anytime.
-
- )}
-
-
-
-
-
-
-
-
-
- Your voice, not a robot's
-
-
- Every message is written by you. Never by AI.
-
-
- GratiText handles the scheduling and delivery so you can stay
- connected without losing what makes your notes special: they come
- from you.
-
-
+
+
+ {/* The phone design ends the green band with a straight edge. */}
+
+ {/* Both stickers sit centred on the wave lines in the design. */}
+
+
+ you are all
+
+
+ kinds of
+
+
+ awesome
+
+
+
+
+ create
+
+ smiles
+
+
+
+
+ Messages written
+
+ by you⦠Not by AI.
+
+
+ Our platform schedules and delivers personal heartfelt messages from
+ you, making it easy to stay connected and nurture your most
+ important relationships.
+
-
-
- How it works
-
-
+
- Six small steps to a lasting habit
-
-
- Whether it is a simple thank you or a meaningful reminder of your
- affection, GratiText helps you make a lasting impact on the people
- you care about.
-
-
-
- {steps.map((step, index) => (
-
+
+
+
+
-
-
-
-
-
+ How does
+
+ gratitext work?
+
+
+ Whether it's a simple thank you or a meaningful reminder of your
+ affection, GratiText helps you make a lasting impact on the people
+ you care about.
+
+
+ {/* The design leaves room above the steps for the plane's loop. */}
+
+ {steps.map((step, index) => (
+
+
{String(index + 1).padStart(2, '0')}
-
-
-
+
{step.title}
-
+
{step.description}
-
-
- ))}
-
+
+ ))}
+
+
+
-
-
- Pricing
-
-
- Simple plans, 14 days free
-
-
- Pick the plan that matches how many people you want to reach.
- Every plan starts with a free two-week trial.
-
+
+ {smileys.map((smiley) => (
+
+ ))}
+
+
+ Affordable
+
+ pricing plans
+
+
+ Whether it's a simple thank you or a meaningful reminder of your
+ affection, GratiText helps you make a lasting impact on the
+ people you care about.
+
+
-
+
-
-
-
-
- Ready when you are
-
+ {/* Full-bleed on phones, a rounded card inside the container on desktop. */}
+
+
+ {/* The sun and heart are cropped by the banner edge in the design. */}
+
+
+
+
+
- Send your first note of thanks today.
+ Create your account today and
+ get 2 weeks for free!
-
- Your first 14 days are on us. No credit card required.
-
+ {user ? (
+
+ Go to your recipients
+
+ ) : (
+ // Signup reads `phoneNumber` from the query string to prefill.
+
+ )}
-
- {primaryCta.label}
-
diff --git a/app/routes/_app+/recipients+/$recipientId.index.tsx b/app/routes/_app+/recipients+/$recipientId.index.tsx
index 2f5b4354..e3af6bcf 100644
--- a/app/routes/_app+/recipients+/$recipientId.index.tsx
+++ b/app/routes/_app+/recipients+/$recipientId.index.tsx
@@ -2,6 +2,7 @@ import { getFormProps, getTextareaProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod/v4'
import { invariantResponse } from '@epic-web/invariant'
import {
+ type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
@@ -24,25 +25,22 @@ import {
GeneralErrorBoundary,
} from '#app/components/error-boundary.tsx'
import { ErrorList } from '#app/components/forms.js'
-import { SearchBar } from '#app/components/search-bar.tsx'
import { Button } from '#app/components/ui/button.tsx'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from '#app/components/ui/dropdown-menu.tsx'
import { Icon } from '#app/components/ui/icon.tsx'
import { StatusButton } from '#app/components/ui/status-button.js'
import { requireUserId } from '#app/utils/auth.server.ts'
import { getHints } from '#app/utils/client-hints.js'
-import {
- CronParseError,
- formatSendTime,
- getSendTime,
-} from '#app/utils/cron.server.js'
+import { CronParseError, getSendTime } from '#app/utils/cron.server.js'
import { prisma } from '#app/utils/db.server.ts'
+import { formatThreadDate, formatThreadTime } from '#app/utils/format-date.ts'
+import { cn } from '#app/utils/misc.tsx'
+import { getSubscriptionTier } from '#app/utils/stripe.server.ts'
import { sendTextToRecipient } from '#app/utils/text.server.js'
import { createToastHeaders } from '#app/utils/toast.server.js'
@@ -50,6 +48,8 @@ type LoaderData = Awaited
>['data']
type FutureMessage = LoaderData['futureMessages'][number]
const PAST_MESSAGES_PER_PAGE = 30
+/** Mirrors the per-day send allowance enforced in `sendTextToRecipient`. */
+const DAILY_SEND_LIMIT = { none: 0, basic: 1, premium: 10 } as const
function parseDateValue(value: string) {
if (!value) return null
@@ -161,9 +161,31 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
throw new Response('Not found', { status: 404 })
}
- const optOut = await prisma.optOut.findUnique({
- where: { phoneNumber: recipient.phoneNumber },
- })
+ const [optOut, user] = await Promise.all([
+ prisma.optOut.findUnique({
+ where: { phoneNumber: recipient.phoneNumber },
+ }),
+ prisma.user.findUniqueOrThrow({
+ where: { id: userId },
+ select: { stripeId: true },
+ }),
+ ])
+ const subscriptionTier = await getSubscriptionTier(user.stripeId)
+ const dailyLimit = DAILY_SEND_LIMIT[subscriptionTier]
+ const sentInLastDay = dailyLimit
+ ? await prisma.message.count({
+ where: {
+ recipient: { userId },
+ sentAt: { gte: new Date(Date.now() - 1000 * 60 * 60 * 23) },
+ },
+ })
+ : 0
+ const sendNow: { disabled: boolean; reason: string | null } =
+ subscriptionTier === 'none'
+ ? { disabled: true, reason: 'Subscribe to send messages' }
+ : sentInLastDay >= dailyLimit
+ ? { disabled: true, reason: "Today's limit reached" }
+ : { disabled: false, reason: null }
const startDate = getStartDate(
startDateFilter,
@@ -201,10 +223,12 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
: null
const { userId: _userId, messages, ...recipientProps } = recipient
+ const displayTimeZone = hints.timeZone || recipient.timeZone
return json({
optedOut: Boolean(optOut),
recipient: recipientProps,
+ sendNow,
searchQuery,
startDateFilter,
endDateFilter,
@@ -227,6 +251,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
earlierOrder: null,
laterOrder: null,
sendAtDisplay: null as string | null,
+ sendAtTime: null as string | null,
}
if (optOut) return base
@@ -240,15 +265,17 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const isLast = i === arr.length - 1
const earlierOrder = isFirst ? null : (oneBefore + twoBefore) / 2
const laterOrder = isLast ? null : (oneAfter + twoAfter) / 2
- const sendAtDisplay = formatSendTime(
- getSendTime(recipient.scheduleCron, { tz: recipient.timeZone }, i),
- hints.timeZone || recipient.timeZone,
+ const sendAt = getSendTime(
+ recipient.scheduleCron,
+ { tz: recipient.timeZone },
+ i,
)
return {
...base,
earlierOrder,
laterOrder,
- sendAtDisplay,
+ sendAtDisplay: formatThreadDate(sendAt, displayTimeZone),
+ sendAtTime: formatThreadTime(sendAt, displayTimeZone),
}
} catch (error) {
return {
@@ -262,14 +289,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
}),
pastMessages: pastPageMessages.map((m) => ({
id: m.id,
- sentAtDisplay: m.sentAt!.toLocaleDateString('en-US', {
- weekday: 'short',
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- hour: 'numeric',
- minute: 'numeric',
- }),
+ sentAtDisplay: formatThreadDate(m.sentAt!, displayTimeZone),
+ sentAtTime: formatThreadTime(m.sentAt!, displayTimeZone),
sentAtIso: m.sentAt!.toISOString(),
content: m.content,
})),
@@ -450,12 +471,43 @@ async function updateMessageOrderAction({ formData }: MessageActionArgs) {
return json({ result: submission.reply() }, { status: 200 })
}
+const bubbleClassName =
+ 'flex w-full flex-col gap-3 rounded-[1.25rem] px-5 py-4 text-base leading-relaxed md:max-w-[35rem] md:px-6 md:py-5'
+
+/**
+ * Sizes a textarea to its content without JS: an invisible replica of the
+ * text sits in the same grid cell so the cell (and the textarea) grows with
+ * it. Give the wrapper and the textarea the same font and padding classes.
+ */
+function GrowWrap({
+ value,
+ className,
+ children,
+}: {
+ value: string
+ className?: string
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
export default function RecipientRoute() {
const data = useLoaderData()
const newMessageFetcher = useFetcher()
const isCreating = newMessageFetcher.state !== 'idle'
const newMessageInputRef = useRef(null)
const shouldClearMessageInput = useRef(false)
+ const [draft, setDraft] = useState('')
const [searchParams] = useSearchParams()
const loadMoreFetcher = useFetcher()
const loadMoreData = loadMoreFetcher.data ?? null
@@ -482,6 +534,7 @@ export default function RecipientRoute() {
const hasErrors = Boolean(newMessageFetcher.data?.result?.error)
if (!hasErrors && newMessageInputRef.current) {
newMessageInputRef.current.value = ''
+ setDraft('')
}
shouldClearMessageInput.current = false
}, [newMessageFetcher.state, newMessageFetcher.data])
@@ -578,150 +631,150 @@ export default function RecipientRoute() {
const hasPastMessages = pastMessagesForDisplay.length > 0
const hasFutureMessages = data.futureMessages.length > 0
const hasAnyMessages = hasPastMessages || hasFutureMessages
- const emptyThreadMessage = isPastFiltered
- ? 'No messages match your search.'
- : 'No messages yet.'
- const loadMoreLabel = pastNextCursor
- ? isLoadingMore
- ? 'Loading earlier messages...'
- : 'Scroll up to load earlier messages.'
- : 'Beginning of thread.'
+ const hasDraft = draft.trim().length > 0
+ const newMessageErrors = newMessageFetcher.data?.result?.error
+ ? (newMessageFetcher.data.result.error.content ??
+ newMessageFetcher.data.result.error[''] ??
+ [])
+ : null
return (
-
- {data.cronError ? (
-
- Invalid Schedule: {' '}
- {data.cronError}{' '}
-
- Update the schedule
-
-
- ) : null}
-
-
-
-
Messages
-
- {hasFutureMessages
- ? `${data.futureMessages.length} ${data.futureMessages.length === 1 ? 'message' : 'messages'} queued to send${hasPastMessages ? ', with your history above.' : '.'}`
- : 'Nothing queued yet. Add a note below and it will go out on the next scheduled send.'}
-
+
+
+ {data.cronError ? (
+
+ Invalid schedule: {' '}
+ {data.cronError}{' '}
+
+ Update the schedule
+
-
-
+ ) : null}
{hasAnyMessages ? (
-
- {hasPastMessages || pastNextCursor ? (
-
- {loadMoreLabel}
-
+ <>
+ {pastNextCursor ? (
+
+ {isLoadingMore
+ ? 'Loading earlier messagesβ¦'
+ : 'Scroll up to load earlier messages'}
+
) : null}
-
+ >
) : (
-
+
- {emptyThreadMessage}
+ {isPastFiltered
+ ? 'No messages match your search.'
+ : 'No messages yet.'}
+
+
+ {isPastFiltered
+ ? 'Try a different search or clear the filters to see everything.'
+ : 'Write a short note of thanks below. It will be sent at the next scheduled time.'}
- {isPastFiltered ? (
-
- Try a different search or clear the filters to see everything.
-
- ) : (
- <>
-
- Write a short note of thanks below. It will be sent at the
- next scheduled time.
-
-
newMessageInputRef.current?.focus()}
- >
-
- Write your first message
-
-
- >
- )}
)}
-
-
-
+
+
+
Add a new message
-
-
+
+
+
-
- Add
-
+
+ Add to Queue
+ Add
-
- The newest message goes out first at the next scheduled send. You can
- edit or send any queued message early from its menu.
-
- {newMessageFetcher.data?.result?.error ? (
-
+ {newMessageErrors ? (
+
+
+
) : null}
)
}
-function MessageForms({ message }: { message: FutureMessage }) {
+function MessageForms({
+ message,
+ sendNow,
+}: {
+ message: FutureMessage
+ sendNow: LoaderData['sendNow']
+}) {
const updateContentFetcher = useFetcher
()
const sendNowFetcher = useFetcher()
const deleteFetcher = useFetcher()
@@ -740,10 +793,16 @@ function MessageForms({ message }: { message: FutureMessage }) {
},
shouldRevalidate: 'onBlur',
})
- const cardTone = 'bg-message-card'
- const scheduleLabel = message.sendAtDisplay
- ? `Scheduled for ${message.sendAtDisplay}`
- : 'Scheduled message'
+ const scheduleLabel = message.sendAtDisplay ? (
+ <>
+ Scheduled for{' '}
+
+ {message.sendAtDisplay}
+
+ >
+ ) : (
+ 'Scheduled message'
+ )
const sendErrors = getResultErrors(sendNowFetcher.data?.result)
const deleteErrors = getResultErrors(deleteFetcher.data?.result)
const updateIsPending = updateContentFetcher.state !== 'idle'
@@ -793,27 +852,37 @@ function MessageForms({ message }: { message: FutureMessage }) {
setConfirmDelete(false)
}
+ const errors = [
+ ...(updateContentForm.errors ?? []),
+ ...(updateContentFields.content.errors ?? []),
+ ...(sendErrors ?? []),
+ ...(deleteErrors ?? []),
+ ]
+
return (
-
+
-
-
+
-
+
{scheduleLabel}
+
+
{showSaveButton ? (
@@ -841,30 +910,59 @@ function MessageForms({ message }: { message: FutureMessage }) {
-
- Send Now
+
+
+ Send Now
+ {sendNow.reason ? (
+
+ {sendNow.reason}
+
+ ) : null}
+
-
+
Edit Message
-
-
+
{confirmDelete ? 'Confirm delete' : 'Delete'}
@@ -879,29 +977,27 @@ function MessageForms({ message }: { message: FutureMessage }) {
Message content
-
-
-
-
-
-
-
+ {errors.length ? (
+
+
+
+ ) : null}
)
}
diff --git a/app/routes/_app+/recipients+/$recipientId.tsx b/app/routes/_app+/recipients+/$recipientId.tsx
index b21e00a5..098874b4 100644
--- a/app/routes/_app+/recipients+/$recipientId.tsx
+++ b/app/routes/_app+/recipients+/$recipientId.tsx
@@ -1,4 +1,3 @@
-import { useEffect, useRef } from 'react'
import {
Link,
Outlet,
@@ -6,27 +5,32 @@ import {
type LoaderFunctionArgs,
type MetaFunction,
useLoaderData,
+ useOutletContext,
} from 'react-router'
import {
ErrorMessage,
GeneralErrorBoundary,
} from '#app/components/error-boundary.js'
+import { SearchBar } from '#app/components/search-bar.tsx'
import { ButtonLink } from '#app/components/ui/button.tsx'
import { Icon } from '#app/components/ui/icon.js'
import { SimpleTooltip } from '#app/components/ui/tooltip.js'
import { requireUserId } from '#app/utils/auth.server.js'
-import { getHints } from '#app/utils/client-hints.js'
-import {
- CronParseError,
- formatSendTime,
- getSendTime,
-} from '#app/utils/cron.server.js'
import { prisma } from '#app/utils/db.server.js'
import { cn } from '#app/utils/misc.tsx'
+import {
+ type RecipientsOutletContext,
+ type ScheduleDisplay,
+} from './_layout.tsx'
+
+/**
+ * The thread fills the viewport and scrolls on its own, like a chat app. On
+ * phones the header and thread sit on the beige "hero" tint.
+ */
+export const handle = { pageTint: 'hero', layout: 'fill' } as const
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request)
- const hints = getHints(request)
const recipient = await prisma.recipient.findUnique({
where: { id: params.recipientId },
select: {
@@ -34,8 +38,6 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
userId: true,
name: true,
phoneNumber: true,
- scheduleCron: true,
- timeZone: true,
verified: true,
},
})
@@ -50,29 +52,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const { userId: _userId, ...recipientData } = recipient
- let formattedNextSendTime: string
- let cronError: string | null = null
- try {
- formattedNextSendTime = formatSendTime(
- getSendTime(recipient.scheduleCron, { tz: recipient.timeZone }, 0),
- hints.timeZone || recipient.timeZone,
- )
- } catch (error) {
- if (error instanceof CronParseError) {
- formattedNextSendTime = `Invalid cron: ${error.cronString}`
- cronError = error.message
- } else {
- formattedNextSendTime = 'Invalid schedule'
- cronError = error instanceof Error ? error.message : 'Unknown error'
- }
- }
-
- return json({
- optedOut: Boolean(optedOut),
- recipient: recipientData,
- formattedNextSendTime,
- cronError,
- })
+ return json({ optedOut: Boolean(optedOut), recipient: recipientData })
}
export const meta: MetaFunction
= ({ data }) => {
@@ -85,109 +65,174 @@ export const meta: MetaFunction = ({ data }) => {
export default function RecipientRoute() {
const data = useLoaderData()
- const firstLinkRef = useRef(null)
-
- useEffect(() => {
- firstLinkRef.current?.focus()
- }, [data.recipient.id])
+ const { recipients } = useOutletContext()
+ const scheduleDisplay = recipients.find(
+ (recipient) => recipient.id === data.recipient.id,
+ )?.scheduleDisplay
const hasBadges = data.optedOut || !data.recipient.verified
-
- return (
-