feat: gate premium CV themes
This commit is contained in:
@@ -297,4 +297,13 @@ public sealed class CvBuilderTests
|
||||
Assert.Null(await svc.GetPublicOwnerAsync(revokedSlug, default));
|
||||
Assert.Equal("user-1", await svc.GetPublicOwnerAsync(v.PublicSlug, default));
|
||||
}
|
||||
[Fact]
|
||||
public void Premium_theme_policy_keeps_three_free_themes_available()
|
||||
{
|
||||
Assert.Equal(3, CvThemeCatalog.Themes.Count(t => CvThemeCatalog.CanUse(t.Id, false)));
|
||||
Assert.Equal(5, CvThemeCatalog.Themes.Count(t => t.Premium));
|
||||
Assert.All(CvThemeCatalog.Themes, t => Assert.True(CvThemeCatalog.CanUse(t.Id, true)));
|
||||
Assert.False(CvThemeCatalog.CanUse("unknown", true));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -38,8 +38,11 @@ public sealed class CvVariantController : ControllerBase
|
||||
public sealed record AiAssistResult(string Original, string Result);
|
||||
|
||||
[HttpGet("themes")]
|
||||
public ActionResult<IEnumerable<object>> GetThemes()
|
||||
public async Task<ActionResult<IEnumerable<object>>> GetThemes()
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
var premiumThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).PremiumThemes;
|
||||
var themes = CvThemeCatalog.Themes.Select(t => new
|
||||
{
|
||||
id = t.Id,
|
||||
@@ -51,6 +54,8 @@ public sealed class CvVariantController : ControllerBase
|
||||
photoShape = t.PhotoShape,
|
||||
supportsIcons = t.DefaultIcons,
|
||||
atsFriendly = t.AtsFriendly,
|
||||
premium = t.Premium,
|
||||
available = premiumThemes || !t.Premium,
|
||||
swatches = new[] { t.Accent, t.SidebarBg, t.Paper },
|
||||
});
|
||||
return Ok(themes);
|
||||
@@ -81,6 +86,8 @@ public sealed class CvVariantController : ControllerBase
|
||||
if (user is null) return Unauthorized();
|
||||
if (request?.Settings is not null && !CvThemeCatalog.Exists(request.Settings.ThemeId))
|
||||
return BadRequest("Unknown theme.");
|
||||
if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Premium.");
|
||||
var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct);
|
||||
return Ok(ToDto(variant));
|
||||
}
|
||||
@@ -101,6 +108,13 @@ public sealed class CvVariantController : ControllerBase
|
||||
if (user is null) return Unauthorized();
|
||||
var settings = CvVariantSettingsJson.Normalize(request.Settings);
|
||||
if (!CvThemeCatalog.Exists(settings.ThemeId)) return BadRequest("Unknown theme.");
|
||||
if (!await CanUseThemeAsync(user, settings.ThemeId))
|
||||
{
|
||||
var current = await _variants.GetAsync(user.Id, id, ct);
|
||||
if (current is null) return NotFound();
|
||||
if (!string.Equals(CvVariantSettingsJson.Deserialize(current.SettingsJson).ThemeId, settings.ThemeId, StringComparison.OrdinalIgnoreCase))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Premium.");
|
||||
}
|
||||
var variant = await _variants.SaveAsync(user.Id, id, request.Name, settings, request.Source ?? "autosave", ct);
|
||||
return variant is null ? NotFound() : Ok(ToDto(variant));
|
||||
}
|
||||
@@ -164,6 +178,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
var settings = CvVariantSettingsJson.Normalize(request.Settings);
|
||||
if (!CvThemeCatalog.Exists(settings.ThemeId)) return BadRequest("Unknown theme.");
|
||||
var render = await _variants.RenderSettingsAsync(user.Id, settings, Person(user), ct);
|
||||
return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName));
|
||||
}
|
||||
@@ -222,6 +237,9 @@ public sealed class CvVariantController : ControllerBase
|
||||
return $"{task} Preserve every factual claim — never invent employers, titles, dates, or metrics. Write in {lang}. Return only the rewritten text with no preamble.{extra}";
|
||||
}
|
||||
|
||||
private async Task<bool> CanUseThemeAsync(ApplicationUser user, string? themeId) =>
|
||||
CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).PremiumThemes);
|
||||
|
||||
private static CvRenderPerson Person(ApplicationUser user)
|
||||
{
|
||||
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
+9
-5
@@ -50,6 +50,7 @@ public sealed class CvTheme
|
||||
// ATS-friendly = single-column, no sidebar tables/graphics that trip naive resume parsers. Set on
|
||||
// the single-column themes; surfaced in GET /api/cv/themes so the picker can badge it.
|
||||
public bool AtsFriendly { get; init; }
|
||||
public bool Premium { get; init; }
|
||||
|
||||
// Which section keys render in the sidebar for two-column layouts (ignored for single/header-band).
|
||||
public List<string> SidebarSections { get; init; } = new() { "contact", "skills", "languages" };
|
||||
@@ -79,7 +80,7 @@ public static class CvThemeCatalog
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "executive", Name = "Executive", Category = "Executive",
|
||||
Premium = true, Id = "executive", Name = "Executive", Category = "Executive",
|
||||
Description = "High-contrast, serif, leadership-weighted. For senior and client-facing roles.",
|
||||
Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline",
|
||||
Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917",
|
||||
@@ -88,7 +89,7 @@ public static class CvThemeCatalog
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "technical", Name = "Technical", Category = "Technical",
|
||||
Premium = true, Id = "technical", Name = "Technical", Category = "Technical",
|
||||
Description = "Dense two-column layout tuned for engineering CVs — skills and projects up front.",
|
||||
Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar",
|
||||
Accent = "#0f4c5c", Ink = "#102a43", Muted = "#486581", SidebarBg = "#0f4c5c", SidebarInk = "#ffffff",
|
||||
@@ -107,7 +108,7 @@ public static class CvThemeCatalog
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "nordic", Name = "Nordic", Category = "Modern Professional",
|
||||
Premium = true, Id = "nordic", Name = "Nordic", Category = "Modern Professional",
|
||||
Description = "Calm cool-blue sidebar, generous whitespace, Scandinavian restraint.",
|
||||
Layout = "sidebar-right", HeaderStyle = "plain", HeadingStyle = "caps-rule",
|
||||
Accent = "#3b6ea5", Ink = "#1f2937", Muted = "#4b5563", SidebarBg = "#eef3f8", SidebarInk = "#1f2937",
|
||||
@@ -116,7 +117,7 @@ public static class CvThemeCatalog
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "elegant", Name = "Elegant", Category = "Creative",
|
||||
Premium = true, Id = "elegant", Name = "Elegant", Category = "Creative",
|
||||
Description = "Editorial serif headings over sans body, premium spacing and a plum accent.",
|
||||
Layout = "single", HeaderStyle = "kicker", HeadingStyle = "underline",
|
||||
Accent = "#7c3aed", Ink = "#1f2937", Muted = "#4b5563",
|
||||
@@ -125,7 +126,7 @@ public static class CvThemeCatalog
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "creative", Name = "Creative", Category = "Creative",
|
||||
Premium = true, Id = "creative", Name = "Creative", Category = "Creative",
|
||||
Description = "Bold accent sidebar and photo-forward header for design and product roles.",
|
||||
Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar",
|
||||
Accent = "#db2777", Ink = "#18181b", Muted = "#52525b", SidebarBg = "#be185d", SidebarInk = "#ffffff",
|
||||
@@ -141,6 +142,9 @@ public static class CvThemeCatalog
|
||||
return Themes.FirstOrDefault(t => t.Id == key) ?? Themes[0];
|
||||
}
|
||||
|
||||
public static bool CanUse(string? id, bool premiumThemes) =>
|
||||
Exists(id) && (premiumThemes || !Resolve(id).Premium);
|
||||
|
||||
public static bool Exists(string? id)
|
||||
{
|
||||
var key = (id ?? string.Empty).Trim().ToLowerInvariant();
|
||||
|
||||
@@ -195,7 +195,7 @@ Goal: commercialise. Last, per the guide's "do not over-engineer before needed.
|
||||
| 7.4 | **DONE (2026-07-30)** — attachment uploads enforce total per-user storage entitlements (250 MB free, 5 GB Premium/Admin) in addition to the existing 10 MB per-file cap. | **P3** | **S** | 7.2 | Storage limits match the exposed capability model. |
|
||||
| 7.5 | **Stripe billing** | **P3** | **L** | 7.2 | Still blocked on **Stripe keys** — the only remaining hard blocker. Tiers are now decided. |
|
||||
| 7.6 | ✅ **DONE (2026-07-30)** — public CV (`/cv/{guid}`), privacy-first random links, revoke/rotate sharing | **P3** | **M** | 3.4, 4.2 | Anonymous rendering is isolated behind an explicit public flag, served with `noindex`, and revoked links cannot be restored accidentally. |
|
||||
| 7.7 | **Premium themes** | **P3** | **S** | 4.3, 7.2 | Trivial once themes are data. Impossible while they are C# methods. A decided premium lever. |
|
||||
| 7.7 | ✅ **DONE (2026-07-30)** — three free CV themes plus five Premium themes, enforced by account entitlement and clearly locked in the picker | **P3** | **S** | 4.3, 7.2 | Existing Premium-theme CVs remain editable and exportable after downgrade so user data is never held hostage. |
|
||||
| 7.8 | **DONE (2026-07-30)** — CI runs NuGet transitive vulnerability reporting and a production-only npm audit. The npm audit reports the existing no-fix advisory baseline without blocking unrelated deploys. | **P2** | **S** | none | Vulnerable dependencies are now visible before deployment. |
|
||||
| 7.9 | **Per-user AI provider cost controls** | **P3** | **S** | 5.2, 7.2 | With `AI_PROVIDER=gemini` the "advanced AI" tier spends real money per call. Metering (5.2) measures; this enforces. |
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ export type CvTheme = {
|
||||
photoShape: string;
|
||||
supportsIcons: boolean;
|
||||
atsFriendly: boolean;
|
||||
premium: boolean;
|
||||
available: boolean;
|
||||
swatches: string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -570,17 +570,21 @@ function CustomizeTab({ settings, update, themes }: {
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
{themes.map((t) => {
|
||||
const active = t.id === settings.themeId;
|
||||
const locked = t.available === false;
|
||||
return (
|
||||
<Paper key={t.id} variant="outlined" role="button" tabIndex={0}
|
||||
onClick={() => update({ themeId: t.id })}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); update({ themeId: t.id }); } }}
|
||||
sx={{ p: 1, cursor: "pointer", outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}>
|
||||
<Paper key={t.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked}
|
||||
onClick={() => { if (!locked) update({ themeId: t.id }); }}
|
||||
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: t.id }); } }}
|
||||
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mb: 0.5 }}>
|
||||
{t.swatches.map((s, i) => <Box key={i} sx={{ width: 14, height: 14, borderRadius: "3px", bgcolor: s, border: "1px solid rgba(0,0,0,0.1)" }} />)}
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t.category}</Typography>
|
||||
{t.atsFriendly && <Chip size="small" label="ATS-friendly" color="success" variant="outlined" sx={{ mt: 0.5, height: 18, fontSize: 10 }} />}
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
|
||||
{t.atsFriendly && <Chip size="small" label="ATS-friendly" color="success" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
||||
{t.premium && <Chip size="small" label={locked ? "Premium" : "Premium unlocked"} color="secondary" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user