diff --git a/internal/api/handler/onboarding.go b/internal/api/handler/onboarding.go index 1e407a71..cea1fb31 100644 --- a/internal/api/handler/onboarding.go +++ b/internal/api/handler/onboarding.go @@ -13,6 +13,8 @@ type completeOnboardingRequest struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` ReferralSource string `json:"referral_source"` + Role string `json:"role"` + TeamSize string `json:"team_size"` } var validReferralSources = map[string]bool{ @@ -23,6 +25,25 @@ var validReferralSources = map[string]bool{ "other": true, } +// Persona + team-size answers from the onboarding questionnaire. Both optional: +// when provided they must be one of these, otherwise they're stored as NULL. +var validRoles = map[string]bool{ + "founder": true, + "sales": true, + "marketing": true, + "agency": true, + "recruiter": true, + "other": true, +} + +var validTeamSizes = map[string]bool{ + "just_me": true, + "2-10": true, + "11-50": true, + "51-200": true, + "200+": true, +} + func (h *Handler) CompleteOnboarding(c *gin.Context) { var req completeOnboardingRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -45,6 +66,16 @@ func (h *Handler) CompleteOnboarding(c *gin.Context) { return } + if req.Role != "" && !validRoles[req.Role] { + errx.Handle(c, errx.New(errx.BadRequest, "Invalid role.")) + return + } + + if req.TeamSize != "" && !validTeamSizes[req.TeamSize] { + errx.Handle(c, errx.New(errx.BadRequest, "Invalid team size.")) + return + } + userID := middleware.GetUserID(c) uid, err := uuid.Parse(userID) if err != nil { @@ -52,7 +83,7 @@ func (h *Handler) CompleteOnboarding(c *gin.Context) { return } - if xerr := h.UserService.CompleteOnboarding(c.Request.Context(), uid, req.FirstName, req.LastName, req.ReferralSource); xerr != nil { + if xerr := h.UserService.CompleteOnboarding(c.Request.Context(), uid, req.FirstName, req.LastName, req.ReferralSource, req.Role, req.TeamSize); xerr != nil { errx.Handle(c, xerr) return } diff --git a/internal/app/user/onboarding.go b/internal/app/user/onboarding.go index fd8961dc..4597487d 100644 --- a/internal/app/user/onboarding.go +++ b/internal/app/user/onboarding.go @@ -7,8 +7,8 @@ import ( "github.com/warmbly/warmbly/internal/errx" ) -func (s *userService) CompleteOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource string) *errx.Error { - if err := s.userRepository.UpdateOnboarding(ctx, userID, firstName, lastName, referralSource); err != nil { +func (s *userService) CompleteOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource, role, teamSize string) *errx.Error { + if err := s.userRepository.UpdateOnboarding(ctx, userID, firstName, lastName, referralSource, role, teamSize); err != nil { return errx.InternalError() } diff --git a/internal/app/user/service.go b/internal/app/user/service.go index a361868a..70404bc5 100644 --- a/internal/app/user/service.go +++ b/internal/app/user/service.go @@ -13,7 +13,7 @@ import ( type UserService interface { SaveUser(ctx context.Context, user *models.User) *errx.Error GetUser(ctx context.Context, userID uuid.UUID) (*models.User, *errx.Error) - CompleteOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource string) *errx.Error + CompleteOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource, role, teamSize string) *errx.Error } type userService struct { diff --git a/internal/infrastructure/db/migrations/000040_provisioning.down.sql b/internal/infrastructure/db/migrations/000040_provisioning.down.sql index 1814d315..76d9181e 100644 --- a/internal/infrastructure/db/migrations/000040_provisioning.down.sql +++ b/internal/infrastructure/db/migrations/000040_provisioning.down.sql @@ -2,5 +2,6 @@ DROP TABLE IF EXISTS decision_log; DROP TABLE IF EXISTS provisioning_jobs; DROP TABLE IF EXISTS provisioning_policy; DROP TABLE IF EXISTS provisioning_templates; -DROP TABLE IF EXISTS worker_profiles; +-- worker_profiles is owned by migration 000029, not this one — do NOT drop it +-- here or rolling back provisioning would destroy the worker-credentials table. DROP TABLE IF EXISTS cloud_credentials; diff --git a/internal/infrastructure/db/migrations/000040_provisioning.up.sql b/internal/infrastructure/db/migrations/000040_provisioning.up.sql index 3375ed19..7c6379c5 100644 --- a/internal/infrastructure/db/migrations/000040_provisioning.up.sql +++ b/internal/infrastructure/db/migrations/000040_provisioning.up.sql @@ -27,22 +27,17 @@ CREATE TABLE IF NOT EXISTS cloud_credentials ( ); -- --------------------------------------------------------------------------- --- Worker env profiles. env_template is the JSONB bundle that gets rendered --- into /etc/warmbly/worker.env during install. tier + egress_kind constrain --- which mailboxes the resulting workers can carry. +-- NOTE: worker_profiles is intentionally NOT created here. It is owned by +-- migration 000029 (worker_credentials), which models the Kafka/Redis/AWS +-- connection bundle that pg_credentials.go reads and writes. An earlier merge +-- accidentally re-declared a second, conflicting worker_profiles in this file +-- (env_template/tier/egress_kind); because it used CREATE TABLE IF NOT EXISTS +-- it silently no-op'd against 029's table, so those columns never existed and +-- nothing reads them. The provisioning surface only needs worker_profiles to +-- EXIST so the provisioning_templates.worker_profile_id FK below can reference +-- it; the tier/egress_kind the provisioner cares about live on +-- provisioning_templates itself. -- --------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS worker_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - description TEXT, - env_template JSONB NOT NULL DEFAULT '{}'::jsonb, - image_tag TEXT NOT NULL DEFAULT 'ghcr.io/warmbly/worker:latest', - tier TEXT NOT NULL CHECK (tier IN ('shared_free','shared_premium','dedicated')), - egress_kind TEXT NOT NULL DEFAULT 'cold_smtp' - CHECK (egress_kind IN ('cold_smtp','oauth_api','warmup_only')), - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -- --------------------------------------------------------------------------- -- Provisioning templates: saved configs for one-click provisioning. diff --git a/internal/infrastructure/db/migrations/000054_onboarding_questions.down.sql b/internal/infrastructure/db/migrations/000054_onboarding_questions.down.sql new file mode 100644 index 00000000..a92789d8 --- /dev/null +++ b/internal/infrastructure/db/migrations/000054_onboarding_questions.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users + DROP COLUMN IF EXISTS team_size, + DROP COLUMN IF EXISTS job_role; diff --git a/internal/infrastructure/db/migrations/000054_onboarding_questions.up.sql b/internal/infrastructure/db/migrations/000054_onboarding_questions.up.sql new file mode 100644 index 00000000..46af2cb1 --- /dev/null +++ b/internal/infrastructure/db/migrations/000054_onboarding_questions.up.sql @@ -0,0 +1,7 @@ +-- Multi-step onboarding questionnaire: the user's role/persona and team size, +-- captured after the profile + workspace-naming steps. Both nullable, so any +-- pre-existing user simply keeps NULL until they complete onboarding again. +-- (Named job_role to avoid colliding with the org-membership "role" concept.) +ALTER TABLE users + ADD COLUMN IF NOT EXISTS job_role VARCHAR(50), + ADD COLUMN IF NOT EXISTS team_size VARCHAR(20); diff --git a/internal/repository/pg_user.go b/internal/repository/pg_user.go index 380f0de7..0a30bfb7 100644 --- a/internal/repository/pg_user.go +++ b/internal/repository/pg_user.go @@ -21,7 +21,7 @@ type UserRepository interface { GetUser(ctx context.Context, id uuid.UUID) (*models.User, error) GetUserByEmail(ctx context.Context, email string) (*models.User, error) SetFreeTrialUsed(ctx context.Context, userID uuid.UUID) error - UpdateOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource string) error + UpdateOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource, role, teamSize string) error UpdateAvatar(ctx context.Context, userID uuid.UUID, avatarURL *string) error // GetBanState returns the user's ban_scope bitmask (0 = not @@ -150,9 +150,9 @@ func (r *userRepository) SetFreeTrialUsed(ctx context.Context, userID uuid.UUID) return err } -func (r *userRepository) UpdateOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource string) error { - const q = `UPDATE users SET first_name=$2, last_name=$3, referral_source=$4, onboarding_completed_at=NOW(), updated_at=NOW() WHERE id=$1` - _, err := r.DB.Exec(ctx, q, userID, firstName, lastName, referralSource) +func (r *userRepository) UpdateOnboarding(ctx context.Context, userID uuid.UUID, firstName, lastName, referralSource, role, teamSize string) error { + const q = `UPDATE users SET first_name=$2, last_name=$3, referral_source=$4, job_role=NULLIF($5,''), team_size=NULLIF($6,''), onboarding_completed_at=NOW(), updated_at=NOW() WHERE id=$1` + _, err := r.DB.Exec(ctx, q, userID, firstName, lastName, referralSource, role, teamSize) return err } diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 8d88788b..0ddb6756 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -6,7 +6,12 @@ import sitemap from '@astrojs/sitemap'; // https://astro.build/config export default defineConfig({ site: 'https://warmbly.com', - integrations: [sitemap()], + integrations: [ + sitemap({ + // The 404 page is noindex and should never appear in the sitemap. + filter: (page) => !page.includes('/404'), + }), + ], vite: { plugins: [tailwindcss()], }, diff --git a/site/public/og-image.png b/site/public/og-image.png index 8d840759..804af42a 100644 Binary files a/site/public/og-image.png and b/site/public/og-image.png differ diff --git a/site/scripts/gen-icons.mjs b/site/scripts/gen-icons.mjs index b13acc30..061cbb12 100644 --- a/site/scripts/gen-icons.mjs +++ b/site/scripts/gen-icons.mjs @@ -1,6 +1,6 @@ /** - * Generate every favicon / app-icon / social-card variant from the - * source social avatar (public/brand/social-avatar.jpg). + * Generate every favicon / app-icon variant from the source social avatar + * (public/brand/social-avatar.jpg). * * Outputs land in public/: * favicon-16x16.png, favicon-32x32.png, favicon-48x48.png, @@ -8,7 +8,9 @@ * apple-touch-icon.png (180 square, iOS rounds it) * web-app-manifest-192x192.png * web-app-manifest-512x512.png - * og-image.png (1200x630 OG / Twitter card) + * + * The 1200x630 OG / Twitter card (public/og-image.png) is generated + * separately from scripts/og-image.html — see scripts/render-og.py. * * Run with: node scripts/gen-icons.mjs */ @@ -55,52 +57,6 @@ async function rounded(size, outPath, radiusRatio = 0.22) { console.log('wrote', outPath, `(rounded r=${radiusRatio})`); } -async function ogCard(outPath) { - // 1200x630 sky-blue canvas with the avatar centered on the left half - // and room on the right for typography handled by the platform. - const W = 1200; - const H = 630; - const AVATAR = 460; - - const avatar = await sharp(source) - .resize(AVATAR, AVATAR, { fit: 'cover' }) - .png() - .toBuffer(); - - const avatarRounded = await sharp(avatar) - .composite([{ input: roundedMask(AVATAR, 0.18), blend: 'dest-in' }]) - .png() - .toBuffer(); - - // sky gradient background, matching the site's sky tokens - const bg = Buffer.from( - ` - - - - - - - - - ` - ); - - await sharp(bg) - .png() - .composite([ - { - input: avatarRounded, - top: Math.round((H - AVATAR) / 2), - left: 95, - }, - ]) - .png({ quality: 92 }) - .toFile(outPath); - - console.log('wrote', outPath); -} - await Promise.all([ // Tab favicons: rounded so the icon reads as a soft chip in the tab strip. rounded(16, resolve(PUB, 'favicon-16x16.png'), 0.22), @@ -115,9 +71,6 @@ await Promise.all([ // for adaptive (maskable) usage. square(192, resolve(PUB, 'web-app-manifest-192x192.png')), square(512, resolve(PUB, 'web-app-manifest-512x512.png')), - - // Social cards - ogCard(resolve(PUB, 'og-image.png')), ]); console.log('done.'); diff --git a/site/scripts/og-assets/inter-latin-500-normal.woff2 b/site/scripts/og-assets/inter-latin-500-normal.woff2 new file mode 100644 index 00000000..54f0a595 Binary files /dev/null and b/site/scripts/og-assets/inter-latin-500-normal.woff2 differ diff --git a/site/scripts/og-assets/inter-latin-600-normal.woff2 b/site/scripts/og-assets/inter-latin-600-normal.woff2 new file mode 100644 index 00000000..d1897949 Binary files /dev/null and b/site/scripts/og-assets/inter-latin-600-normal.woff2 differ diff --git a/site/scripts/og-assets/inter-latin-700-normal.woff2 b/site/scripts/og-assets/inter-latin-700-normal.woff2 new file mode 100644 index 00000000..a68fb101 Binary files /dev/null and b/site/scripts/og-assets/inter-latin-700-normal.woff2 differ diff --git a/site/scripts/og-assets/inter-latin-800-normal.woff2 b/site/scripts/og-assets/inter-latin-800-normal.woff2 new file mode 100644 index 00000000..74a16d45 Binary files /dev/null and b/site/scripts/og-assets/inter-latin-800-normal.woff2 differ diff --git a/site/scripts/og-image.html b/site/scripts/og-image.html new file mode 100644 index 00000000..76681b44 --- /dev/null +++ b/site/scripts/og-image.html @@ -0,0 +1,97 @@ + + + + + + + + +
+
+
+
+
+ + + + + + + + + +
+ + + + +
+ + Warmbly +
+
+ + diff --git a/site/scripts/render-og.py b/site/scripts/render-og.py new file mode 100644 index 00000000..beeaad43 --- /dev/null +++ b/site/scripts/render-og.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Render scripts/og-image.html to public/og-image.png at 1200x630. + +Run with the project's Playwright environment, e.g.: + /tmp/pwvenv/bin/python scripts/render-og.py +""" +import pathlib +from playwright.sync_api import sync_playwright + +HERE = pathlib.Path(__file__).resolve().parent +SRC = HERE / "og-image.html" +OUT = HERE.parent / "public" / "og-image.png" + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1200, "height": 630}, device_scale_factor=1) + page.goto(SRC.as_uri()) + page.wait_for_timeout(400) # let webfonts + cloud images settle + page.locator(".card").screenshot(path=str(OUT)) + browser.close() + +print("wrote", OUT) diff --git a/site/src/layouts/Layout.astro b/site/src/layouts/Layout.astro index 35f19cc6..aae87c12 100644 --- a/site/src/layouts/Layout.astro +++ b/site/src/layouts/Layout.astro @@ -7,6 +7,10 @@ interface Props { title: string; description?: string; ogImage?: string; + /** og:type — 'website' for most pages, 'article' for Learn essays */ + ogType?: string; + /** Extra structured data injected per-page (e.g. BlogPosting, SoftwareApplication) */ + jsonLd?: Record | Record[]; noIndex?: boolean; /** Toggle "marketing chrome" header/footer */ bare?: boolean; @@ -16,10 +20,14 @@ const { title, description = 'Warmbly is the deliverability platform for cold email. Warm your inboxes, send at scale, keep your reputation clean.', ogImage = '/og-image.png', + ogType = 'website', + jsonLd, noIndex = false, bare = false, } = Astro.props; +const extraJsonLd = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []; + const siteOrigin = (Astro.site ?? new URL('https://warmbly.com')).toString().replace(/\/$/, ''); const canonical = new URL(Astro.url.pathname, Astro.site ?? 'https://warmbly.com').toString(); const ogImageAbsolute = new URL(ogImage, Astro.site ?? 'https://warmbly.com').toString(); @@ -28,6 +36,7 @@ const ogImageAbsolute = new URL(ogImage, Astro.site ?? 'https://warmbly.com').to const organizationJsonLd = { '@context': 'https://schema.org', '@type': 'Organization', + '@id': `${siteOrigin}#org`, name: 'Warmbly', legalName: 'Mindroot Ltd', url: siteOrigin, @@ -81,7 +90,6 @@ const websiteJsonLd = { {title} - {noIndex && } - + + + - + @@ -122,7 +132,7 @@ const websiteJsonLd = { - + @@ -130,9 +140,12 @@ const websiteJsonLd = { - +