feat(sponsor onboarding): add onboarding page, email template, form, schema, and server action to collect sponsor details and notify via email for approval process

This commit is contained in:
Mathias
2026-03-22 23:20:48 +01:00
parent 6fef23e991
commit 7691575c2f
12 changed files with 359 additions and 13 deletions
@@ -0,0 +1,33 @@
import { Metadata } from "next";
import { Sparkles } from "lucide-react";
import { getI18n } from "locales/server";
import { SponsorOnboardingForm } from "@/features/sponsor/onboarding/SponsorOnboardingForm";
export const metadata: Metadata = {
title: "Sponsor Onboarding | Workout Cool",
description: "Complete your sponsorship setup on Workout.cool",
robots: { index: false, follow: false },
};
export default async function SponsorOnboardingPage() {
const t = await getI18n();
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md space-y-6">
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-2">
<Sparkles className="w-6 h-6 text-[#4F8EF7]" />
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t("ads.onboarding_title")}</h1>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400">{t("ads.onboarding_description")}</p>
</div>
<div className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 p-6 shadow-sm">
<SponsorOnboardingForm />
</div>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import * as React from "react";
import { Body, Container, Head, Heading, Hr, Html, Link, Preview, Section, Text, Tailwind } from "@react-email/components";
interface SponsorOnboardingEmailProps {
brandName: string;
email: string;
websiteUrl: string;
logoUrl: string;
tagline?: string;
notes?: string;
}
const SponsorOnboardingEmail = ({ brandName, email, websiteUrl, logoUrl, tagline, notes }: SponsorOnboardingEmailProps) => (
<Html>
<Head />
<Preview>New Sponsor: {brandName}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-[40px] w-[465px] rounded border border-solid border-[#eaeaea] p-[20px]">
<Section className="mt-[32px]">
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-black">
New Sponsor Onboarding
</Heading>
<Text className="text-[14px] leading-[24px] text-black">
<strong>{brandName}</strong> just completed the sponsor onboarding form after payment.
</Text>
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
<Text className="text-[14px] leading-[24px] text-black">
<strong>Brand:</strong> {brandName}
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<strong>Email:</strong> {email}
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<strong>Website:</strong> <Link href={websiteUrl}>{websiteUrl}</Link>
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<strong>Logo:</strong> <Link href={logoUrl}>{logoUrl}</Link>
</Text>
{tagline && (
<Text className="text-[14px] leading-[24px] text-black">
<strong>Tagline:</strong> {tagline}
</Text>
)}
{notes && (
<>
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
<Text className="text-[14px] leading-[24px] text-black">
<strong>Additional notes:</strong>
</Text>
<Text className="whitespace-pre-wrap text-[14px] leading-[24px] text-black">{notes}</Text>
</>
)}
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
export default SponsorOnboardingEmail;
+14
View File
@@ -1974,5 +1974,19 @@ export default {
feature_targeted: "Targeted fitness audience (18-35)",
feature_premium_placement: "Premium sidebar & banner placement",
cta_book: "Book this spot",
onboarding_title: "Sponsor Onboarding",
onboarding_description: "Thanks for your payment! Fill in your brand details below and we'll get your ad live within 24h.",
onboarding_brand_name: "Brand Name",
onboarding_email: "Contact Email",
onboarding_website: "Website URL",
onboarding_logo: "Logo URL",
onboarding_logo_hint: "Direct link to your logo image (PNG or SVG, min 200x200px)",
onboarding_tagline: "Tagline",
onboarding_notes: "Additional notes",
onboarding_notes_placeholder: "Any specific requirements, preferred placement, campaign goals...",
onboarding_submit: "Submit my sponsorship",
onboarding_success_title: "You're all set!",
onboarding_success_description: "We received your brand details. Your ad will be live within 24 hours. We'll send you a confirmation email.",
optional: "optional",
},
} as const;
+14
View File
@@ -1976,5 +1976,19 @@ export default {
feature_targeted: "Audiencia fitness segmentada (18-35)",
feature_premium_placement: "Ubicación premium en sidebar y banners",
cta_book: "Reservar este espacio",
onboarding_title: "Onboarding de Sponsor",
onboarding_description: "¡Gracias por tu pago! Completa los datos de tu marca abajo y tu anuncio estará activo en 24h.",
onboarding_brand_name: "Nombre de la marca",
onboarding_email: "Email de contacto",
onboarding_website: "URL del sitio web",
onboarding_logo: "URL del logo",
onboarding_logo_hint: "Enlace directo a tu logo (PNG o SVG, mín 200x200px)",
onboarding_tagline: "Eslogan",
onboarding_notes: "Notas adicionales",
onboarding_notes_placeholder: "Requisitos específicos, ubicación preferida, objetivos de campaña...",
onboarding_submit: "Enviar mi patrocinio",
onboarding_success_title: "¡Todo listo!",
onboarding_success_description: "Recibimos los datos de tu marca. Tu anuncio estará activo en 24 horas. Te enviaremos un email de confirmación.",
optional: "opcional",
},
} as const;
+14
View File
@@ -2000,5 +2000,19 @@ export default {
feature_targeted: "Audience fitness ciblée (18-35 ans)",
feature_premium_placement: "Placement premium sidebar et bannière",
cta_book: "Réserver cet emplacement",
onboarding_title: "Onboarding Sponsor",
onboarding_description: "Merci pour votre paiement ! Remplissez les détails de votre marque ci-dessous et votre pub sera en ligne sous 24h.",
onboarding_brand_name: "Nom de la marque",
onboarding_email: "Email de contact",
onboarding_website: "URL du site web",
onboarding_logo: "URL du logo",
onboarding_logo_hint: "Lien direct vers votre logo (PNG ou SVG, min 200x200px)",
onboarding_tagline: "Slogan",
onboarding_notes: "Notes complémentaires",
onboarding_notes_placeholder: "Exigences particulières, placement préféré, objectifs de campagne...",
onboarding_submit: "Envoyer mon sponsoring",
onboarding_success_title: "C'est tout bon !",
onboarding_success_description: "Nous avons bien reçu vos informations. Votre pub sera en ligne sous 24 heures. Vous recevrez un email de confirmation.",
optional: "optionnel",
},
} as const;
+14
View File
@@ -1976,5 +1976,19 @@ export default {
feature_targeted: "Público fitness segmentado (18-35)",
feature_premium_placement: "Posicionamento premium em sidebar e banners",
cta_book: "Reservar este espaço",
onboarding_title: "Onboarding de Patrocinador",
onboarding_description: "Obrigado pelo pagamento! Preencha os dados da sua marca abaixo e seu anúncio estará ativo em 24h.",
onboarding_brand_name: "Nome da marca",
onboarding_email: "Email de contato",
onboarding_website: "URL do site",
onboarding_logo: "URL do logo",
onboarding_logo_hint: "Link direto para o seu logo (PNG ou SVG, mín 200x200px)",
onboarding_tagline: "Slogan",
onboarding_notes: "Notas adicionais",
onboarding_notes_placeholder: "Requisitos específicos, posicionamento preferido, objetivos da campanha...",
onboarding_submit: "Enviar meu patrocínio",
onboarding_success_title: "Tudo pronto!",
onboarding_success_description: "Recebemos os dados da sua marca. Seu anúncio estará ativo em 24 horas. Enviaremos um email de confirmação.",
optional: "opcional",
},
} as const;
+14
View File
@@ -1967,5 +1967,19 @@ export default {
feature_targeted: "Целевая фитнес-аудитория (18-35)",
feature_premium_placement: "Премиум-размещение в сайдбаре и баннерах",
cta_book: "Забронировать место",
onboarding_title: "Регистрация спонсора",
onboarding_description: "Спасибо за оплату! Заполните данные вашего бренда ниже, и ваша реклама будет запущена в течение 24 часов.",
onboarding_brand_name: "Название бренда",
onboarding_email: "Контактный email",
onboarding_website: "URL сайта",
onboarding_logo: "URL логотипа",
onboarding_logo_hint: "Прямая ссылка на ваш логотип (PNG или SVG, мин 200x200px)",
onboarding_tagline: "Слоган",
onboarding_notes: "Дополнительные заметки",
onboarding_notes_placeholder: "Особые требования, предпочтительное размещение, цели кампании...",
onboarding_submit: "Отправить данные спонсора",
onboarding_success_title: "Всё готово!",
onboarding_success_description: "Мы получили данные вашего бренда. Ваша реклама будет запущена в течение 24 часов. Мы отправим вам подтверждение по email.",
optional: "необязательно",
},
} as const;
+14
View File
@@ -1912,5 +1912,19 @@ export default {
feature_targeted: "精准健身受众 (18-35岁)",
feature_premium_placement: "侧边栏和横幅的高级位置",
cta_book: "预订此广告位",
onboarding_title: "赞助商注册",
onboarding_description: "感谢您的付款!请在下方填写您的品牌信息,我们将在24小时内上线您的广告。",
onboarding_brand_name: "品牌名称",
onboarding_email: "联系邮箱",
onboarding_website: "网站链接",
onboarding_logo: "Logo链接",
onboarding_logo_hint: "Logo图片直链(PNG或SVG,最小200x200px",
onboarding_tagline: "标语",
onboarding_notes: "补充说明",
onboarding_notes_placeholder: "特殊要求、首选位置、活动目标...",
onboarding_submit: "提交赞助信息",
onboarding_success_title: "一切就绪!",
onboarding_success_description: "我们已收到您的品牌信息。您的广告将在24小时内上线。我们会发送确认邮件给您。",
optional: "可选",
},
} as const;
@@ -1,7 +1,6 @@
"use client";
import { Drawer } from "vaul";
import { ReactNode } from "react";
import { ExternalLink, Globe, MapPin, Monitor, PieChart, Smartphone, Sparkles, Target, TrendingUp, X } from "lucide-react";
import { useI18n } from "locales/client";
@@ -14,18 +13,6 @@ interface SponsorDialogProps {
onOpenChange: (open: boolean) => void;
}
function StatCard({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 p-3">
<div className="flex items-center gap-1.5 mb-1">
{icon}
<span className="text-[10px] uppercase tracking-wide text-slate-500 dark:text-slate-400">{label}</span>
</div>
<p className="font-semibold text-sm text-slate-800 dark:text-slate-200">{value}</p>
</div>
);
}
export function SponsorDialog({ open, onOpenChange }: SponsorDialogProps) {
const t = useI18n();
const stripeUrl = env.NEXT_PUBLIC_STRIPE_AD_SPOT_URL ?? "#";
@@ -0,0 +1,149 @@
"use client";
import { useAction } from "next-safe-action/hooks";
import { CheckCircle, ExternalLink, Loader2 } from "lucide-react";
import { useI18n } from "locales/client";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { SponsorOnboardingSchema, type SponsorOnboardingSchemaType } from "./sponsor-onboarding.schema";
import { sponsorOnboardingAction } from "./sponsor-onboarding.action";
export function SponsorOnboardingForm() {
const t = useI18n();
const form = useZodForm({
schema: SponsorOnboardingSchema,
defaultValues: {
brandName: "",
email: "",
websiteUrl: "",
logoUrl: "",
tagline: "",
notes: "",
},
});
const { execute, status } = useAction(sponsorOnboardingAction);
const onSubmit = (data: SponsorOnboardingSchemaType) => {
execute(data);
};
if (status === "hasSucceeded") {
return (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<CheckCircle className="w-16 h-16 text-emerald-500" />
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">{t("ads.onboarding_success_title")}</h2>
<p className="text-slate-500 dark:text-slate-400 max-w-md">{t("ads.onboarding_success_description")}</p>
</div>
);
}
return (
<Form className="space-y-5" form={form} onSubmit={onSubmit}>
<FormField
control={form.control}
name="brandName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("ads.onboarding_brand_name")}</FormLabel>
<FormControl>
<Input placeholder="Acme Inc." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("ads.onboarding_email")}</FormLabel>
<FormControl>
<Input placeholder="sponsor@example.com" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="websiteUrl"
render={({ field }) => (
<FormItem>
<FormLabel>{t("ads.onboarding_website")}</FormLabel>
<FormControl>
<Input placeholder="https://example.com" type="url" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="logoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>{t("ads.onboarding_logo")}</FormLabel>
<FormControl>
<Input placeholder="https://example.com/logo.png" type="url" {...field} />
</FormControl>
<p className="text-xs text-slate-500 dark:text-slate-400">{t("ads.onboarding_logo_hint")}</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tagline"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("ads.onboarding_tagline")} <span className="text-slate-400 font-normal">({t("ads.optional")})</span>
</FormLabel>
<FormControl>
<Input placeholder="Your catchy tagline here" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("ads.onboarding_notes")} <span className="text-slate-400 font-normal">({t("ads.optional")})</span>
</FormLabel>
<FormControl>
<Textarea placeholder={t("ads.onboarding_notes_placeholder")} rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
className="flex items-center justify-center gap-2 w-full bg-gradient-to-r from-[#4F8EF7] to-[#25CB78] text-white font-semibold py-3 rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
disabled={status === "executing"}
type="submit"
>
{status === "executing" ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<ExternalLink className="w-4 h-4" />
)}
{t("ads.onboarding_submit")}
</button>
</Form>
);
}
@@ -0,0 +1,20 @@
"use server";
import SponsorOnboardingEmail from "@emails/SponsorOnboardingEmail";
import { sendEmail } from "@/shared/lib/mail/sendEmail";
import { SiteConfig } from "@/shared/config/site-config";
import { actionClient } from "@/shared/api/safe-actions";
import { SponsorOnboardingSchema } from "./sponsor-onboarding.schema";
export const sponsorOnboardingAction = actionClient.schema(SponsorOnboardingSchema).action(async ({ parsedInput }) => {
await sendEmail({
from: SiteConfig.email.from,
to: SiteConfig.email.contact,
subject: `New Sponsor: ${parsedInput.brandName}`,
text: `New sponsor onboarding: ${parsedInput.brandName} (${parsedInput.email}) - ${parsedInput.websiteUrl}`,
react: SponsorOnboardingEmail(parsedInput),
});
return { message: "Your sponsorship details have been submitted successfully." };
});
@@ -0,0 +1,12 @@
import { z } from "zod";
export const SponsorOnboardingSchema = z.object({
brandName: z.string().min(1, "Brand name is required"),
email: z.string().email("Invalid email address"),
websiteUrl: z.string().url("Invalid URL"),
logoUrl: z.string().url("Invalid logo URL"),
tagline: z.string().optional(),
notes: z.string().optional(),
});
export type SponsorOnboardingSchemaType = z.infer<typeof SponsorOnboardingSchema>;