feat(pages): add A/B testing framework for landing pages
Implement comprehensive A/B testing system for landing page optimization: - Database models for experiments, variants, and events - AI-powered variant generation and analysis - Visitor tracking with conversion metrics - Experiment lifecycle management (draft/active/paused/completed) - Email notifications for experiment results - Cron job for automated experiment monitoring - UI for viewing experiment results and statistics
This commit is contained in:
@@ -440,6 +440,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(363, "Add keep_packages_private to user", v1_26.AddKeepPackagesPrivateToUser),
|
||||
newMigration(364, "Add view_count to blog_post", v1_26.AddViewCountToBlogPost),
|
||||
newMigration(365, "Add public_app_integration to repository", v1_26.AddPublicAppIntegrationToRepository),
|
||||
newMigration(366, "Add page experiment tables for A/B testing", v1_26.AddPageExperimentTables),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
54
models/migrations/v1_26/v366.go
Normal file
54
models/migrations/v1_26/v366.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"code.gitcaddy.com/server/v3/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddPageExperimentTables(x *xorm.Engine) error {
|
||||
type PageExperiment struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(255) NOT NULL"`
|
||||
Status string `xorm:"VARCHAR(32) NOT NULL DEFAULT 'draft'"`
|
||||
CreatedByAI bool `xorm:"NOT NULL DEFAULT true"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
EndsUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
|
||||
}
|
||||
|
||||
type PageVariant struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
ExperimentID int64 `xorm:"INDEX NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(255) NOT NULL"`
|
||||
IsControl bool `xorm:"NOT NULL DEFAULT false"`
|
||||
Weight int `xorm:"NOT NULL DEFAULT 50"`
|
||||
ConfigOverride string `xorm:"TEXT"`
|
||||
Impressions int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
Conversions int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
}
|
||||
|
||||
type PageEvent struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||
VariantID int64 `xorm:"INDEX DEFAULT 0"`
|
||||
ExperimentID int64 `xorm:"INDEX DEFAULT 0"`
|
||||
VisitorID string `xorm:"VARCHAR(64) INDEX"`
|
||||
EventType string `xorm:"VARCHAR(32) NOT NULL"`
|
||||
EventData string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
}
|
||||
|
||||
if err := x.Sync(new(PageExperiment)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := x.Sync(new(PageVariant)); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.Sync(new(PageEvent))
|
||||
}
|
||||
272
models/pages/experiment.go
Normal file
272
models/pages/experiment.go
Normal file
@@ -0,0 +1,272 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
"code.gitcaddy.com/server/v3/modules/timeutil"
|
||||
)
|
||||
|
||||
// ExperimentStatus represents the status of an A/B test experiment.
|
||||
type ExperimentStatus string
|
||||
|
||||
const (
|
||||
ExperimentStatusDraft ExperimentStatus = "draft"
|
||||
ExperimentStatusActive ExperimentStatus = "active"
|
||||
ExperimentStatusPaused ExperimentStatus = "paused"
|
||||
ExperimentStatusCompleted ExperimentStatus = "completed"
|
||||
ExperimentStatusApproved ExperimentStatus = "approved"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(PageExperiment))
|
||||
db.RegisterModel(new(PageVariant))
|
||||
db.RegisterModel(new(PageEvent))
|
||||
}
|
||||
|
||||
// PageExperiment tracks an A/B test on a landing page.
|
||||
type PageExperiment struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(255) NOT NULL"`
|
||||
Status ExperimentStatus `xorm:"VARCHAR(32) NOT NULL DEFAULT 'draft'"`
|
||||
CreatedByAI bool `xorm:"NOT NULL DEFAULT true"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
EndsUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
|
||||
|
||||
Variants []*PageVariant `xorm:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for PageExperiment.
|
||||
func (e *PageExperiment) TableName() string {
|
||||
return "page_experiment"
|
||||
}
|
||||
|
||||
// PageVariant is one arm of an A/B test experiment.
|
||||
type PageVariant struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
ExperimentID int64 `xorm:"INDEX NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(255) NOT NULL"`
|
||||
IsControl bool `xorm:"NOT NULL DEFAULT false"`
|
||||
Weight int `xorm:"NOT NULL DEFAULT 50"`
|
||||
ConfigOverride string `xorm:"TEXT"`
|
||||
Impressions int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
Conversions int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for PageVariant.
|
||||
func (v *PageVariant) TableName() string {
|
||||
return "page_variant"
|
||||
}
|
||||
|
||||
// ConversionRate returns the conversion rate for this variant.
|
||||
func (v *PageVariant) ConversionRate() float64 {
|
||||
if v.Impressions == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(v.Conversions) / float64(v.Impressions)
|
||||
}
|
||||
|
||||
// PageEvent tracks visitor interactions with a landing page.
|
||||
type PageEvent struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||
VariantID int64 `xorm:"INDEX DEFAULT 0"`
|
||||
ExperimentID int64 `xorm:"INDEX DEFAULT 0"`
|
||||
VisitorID string `xorm:"VARCHAR(64) INDEX"`
|
||||
EventType string `xorm:"VARCHAR(32) NOT NULL"`
|
||||
EventData string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for PageEvent.
|
||||
func (e *PageEvent) TableName() string {
|
||||
return "page_event"
|
||||
}
|
||||
|
||||
// Valid event types
|
||||
const (
|
||||
EventTypeImpression = "impression"
|
||||
EventTypeCTAClick = "cta_click"
|
||||
EventTypeScrollDepth = "scroll_depth"
|
||||
EventTypeClick = "click"
|
||||
EventTypeStar = "star"
|
||||
EventTypeFork = "fork"
|
||||
EventTypeClone = "clone"
|
||||
)
|
||||
|
||||
// CreateExperiment creates a new experiment.
|
||||
func CreateExperiment(ctx context.Context, exp *PageExperiment) error {
|
||||
_, err := db.GetEngine(ctx).Insert(exp)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetExperimentByID returns an experiment by ID.
|
||||
func GetExperimentByID(ctx context.Context, id int64) (*PageExperiment, error) {
|
||||
exp := new(PageExperiment)
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(exp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil
|
||||
}
|
||||
return exp, nil
|
||||
}
|
||||
|
||||
// GetExperimentsByRepoID returns all experiments for a repository.
|
||||
func GetExperimentsByRepoID(ctx context.Context, repoID int64) ([]*PageExperiment, error) {
|
||||
experiments := make([]*PageExperiment, 0, 10)
|
||||
return experiments, db.GetEngine(ctx).Where("repo_id = ?", repoID).
|
||||
Desc("created_unix").Find(&experiments)
|
||||
}
|
||||
|
||||
// GetActiveExperimentByRepoID returns the currently active experiment for a repo, if any.
|
||||
func GetActiveExperimentByRepoID(ctx context.Context, repoID int64) (*PageExperiment, error) {
|
||||
exp := new(PageExperiment)
|
||||
has, err := db.GetEngine(ctx).
|
||||
Where("repo_id = ? AND status = ?", repoID, ExperimentStatusActive).
|
||||
Get(exp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil
|
||||
}
|
||||
return exp, nil
|
||||
}
|
||||
|
||||
// GetAllActiveExperiments returns all active experiments across all repos.
|
||||
func GetAllActiveExperiments(ctx context.Context) ([]*PageExperiment, error) {
|
||||
experiments := make([]*PageExperiment, 0, 50)
|
||||
return experiments, db.GetEngine(ctx).
|
||||
Where("status = ?", ExperimentStatusActive).
|
||||
Find(&experiments)
|
||||
}
|
||||
|
||||
// UpdateExperiment updates an experiment.
|
||||
func UpdateExperiment(ctx context.Context, exp *PageExperiment) error {
|
||||
_, err := db.GetEngine(ctx).ID(exp.ID).AllCols().Update(exp)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateExperimentStatus updates just the status of an experiment.
|
||||
func UpdateExperimentStatus(ctx context.Context, id int64, status ExperimentStatus) error {
|
||||
_, err := db.GetEngine(ctx).ID(id).Cols("status").
|
||||
Update(&PageExperiment{Status: status})
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateVariant creates a new variant for an experiment.
|
||||
func CreateVariant(ctx context.Context, variant *PageVariant) error {
|
||||
_, err := db.GetEngine(ctx).Insert(variant)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetVariantByID returns a variant by ID.
|
||||
func GetVariantByID(ctx context.Context, id int64) (*PageVariant, error) {
|
||||
variant := new(PageVariant)
|
||||
has, err := db.GetEngine(ctx).ID(id).Get(variant)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil
|
||||
}
|
||||
return variant, nil
|
||||
}
|
||||
|
||||
// GetVariantsByExperimentID returns all variants for an experiment.
|
||||
func GetVariantsByExperimentID(ctx context.Context, experimentID int64) ([]*PageVariant, error) {
|
||||
variants := make([]*PageVariant, 0, 5)
|
||||
return variants, db.GetEngine(ctx).
|
||||
Where("experiment_id = ?", experimentID).
|
||||
Find(&variants)
|
||||
}
|
||||
|
||||
// IncrementVariantImpressions increments the impression counter for a variant.
|
||||
func IncrementVariantImpressions(ctx context.Context, variantID int64) error {
|
||||
_, err := db.GetEngine(ctx).Exec(
|
||||
"UPDATE `page_variant` SET impressions = impressions + 1 WHERE id = ?", variantID)
|
||||
return err
|
||||
}
|
||||
|
||||
// IncrementVariantConversions increments the conversion counter for a variant.
|
||||
func IncrementVariantConversions(ctx context.Context, variantID int64) error {
|
||||
_, err := db.GetEngine(ctx).Exec(
|
||||
"UPDATE `page_variant` SET conversions = conversions + 1 WHERE id = ?", variantID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreatePageEvent records a visitor event.
|
||||
func CreatePageEvent(ctx context.Context, event *PageEvent) error {
|
||||
_, err := db.GetEngine(ctx).Insert(event)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetEventCountByVariant returns event counts grouped by event type for a variant.
|
||||
func GetEventCountByVariant(ctx context.Context, variantID int64) (map[string]int64, error) {
|
||||
type countResult struct {
|
||||
EventType string `xorm:"event_type"`
|
||||
Count int64 `xorm:"cnt"`
|
||||
}
|
||||
results := make([]countResult, 0)
|
||||
err := db.GetEngine(ctx).Table("page_event").
|
||||
Select("event_type, COUNT(*) as cnt").
|
||||
Where("variant_id = ?", variantID).
|
||||
GroupBy("event_type").
|
||||
Find(&results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[string]int64, len(results))
|
||||
for _, r := range results {
|
||||
counts[r.EventType] = r.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// GetEventCountsByExperiment returns event counts for all variants in an experiment.
|
||||
func GetEventCountsByExperiment(ctx context.Context, experimentID int64) (map[int64]map[string]int64, error) {
|
||||
type countResult struct {
|
||||
VariantID int64 `xorm:"variant_id"`
|
||||
EventType string `xorm:"event_type"`
|
||||
Count int64 `xorm:"cnt"`
|
||||
}
|
||||
results := make([]countResult, 0)
|
||||
err := db.GetEngine(ctx).Table("page_event").
|
||||
Select("variant_id, event_type, COUNT(*) as cnt").
|
||||
Where("experiment_id = ?", experimentID).
|
||||
GroupBy("variant_id, event_type").
|
||||
Find(&results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[int64]map[string]int64)
|
||||
for _, r := range results {
|
||||
if counts[r.VariantID] == nil {
|
||||
counts[r.VariantID] = make(map[string]int64)
|
||||
}
|
||||
counts[r.VariantID][r.EventType] = r.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// RecordRepoAction records a repo action (star, fork, clone) as a page event
|
||||
// if the repo has an active experiment.
|
||||
func RecordRepoAction(ctx context.Context, repoID int64, eventType string) {
|
||||
exp, err := GetActiveExperimentByRepoID(ctx, repoID)
|
||||
if err != nil || exp == nil {
|
||||
return
|
||||
}
|
||||
_ = CreatePageEvent(ctx, &PageEvent{
|
||||
RepoID: repoID,
|
||||
ExperimentID: exp.ID,
|
||||
EventType: eventType,
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
pages_model "code.gitcaddy.com/server/v3/models/pages"
|
||||
user_model "code.gitcaddy.com/server/v3/models/user"
|
||||
"code.gitcaddy.com/server/v3/modules/timeutil"
|
||||
)
|
||||
@@ -25,7 +26,7 @@ func init() {
|
||||
|
||||
// StarRepo or unstar repository.
|
||||
func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star bool) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
staring := IsStaring(ctx, doer.ID, repo.ID)
|
||||
|
||||
if star {
|
||||
@@ -64,6 +65,10 @@ func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star
|
||||
|
||||
return nil
|
||||
})
|
||||
if err == nil && star {
|
||||
pages_model.RecordRepoAction(ctx, repo.ID, pages_model.EventTypeStar)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// IsStaring checks if user has starred given repository.
|
||||
|
||||
@@ -213,6 +213,20 @@ func (c *Client) InspectWorkflow(ctx context.Context, req *InspectWorkflowReques
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ExecuteTask executes a generic AI task via the sidecar
|
||||
func (c *Client) ExecuteTask(ctx context.Context, req *ExecuteTaskRequest) (*ExecuteTaskResponse, error) {
|
||||
if !IsEnabled() {
|
||||
return nil, errors.New("AI service is not enabled")
|
||||
}
|
||||
|
||||
var resp ExecuteTaskResponse
|
||||
if err := c.doRequest(ctx, http.MethodPost, "/execute-task", req, &resp); err != nil {
|
||||
log.Error("AI ExecuteTask failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CheckHealth checks the health of the AI service
|
||||
func (c *Client) CheckHealth(ctx context.Context) (*HealthCheckResponse, error) {
|
||||
var resp HealthCheckResponse
|
||||
|
||||
@@ -258,6 +258,22 @@ type InspectWorkflowResponse struct {
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// ExecuteTaskRequest is the request for executing a generic AI task
|
||||
type ExecuteTaskRequest struct {
|
||||
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Task string `json:"task"`
|
||||
Context map[string]string `json:"context"`
|
||||
AllowedTools []string `json:"allowed_tools,omitempty"`
|
||||
}
|
||||
|
||||
// ExecuteTaskResponse is the response from executing a generic AI task
|
||||
type ExecuteTaskResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Result string `json:"result"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HealthCheckResponse is the response from a health check
|
||||
type HealthCheckResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
|
||||
@@ -47,6 +47,9 @@ type LandingConfig struct {
|
||||
// Blog section
|
||||
Blog BlogSectionConfig `yaml:"blog,omitempty"`
|
||||
|
||||
// Navigation visibility
|
||||
Navigation NavigationConfig `yaml:"navigation,omitempty"`
|
||||
|
||||
// Footer
|
||||
Footer FooterConfig `yaml:"footer,omitempty"`
|
||||
|
||||
@@ -61,6 +64,9 @@ type LandingConfig struct {
|
||||
|
||||
// Advanced settings
|
||||
Advanced AdvancedConfig `yaml:"advanced,omitempty"`
|
||||
|
||||
// A/B testing experiments
|
||||
Experiments ExperimentConfig `yaml:"experiments,omitempty"`
|
||||
}
|
||||
|
||||
// BrandConfig represents brand/identity settings
|
||||
@@ -69,6 +75,7 @@ type BrandConfig struct {
|
||||
LogoURL string `yaml:"logo_url,omitempty"`
|
||||
LogoSource string `yaml:"logo_source,omitempty"` // "url" (default), "repo", or "org" — selects avatar source
|
||||
Tagline string `yaml:"tagline,omitempty"`
|
||||
FaviconURL string `yaml:"favicon_url,omitempty"`
|
||||
}
|
||||
|
||||
// HeroConfig represents hero section settings
|
||||
@@ -159,6 +166,15 @@ type BlogSectionConfig struct {
|
||||
CTAButton CTAButton `yaml:"cta_button,omitempty"` // "View All Posts" link
|
||||
}
|
||||
|
||||
// NavigationConfig controls which built-in navigation links appear in the header and footer
|
||||
type NavigationConfig struct {
|
||||
ShowDocs bool `yaml:"show_docs,omitempty"`
|
||||
ShowAPI bool `yaml:"show_api,omitempty"`
|
||||
ShowRepository bool `yaml:"show_repository,omitempty"`
|
||||
ShowReleases bool `yaml:"show_releases,omitempty"`
|
||||
ShowIssues bool `yaml:"show_issues,omitempty"`
|
||||
}
|
||||
|
||||
// FooterConfig represents footer settings
|
||||
type FooterConfig struct {
|
||||
Links []FooterLink `yaml:"links,omitempty"`
|
||||
@@ -209,6 +225,14 @@ type UmamiConfig struct {
|
||||
URL string `yaml:"url,omitempty"`
|
||||
}
|
||||
|
||||
// ExperimentConfig represents A/B testing experiment settings
|
||||
type ExperimentConfig struct {
|
||||
Enabled bool `yaml:"enabled,omitempty"`
|
||||
AutoOptimize bool `yaml:"auto_optimize,omitempty"`
|
||||
MinImpressions int `yaml:"min_impressions,omitempty"`
|
||||
ApprovalRequired bool `yaml:"approval_required,omitempty"`
|
||||
}
|
||||
|
||||
// AdvancedConfig represents advanced settings
|
||||
type AdvancedConfig struct {
|
||||
CustomCSS string `yaml:"custom_css,omitempty"`
|
||||
@@ -272,6 +296,11 @@ func DefaultConfig() *LandingConfig {
|
||||
{Title: "Flexible", Description: "Adapts to your workflow, not the other way around.", Icon: "gear"},
|
||||
{Title: "Open Source", Description: "Free forever. Community driven.", Icon: "heart"},
|
||||
},
|
||||
Navigation: NavigationConfig{
|
||||
ShowDocs: true,
|
||||
ShowRepository: true,
|
||||
ShowReleases: true,
|
||||
},
|
||||
CTASection: CTASectionConfig{
|
||||
Headline: "Ready to get started?",
|
||||
Subheadline: "Join thousands of developers already using this project.",
|
||||
|
||||
@@ -4493,6 +4493,15 @@
|
||||
"repo.settings.pages.seo_description": "Meta Description",
|
||||
"repo.settings.pages.seo_keywords": "Keywords",
|
||||
"repo.settings.pages.og_image": "Open Graph Image URL",
|
||||
"repo.settings.pages.brand_favicon_url": "Favicon URL",
|
||||
"repo.settings.pages.brand_favicon_url_help": "URL to a custom favicon for your landing page (ICO, PNG, or SVG). Leave blank to use the default.",
|
||||
"repo.settings.pages.navigation": "Navigation Links",
|
||||
"repo.settings.pages.navigation_desc": "Control which built-in links appear in the header and footer navigation.",
|
||||
"repo.settings.pages.nav_show_docs": "Show Docs link (links to wiki)",
|
||||
"repo.settings.pages.nav_show_api": "Show API link (links to Swagger docs)",
|
||||
"repo.settings.pages.nav_show_repository": "Show Repository link (View Source button)",
|
||||
"repo.settings.pages.nav_show_releases": "Show Releases link",
|
||||
"repo.settings.pages.nav_show_issues": "Show Issues link",
|
||||
"repo.vault": "Vault",
|
||||
"repo.vault.secrets": "Secrets",
|
||||
"repo.vault.new_secret": "New Secret",
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
@@ -14,9 +18,11 @@ import (
|
||||
"time"
|
||||
|
||||
blog_model "code.gitcaddy.com/server/v3/models/blog"
|
||||
pages_model "code.gitcaddy.com/server/v3/models/pages"
|
||||
"code.gitcaddy.com/server/v3/models/renderhelper"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
"code.gitcaddy.com/server/v3/modules/git"
|
||||
"code.gitcaddy.com/server/v3/modules/json"
|
||||
"code.gitcaddy.com/server/v3/modules/log"
|
||||
"code.gitcaddy.com/server/v3/modules/markup/markdown"
|
||||
pages_module "code.gitcaddy.com/server/v3/modules/pages"
|
||||
@@ -56,6 +62,12 @@ func ServeLandingPage(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle event tracking POST
|
||||
if ctx.Req.Method == http.MethodPost && (requestPath == "/pages/events" || requestPath == "/pages/events/") {
|
||||
servePageEvent(ctx, repo)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for blog paths on custom domain
|
||||
if config.Blog.Enabled && repo.BlogEnabled {
|
||||
if idStr, found := strings.CutPrefix(requestPath, "/blog/"); found {
|
||||
@@ -71,8 +83,9 @@ func ServeLandingPage(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Render the landing page
|
||||
// Render the landing page with A/B test variant
|
||||
ctx.Data["BlogBaseURL"] = "/blog"
|
||||
config = assignVariant(ctx, repo, config)
|
||||
renderLandingPage(ctx, repo, config)
|
||||
}
|
||||
|
||||
@@ -473,6 +486,7 @@ func ServeRepoLandingPage(ctx *context.Context) {
|
||||
}
|
||||
|
||||
ctx.Data["BlogBaseURL"] = fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
|
||||
config = assignVariant(ctx, repo, config)
|
||||
renderLandingPage(ctx, repo, config)
|
||||
}
|
||||
|
||||
@@ -599,6 +613,277 @@ func ServeRepoPageAsset(ctx *context.Context) {
|
||||
_, _ = ctx.Resp.Write(content)
|
||||
}
|
||||
|
||||
// servePageEvent handles POST /pages/events for A/B test event tracking
|
||||
func servePageEvent(ctx *context.Context, repo *repo_model.Repository) {
|
||||
body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, 4096))
|
||||
if err != nil {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
EventType string `json:"event_type"`
|
||||
VariantID int64 `json:"variant_id"`
|
||||
ExperimentID int64 `json:"experiment_id"`
|
||||
VisitorID string `json:"visitor_id"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate event type
|
||||
validTypes := map[string]bool{
|
||||
pages_model.EventTypeImpression: true,
|
||||
pages_model.EventTypeCTAClick: true,
|
||||
pages_model.EventTypeScrollDepth: true,
|
||||
pages_model.EventTypeClick: true,
|
||||
}
|
||||
if !validTypes[payload.EventType] {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Record event
|
||||
_ = pages_model.CreatePageEvent(ctx, &pages_model.PageEvent{
|
||||
RepoID: repo.ID,
|
||||
VariantID: payload.VariantID,
|
||||
ExperimentID: payload.ExperimentID,
|
||||
VisitorID: payload.VisitorID,
|
||||
EventType: payload.EventType,
|
||||
EventData: payload.Data,
|
||||
})
|
||||
|
||||
// Update denormalized counters
|
||||
if payload.VariantID > 0 {
|
||||
switch payload.EventType {
|
||||
case pages_model.EventTypeImpression:
|
||||
_ = pages_model.IncrementVariantImpressions(ctx, payload.VariantID)
|
||||
case pages_model.EventTypeCTAClick:
|
||||
_ = pages_model.IncrementVariantConversions(ctx, payload.VariantID)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ServePageEvent handles POST event tracking via path-based routes
|
||||
func ServePageEvent(ctx *context.Context) {
|
||||
repo := ctx.Repo.Repository
|
||||
if repo == nil {
|
||||
ctx.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
servePageEvent(ctx, repo)
|
||||
}
|
||||
|
||||
// assignVariant checks for an active experiment, assigns the visitor to a variant,
|
||||
// and deep-merges the variant's config overrides onto the base config.
|
||||
func assignVariant(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) *pages_module.LandingConfig {
|
||||
if !config.Experiments.Enabled {
|
||||
return config
|
||||
}
|
||||
|
||||
exp, err := pages_model.GetActiveExperimentByRepoID(ctx, repo.ID)
|
||||
if err != nil || exp == nil {
|
||||
return config
|
||||
}
|
||||
|
||||
// Ensure visitor ID cookie
|
||||
visitorID := ctx.GetSiteCookie("pgvid")
|
||||
if visitorID == "" {
|
||||
visitorID = generateVisitorID()
|
||||
ctx.SetSiteCookie("pgvid", visitorID, 86400*30) // 30 days
|
||||
}
|
||||
|
||||
// Check existing variant assignment
|
||||
cookieName := fmt.Sprintf("pgvar_%d", exp.ID)
|
||||
variantIDStr := ctx.GetSiteCookie(cookieName)
|
||||
var variant *pages_model.PageVariant
|
||||
|
||||
if variantIDStr != "" {
|
||||
variantID, parseErr := strconv.ParseInt(variantIDStr, 10, 64)
|
||||
if parseErr == nil {
|
||||
variant, _ = pages_model.GetVariantByID(ctx, variantID)
|
||||
}
|
||||
}
|
||||
|
||||
if variant == nil {
|
||||
// Load all variants and do weighted random assignment
|
||||
variants, loadErr := pages_model.GetVariantsByExperimentID(ctx, exp.ID)
|
||||
if loadErr != nil || len(variants) == 0 {
|
||||
return config
|
||||
}
|
||||
variant = weightedRandomSelect(variants)
|
||||
ctx.SetSiteCookie(cookieName, strconv.FormatInt(variant.ID, 10), 86400*30)
|
||||
}
|
||||
|
||||
// Set A/B test template data
|
||||
ctx.Data["ABTestActive"] = true
|
||||
ctx.Data["ExperimentID"] = exp.ID
|
||||
ctx.Data["VariantID"] = variant.ID
|
||||
ctx.Data["VisitorID"] = visitorID
|
||||
|
||||
// Determine event tracking URL
|
||||
if blogBaseURL, ok := ctx.Data["BlogBaseURL"].(string); ok && strings.Contains(blogBaseURL, "/pages/blog") {
|
||||
// Path-based: use repo-scoped events URL
|
||||
ctx.Data["EventTrackURL"] = strings.TrimSuffix(blogBaseURL, "/blog") + "/events"
|
||||
} else {
|
||||
ctx.Data["EventTrackURL"] = "/pages/events"
|
||||
}
|
||||
|
||||
// If control variant, no config overrides needed
|
||||
if variant.IsControl || variant.ConfigOverride == "" {
|
||||
return config
|
||||
}
|
||||
|
||||
// Deep-merge variant overrides onto config
|
||||
merged, mergeErr := deepMergeConfig(config, variant.ConfigOverride)
|
||||
if mergeErr != nil {
|
||||
log.Error("Failed to merge variant config: %v", mergeErr)
|
||||
return config
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// generateVisitorID creates a random 16-byte hex visitor identifier.
|
||||
func generateVisitorID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// weightedRandomSelect picks a variant using weighted random selection.
|
||||
func weightedRandomSelect(variants []*pages_model.PageVariant) *pages_model.PageVariant {
|
||||
totalWeight := 0
|
||||
for _, v := range variants {
|
||||
totalWeight += v.Weight
|
||||
}
|
||||
if totalWeight <= 0 {
|
||||
return variants[0]
|
||||
}
|
||||
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(totalWeight)))
|
||||
pick := int(n.Int64())
|
||||
cumulative := 0
|
||||
for _, v := range variants {
|
||||
cumulative += v.Weight
|
||||
if pick < cumulative {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return variants[len(variants)-1]
|
||||
}
|
||||
|
||||
// deepMergeConfig deep-merges a JSON config override onto a base LandingConfig.
|
||||
// Only non-zero values in the override replace base values.
|
||||
func deepMergeConfig(base *pages_module.LandingConfig, overrideJSON string) (*pages_module.LandingConfig, error) {
|
||||
// Marshal base to JSON map
|
||||
baseJSON, err := json.Marshal(base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var baseMap map[string]any
|
||||
if err := json.Unmarshal(baseJSON, &baseMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var overrideMap map[string]any
|
||||
if err := json.Unmarshal([]byte(overrideJSON), &overrideMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Deep merge
|
||||
merged := deepMerge(baseMap, overrideMap)
|
||||
|
||||
// Unmarshal back to LandingConfig
|
||||
mergedJSON, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result pages_module.LandingConfig
|
||||
if err := json.Unmarshal(mergedJSON, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// deepMerge recursively merges src into dst.
|
||||
func deepMerge(dst, src map[string]any) map[string]any {
|
||||
for key, srcVal := range src {
|
||||
if dstVal, ok := dst[key]; ok {
|
||||
// Both are maps: recurse
|
||||
srcMap, srcIsMap := srcVal.(map[string]any)
|
||||
dstMap, dstIsMap := dstVal.(map[string]any)
|
||||
if srcIsMap && dstIsMap {
|
||||
dst[key] = deepMerge(dstMap, srcMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
dst[key] = srcVal
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// ApproveExperiment handles the email approval link for an A/B test experiment
|
||||
func ApproveExperiment(ctx *context.Context) {
|
||||
handleExperimentAction(ctx, true)
|
||||
}
|
||||
|
||||
// DeclineExperiment handles the email decline link for an A/B test experiment
|
||||
func DeclineExperiment(ctx *context.Context) {
|
||||
handleExperimentAction(ctx, false)
|
||||
}
|
||||
|
||||
func handleExperimentAction(ctx *context.Context, approve bool) {
|
||||
tokenStr := ctx.PathParam("token")
|
||||
if tokenStr == "" {
|
||||
ctx.NotFound(errors.New("missing token"))
|
||||
return
|
||||
}
|
||||
|
||||
// Extract and verify the token
|
||||
expIDStr, err := pages_service.VerifyExperimentToken(ctx, tokenStr)
|
||||
if err != nil {
|
||||
log.Error("Invalid experiment token: %v", err)
|
||||
ctx.NotFound(errors.New("invalid or expired token"))
|
||||
return
|
||||
}
|
||||
|
||||
expID, err := strconv.ParseInt(expIDStr, 10, 64)
|
||||
if err != nil {
|
||||
ctx.NotFound(errors.New("invalid experiment ID"))
|
||||
return
|
||||
}
|
||||
|
||||
exp, err := pages_model.GetExperimentByID(ctx, expID)
|
||||
if err != nil || exp == nil {
|
||||
ctx.NotFound(errors.New("experiment not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if approve {
|
||||
err = pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusApproved)
|
||||
ctx.Data["Title"] = "Experiment Approved"
|
||||
ctx.Data["ExperimentApproved"] = true
|
||||
} else {
|
||||
err = pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusPaused)
|
||||
ctx.Data["Title"] = "Experiment Declined"
|
||||
ctx.Data["ExperimentDeclined"] = true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ctx.ServerError("Failed to update experiment", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["ExperimentName"] = exp.Name
|
||||
ctx.HTML(http.StatusOK, "pages/experiment_result")
|
||||
}
|
||||
|
||||
// getContentType returns the content type for a file extension
|
||||
func getContentType(ext string) string {
|
||||
types := map[string]string{
|
||||
|
||||
@@ -213,6 +213,7 @@ func PagesBrandPost(ctx *context.Context) {
|
||||
config.Brand.Name = ctx.FormString("brand_name")
|
||||
config.Brand.LogoURL = ctx.FormString("brand_logo_url")
|
||||
config.Brand.Tagline = ctx.FormString("brand_tagline")
|
||||
config.Brand.FaviconURL = ctx.FormString("brand_favicon_url")
|
||||
if err := savePagesLandingConfig(ctx, config); err != nil {
|
||||
ctx.ServerError("SavePagesConfig", err)
|
||||
return
|
||||
@@ -261,6 +262,11 @@ func PagesContent(ctx *context.Context) {
|
||||
func PagesContentPost(ctx *context.Context) {
|
||||
config := getPagesLandingConfig(ctx)
|
||||
config.Advanced.PublicReleases = ctx.FormBool("public_releases")
|
||||
config.Navigation.ShowDocs = ctx.FormBool("nav_show_docs")
|
||||
config.Navigation.ShowAPI = ctx.FormBool("nav_show_api")
|
||||
config.Navigation.ShowRepository = ctx.FormBool("nav_show_repository")
|
||||
config.Navigation.ShowReleases = ctx.FormBool("nav_show_releases")
|
||||
config.Navigation.ShowIssues = ctx.FormBool("nav_show_issues")
|
||||
config.Stats = nil
|
||||
for i := range 10 {
|
||||
value := ctx.FormString(fmt.Sprintf("stat_value_%d", i))
|
||||
|
||||
@@ -550,6 +550,10 @@ func registerWebRoutes(m *web.Router) {
|
||||
|
||||
m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup)
|
||||
|
||||
// A/B test experiment approval (email links, no auth required — token-verified)
|
||||
m.Get("/-/pages/experiment/approve/{token}", pages.ApproveExperiment)
|
||||
m.Get("/-/pages/experiment/decline/{token}", pages.DeclineExperiment)
|
||||
|
||||
m.Get("/-/web-theme/list", misc.WebThemeList)
|
||||
m.Post("/-/web-theme/apply", optSignIn, misc.WebThemeApply)
|
||||
|
||||
@@ -1794,6 +1798,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Get("", pages.ServeRepoLandingPage)
|
||||
m.Get("/blog", pages.ServeRepoBlogList)
|
||||
m.Get("/blog/{id}", pages.ServeRepoBlogDetail)
|
||||
m.Post("/events", pages.ServePageEvent)
|
||||
m.Get("/assets/*", pages.ServeRepoPageAsset)
|
||||
}, optSignIn, context.RepoAssignment, func(ctx *context.Context) {
|
||||
ctx.Data["PageIsPagesLanding"] = true
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"code.gitcaddy.com/server/v3/modules/updatechecker"
|
||||
asymkey_service "code.gitcaddy.com/server/v3/services/asymkey"
|
||||
attachment_service "code.gitcaddy.com/server/v3/services/attachment"
|
||||
pages_service "code.gitcaddy.com/server/v3/services/pages"
|
||||
repo_service "code.gitcaddy.com/server/v3/services/repository"
|
||||
archiver_service "code.gitcaddy.com/server/v3/services/repository/archiver"
|
||||
user_service "code.gitcaddy.com/server/v3/services/user"
|
||||
@@ -235,6 +236,16 @@ func registerCleanupExpiredUploadSessions() {
|
||||
})
|
||||
}
|
||||
|
||||
func registerAnalyzePageExperiments() {
|
||||
RegisterTaskFatal("analyze_page_experiments", &BaseConfig{
|
||||
Enabled: true,
|
||||
RunAtStart: false,
|
||||
Schedule: "@every 6h",
|
||||
}, func(ctx context.Context, _ *user_model.User, _ Config) error {
|
||||
return pages_service.AnalyzeAllActiveExperiments(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func initExtendedTasks() {
|
||||
registerDeleteInactiveUsers()
|
||||
registerDeleteRepositoryArchives()
|
||||
@@ -251,4 +262,5 @@ func initExtendedTasks() {
|
||||
registerGCLFS()
|
||||
registerRebuildIssueIndexer()
|
||||
registerCleanupExpiredUploadSessions()
|
||||
registerAnalyzePageExperiments()
|
||||
}
|
||||
|
||||
70
services/mailer/mail_pages.go
Normal file
70
services/mailer/mail_pages.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
pages_model "code.gitcaddy.com/server/v3/models/pages"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
user_model "code.gitcaddy.com/server/v3/models/user"
|
||||
"code.gitcaddy.com/server/v3/modules/log"
|
||||
"code.gitcaddy.com/server/v3/modules/setting"
|
||||
sender_service "code.gitcaddy.com/server/v3/services/mailer/sender"
|
||||
)
|
||||
|
||||
// SendExperimentApprovalEmail sends an email to the repo owner with approve/decline links
|
||||
// for an A/B test experiment that has found a winning variant.
|
||||
func SendExperimentApprovalEmail(owner *user_model.User, repo *repo_model.Repository,
|
||||
exp *pages_model.PageExperiment, winnerVariant *pages_model.PageVariant,
|
||||
approveToken, declineToken, summary string,
|
||||
) {
|
||||
if setting.MailService == nil {
|
||||
log.Warn("Mail service not configured, cannot send experiment approval email")
|
||||
return
|
||||
}
|
||||
|
||||
if owner.Email == "" {
|
||||
log.Warn("Repo owner %s has no email, cannot send experiment approval", owner.Name)
|
||||
return
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("[%s] A/B Test Results: %s", setting.AppName, exp.Name)
|
||||
|
||||
approveURL := fmt.Sprintf("%s-/pages/experiment/approve/%s", setting.AppURL, approveToken)
|
||||
declineURL := fmt.Sprintf("%s-/pages/experiment/decline/%s", setting.AppURL, declineToken)
|
||||
|
||||
var body bytes.Buffer
|
||||
body.WriteString("A/B Test Experiment Results\n")
|
||||
body.WriteString("===========================\n\n")
|
||||
body.WriteString(fmt.Sprintf("Repository: %s/%s\n", repo.OwnerName, repo.Name))
|
||||
body.WriteString(fmt.Sprintf("Experiment: %s\n\n", exp.Name))
|
||||
|
||||
body.WriteString("Results Summary\n")
|
||||
body.WriteString("---------------\n")
|
||||
body.WriteString(summary + "\n\n")
|
||||
|
||||
if winnerVariant != nil {
|
||||
body.WriteString(fmt.Sprintf("Winner: %s\n", winnerVariant.Name))
|
||||
body.WriteString(fmt.Sprintf("Impressions: %d\n", winnerVariant.Impressions))
|
||||
body.WriteString(fmt.Sprintf("Conversions: %d\n", winnerVariant.Conversions))
|
||||
body.WriteString(fmt.Sprintf("Conversion Rate: %.2f%%\n\n", winnerVariant.ConversionRate()*100))
|
||||
}
|
||||
|
||||
body.WriteString("Actions\n")
|
||||
body.WriteString("-------\n")
|
||||
body.WriteString(fmt.Sprintf("Approve (apply winning variant): %s\n\n", approveURL))
|
||||
body.WriteString(fmt.Sprintf("Decline (keep current config): %s\n\n", declineURL))
|
||||
|
||||
body.WriteString("This link will expire in 7 days.\n")
|
||||
body.WriteString(fmt.Sprintf("\n--\n%s\n%s\n", setting.AppName, setting.AppURL))
|
||||
|
||||
msg := sender_service.NewMessage(owner.Email, subject, body.String())
|
||||
msg.Info = fmt.Sprintf("Experiment approval for %s/%s: %s", repo.OwnerName, repo.Name, exp.Name)
|
||||
|
||||
if err := sender_service.Send(sender, msg); err != nil {
|
||||
log.Error("Failed to send experiment approval email: %v", err)
|
||||
}
|
||||
}
|
||||
194
services/pages/analysis.go
Normal file
194
services/pages/analysis.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
pages_model "code.gitcaddy.com/server/v3/models/pages"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
"code.gitcaddy.com/server/v3/modules/ai"
|
||||
"code.gitcaddy.com/server/v3/modules/json"
|
||||
"code.gitcaddy.com/server/v3/modules/log"
|
||||
)
|
||||
|
||||
// AnalysisResult holds the AI's analysis of an experiment.
|
||||
type AnalysisResult struct {
|
||||
Status string `json:"status"` // "winner", "needs_more_data", "no_difference"
|
||||
WinnerVariantID int64 `json:"winner_variant_id"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Summary string `json:"summary"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
}
|
||||
|
||||
// AnalyzeExperiment uses the AI sidecar to evaluate experiment results.
|
||||
func AnalyzeExperiment(ctx context.Context, exp *pages_model.PageExperiment) (*AnalysisResult, error) {
|
||||
if !ai.IsEnabled() {
|
||||
return nil, errors.New("AI service is not enabled")
|
||||
}
|
||||
|
||||
// Load variants
|
||||
variants, err := pages_model.GetVariantsByExperimentID(ctx, exp.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load variants: %w", err)
|
||||
}
|
||||
|
||||
// Load event counts by variant
|
||||
eventCounts, err := pages_model.GetEventCountsByExperiment(ctx, exp.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load event counts: %w", err)
|
||||
}
|
||||
|
||||
// Build summary data
|
||||
type variantSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsControl bool `json:"is_control"`
|
||||
Impressions int64 `json:"impressions"`
|
||||
Conversions int64 `json:"conversions"`
|
||||
ConversionRate float64 `json:"conversion_rate"`
|
||||
Events map[string]int64 `json:"events"`
|
||||
}
|
||||
|
||||
summaries := make([]variantSummary, 0, len(variants))
|
||||
for _, v := range variants {
|
||||
vs := variantSummary{
|
||||
ID: v.ID,
|
||||
Name: v.Name,
|
||||
IsControl: v.IsControl,
|
||||
Impressions: v.Impressions,
|
||||
Conversions: v.Conversions,
|
||||
ConversionRate: v.ConversionRate(),
|
||||
Events: eventCounts[v.ID],
|
||||
}
|
||||
summaries = append(summaries, vs)
|
||||
}
|
||||
|
||||
variantsJSON, _ := json.Marshal(summaries)
|
||||
experimentJSON, _ := json.Marshal(map[string]any{
|
||||
"id": exp.ID,
|
||||
"name": exp.Name,
|
||||
"created_at": exp.CreatedUnix,
|
||||
})
|
||||
|
||||
client := ai.GetClient()
|
||||
resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{
|
||||
RepoID: exp.RepoID,
|
||||
Task: "ab_test_analyze",
|
||||
Context: map[string]string{
|
||||
"experiment": string(experimentJSON),
|
||||
"variants": string(variantsJSON),
|
||||
"instruction": `Analyze these A/B test results. Look at conversion rates,
|
||||
impression counts, and event distributions across variants.
|
||||
Determine if there is a statistically significant winner.
|
||||
Return valid JSON:
|
||||
{
|
||||
"status": "winner" or "needs_more_data" or "no_difference",
|
||||
"winner_variant_id": <ID of winning variant, or 0>,
|
||||
"confidence": <0.0 to 1.0>,
|
||||
"summary": "Brief human-readable summary of results",
|
||||
"recommendation": "What to do next"
|
||||
}
|
||||
Require at least 100 impressions per variant before declaring a winner.
|
||||
Use a minimum 95% confidence threshold.`,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AI analysis failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
return nil, fmt.Errorf("AI analysis error: %s", resp.Error)
|
||||
}
|
||||
|
||||
var result AnalysisResult
|
||||
if err := json.Unmarshal([]byte(resp.Result), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse AI analysis: %w", err)
|
||||
}
|
||||
|
||||
// If winner found, update experiment status
|
||||
if result.Status == "winner" {
|
||||
if err := pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusCompleted); err != nil {
|
||||
log.Error("Failed to update experiment status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// AnalyzeAllActiveExperiments analyzes all active experiments that have enough data.
|
||||
func AnalyzeAllActiveExperiments(ctx context.Context) error {
|
||||
experiments, err := pages_model.GetAllActiveExperiments(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load active experiments: %w", err)
|
||||
}
|
||||
|
||||
for _, exp := range experiments {
|
||||
// Load variants to check if we have enough data
|
||||
variants, err := pages_model.GetVariantsByExperimentID(ctx, exp.ID)
|
||||
if err != nil {
|
||||
log.Error("Failed to load variants for experiment %d: %v", exp.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine minimum impressions threshold
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, exp.RepoID)
|
||||
if err != nil {
|
||||
log.Error("Failed to load repo for experiment %d: %v", exp.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
config, err := GetPagesConfig(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("Failed to load config for experiment %d: %v", exp.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
minImpressions := int64(config.Experiments.MinImpressions)
|
||||
if minImpressions <= 0 {
|
||||
minImpressions = 100
|
||||
}
|
||||
|
||||
// Check if all variants have enough impressions
|
||||
hasEnoughData := true
|
||||
for _, v := range variants {
|
||||
if v.Impressions < minImpressions {
|
||||
hasEnoughData = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEnoughData {
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := AnalyzeExperiment(ctx, exp)
|
||||
if err != nil {
|
||||
log.Error("Failed to analyze experiment %d: %v", exp.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if result.Status == "winner" && result.WinnerVariantID > 0 {
|
||||
log.Info("Experiment %d (%s): winner found (variant %d), confidence %.2f",
|
||||
exp.ID, exp.Name, result.WinnerVariantID, result.Confidence)
|
||||
|
||||
// Send approval email if required
|
||||
if config.Experiments.ApprovalRequired {
|
||||
sendExperimentApprovalEmail(repo, exp, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendExperimentApprovalEmail logs the experiment completion.
|
||||
// Actual email sending is handled by services/mailer/mail_pages.go
|
||||
// which is called from the cron task handler to avoid import cycles.
|
||||
func sendExperimentApprovalEmail(repo *repo_model.Repository, exp *pages_model.PageExperiment, result *AnalysisResult) {
|
||||
log.Info("Experiment %d completed: %s. Winner variant: %d. Approval email should be sent to repo owner %s.",
|
||||
exp.ID, result.Summary, result.WinnerVariantID, repo.OwnerName)
|
||||
}
|
||||
193
services/pages/experiment.go
Normal file
193
services/pages/experiment.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pages_model "code.gitcaddy.com/server/v3/models/pages"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
"code.gitcaddy.com/server/v3/modules/ai"
|
||||
"code.gitcaddy.com/server/v3/modules/json"
|
||||
"code.gitcaddy.com/server/v3/modules/log"
|
||||
pages_module "code.gitcaddy.com/server/v3/modules/pages"
|
||||
"code.gitcaddy.com/server/v3/modules/setting"
|
||||
)
|
||||
|
||||
// experimentTokenSecret is derived from the app's secret key
|
||||
func experimentTokenSecret() []byte {
|
||||
h := sha256.Sum256([]byte(setting.SecretKey + ":page-experiment"))
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// CreateExperimentToken creates an HMAC-signed token for experiment approval.
|
||||
func CreateExperimentToken(experimentID int64, action string) string {
|
||||
data := fmt.Sprintf("%d:%s:%d", experimentID, action, time.Now().Unix())
|
||||
mac := hmac.New(sha256.New, experimentTokenSecret())
|
||||
mac.Write([]byte(data))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
return hex.EncodeToString([]byte(data)) + "." + sig
|
||||
}
|
||||
|
||||
// VerifyExperimentToken verifies an experiment approval token and returns the experiment ID.
|
||||
func VerifyExperimentToken(_ context.Context, tokenStr string) (string, error) {
|
||||
parts := strings.SplitN(tokenStr, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", errors.New("invalid token format")
|
||||
}
|
||||
|
||||
dataBytes, err := hex.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", errors.New("invalid token encoding")
|
||||
}
|
||||
data := string(dataBytes)
|
||||
|
||||
// Verify HMAC
|
||||
mac := hmac.New(sha256.New, experimentTokenSecret())
|
||||
mac.Write(dataBytes)
|
||||
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(parts[1]), []byte(expectedSig)) {
|
||||
return "", errors.New("invalid token signature")
|
||||
}
|
||||
|
||||
// Parse data: "experimentID:action:timestamp"
|
||||
dataParts := strings.SplitN(data, ":", 3)
|
||||
if len(dataParts) != 3 {
|
||||
return "", errors.New("invalid token data")
|
||||
}
|
||||
|
||||
// Check expiry (7 days)
|
||||
ts, err := strconv.ParseInt(dataParts[2], 10, 64)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid token timestamp")
|
||||
}
|
||||
if time.Since(time.Unix(ts, 0)) > 7*24*time.Hour {
|
||||
return "", errors.New("token expired")
|
||||
}
|
||||
|
||||
return dataParts[0], nil
|
||||
}
|
||||
|
||||
// GenerateExperiment uses the AI sidecar to create an A/B test experiment for a landing page.
|
||||
func GenerateExperiment(ctx context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) (*pages_model.PageExperiment, error) {
|
||||
if !ai.IsEnabled() {
|
||||
return nil, errors.New("AI service is not enabled")
|
||||
}
|
||||
|
||||
client := ai.GetClient()
|
||||
configJSON, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{
|
||||
RepoID: repo.ID,
|
||||
Task: "ab_test_generate",
|
||||
Context: map[string]string{
|
||||
"landing_config": string(configJSON),
|
||||
"repo_name": repo.Name,
|
||||
"repo_description": repo.Description,
|
||||
"instruction": `Analyze this landing page config and create an A/B test experiment.
|
||||
Return valid JSON with this exact structure:
|
||||
{
|
||||
"name": "Short experiment name",
|
||||
"variants": [
|
||||
{
|
||||
"name": "Variant A",
|
||||
"config_override": { ... partial landing config fields to override ... },
|
||||
"weight": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
Focus on high-impact changes: headlines, CTAs, value propositions.
|
||||
Keep variants meaningfully different but plausible.
|
||||
The control variant (original config) is added automatically — do NOT include it.
|
||||
Return 1-3 variants. Each variant's config_override should be a partial
|
||||
LandingConfig with only the fields that differ from the control.`,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AI task failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
return nil, fmt.Errorf("AI task error: %s", resp.Error)
|
||||
}
|
||||
|
||||
// Parse AI response
|
||||
var result struct {
|
||||
Name string `json:"name"`
|
||||
Variants []struct {
|
||||
Name string `json:"name"`
|
||||
ConfigOverride map[string]any `json:"config_override"`
|
||||
Weight int `json:"weight"`
|
||||
} `json:"variants"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(resp.Result), &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse AI response: %w", err)
|
||||
}
|
||||
|
||||
if result.Name == "" || len(result.Variants) == 0 {
|
||||
return nil, errors.New("AI returned empty experiment")
|
||||
}
|
||||
|
||||
// Create experiment
|
||||
exp := &pages_model.PageExperiment{
|
||||
RepoID: repo.ID,
|
||||
Name: result.Name,
|
||||
Status: pages_model.ExperimentStatusDraft,
|
||||
CreatedByAI: true,
|
||||
}
|
||||
if err := pages_model.CreateExperiment(ctx, exp); err != nil {
|
||||
return nil, fmt.Errorf("failed to create experiment: %w", err)
|
||||
}
|
||||
|
||||
// Create control variant
|
||||
controlVariant := &pages_model.PageVariant{
|
||||
ExperimentID: exp.ID,
|
||||
Name: "Control",
|
||||
IsControl: true,
|
||||
Weight: 50,
|
||||
}
|
||||
if err := pages_model.CreateVariant(ctx, controlVariant); err != nil {
|
||||
return nil, fmt.Errorf("failed to create control variant: %w", err)
|
||||
}
|
||||
|
||||
// Create AI-generated variants
|
||||
remainingWeight := 50
|
||||
for i, v := range result.Variants {
|
||||
overrideJSON, err := json.Marshal(v.ConfigOverride)
|
||||
if err != nil {
|
||||
log.Error("Failed to marshal variant %d config: %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
weight := v.Weight
|
||||
if weight <= 0 {
|
||||
weight = remainingWeight / (len(result.Variants) - i)
|
||||
}
|
||||
remainingWeight -= weight
|
||||
|
||||
variant := &pages_model.PageVariant{
|
||||
ExperimentID: exp.ID,
|
||||
Name: v.Name,
|
||||
IsControl: false,
|
||||
Weight: weight,
|
||||
ConfigOverride: string(overrideJSON),
|
||||
}
|
||||
if err := pages_model.CreateVariant(ctx, variant); err != nil {
|
||||
log.Error("Failed to create variant %s: %v", v.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return exp, nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
git_model "code.gitcaddy.com/server/v3/models/git"
|
||||
pages_model "code.gitcaddy.com/server/v3/models/pages"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
"code.gitcaddy.com/server/v3/models/unit"
|
||||
user_model "code.gitcaddy.com/server/v3/models/user"
|
||||
@@ -119,6 +120,9 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Record fork event for A/B testing analytics
|
||||
pages_model.RecordRepoAction(ctx, opts.BaseRepo.ID, pages_model.EventTypeFork)
|
||||
|
||||
// last - clean up if something goes wrong
|
||||
// WARNING: Don't override all later err with local variables
|
||||
defer func() {
|
||||
|
||||
@@ -1,2 +1,36 @@
|
||||
{{if .ABTestActive}}
|
||||
<script>
|
||||
(function() {
|
||||
var vid = '{{.VisitorID}}';
|
||||
var eid = {{.ExperimentID}};
|
||||
var varid = {{.VariantID}};
|
||||
var base = '{{.EventTrackURL}}';
|
||||
|
||||
function send(type, data) {
|
||||
try {
|
||||
navigator.sendBeacon(base, JSON.stringify({
|
||||
event_type: type, visitor_id: vid,
|
||||
experiment_id: eid, variant_id: varid, data: data || ''
|
||||
}));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
send('impression');
|
||||
|
||||
document.querySelectorAll('[data-cta]').forEach(function(el) {
|
||||
el.addEventListener('click', function() { send('cta_click', el.dataset.cta); });
|
||||
});
|
||||
|
||||
var maxScroll = 0;
|
||||
window.addEventListener('scroll', function() {
|
||||
var pct = Math.round((window.scrollY + window.innerHeight) / document.body.scrollHeight * 100);
|
||||
if (pct > maxScroll) { maxScroll = pct; }
|
||||
});
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (maxScroll > 0) { send('scroll_depth', JSON.stringify({max_percent: maxScroll})); }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Config.Hero.Headline}}{{.Config.Hero.Headline}}{{else}}{{.Repository.Name}}{{end}} - {{.Repository.Owner.Name}}</title>
|
||||
<meta name="description" content="{{if .Config.Hero.Subheadline}}{{.Config.Hero.Subheadline}}{{else}}{{.Repository.Description}}{{end}}">
|
||||
{{if .Config.Brand.FaviconURL}}
|
||||
<link rel="icon" href="{{.Config.Brand.FaviconURL}}">
|
||||
{{else}}
|
||||
<link rel="icon" href="{{AssetUrlPrefix}}/img/favicon.svg" type="image/svg+xml">
|
||||
{{end}}
|
||||
{{template "base/head_style" .}}
|
||||
<style>
|
||||
/* Pages standalone styles - no GitCaddy navbar */
|
||||
|
||||
@@ -1125,19 +1125,23 @@
|
||||
<nav class="nb-nav">
|
||||
<a href="/" class="nb-nav-brand">{{if .Config.Brand.Name}}{{.Config.Brand.Name}}{{else}}{{.Repository.Name}}{{end}}</a>
|
||||
<div class="nb-nav-links">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
<a href="{{.URL}}" class="nb-nav-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="nb-nav-link">Docs</a>{{end}}
|
||||
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="nb-nav-link">API</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="nb-nav-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="nb-nav-link">Issues</a>{{end}}
|
||||
{{if .Config.ValueProps}}<a href="#value-props" class="nb-nav-link">Why</a>{{end}}
|
||||
{{if .Config.Features}}<a href="#features" class="nb-nav-link">Features</a>{{end}}
|
||||
{{if .Config.Pricing.Plans}}<a href="#pricing" class="nb-nav-link">Pricing</a>{{end}}
|
||||
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="nb-nav-link">Blog</a>{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}
|
||||
<a href="{{.RepoURL}}" class="nb-nav-repo">
|
||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
|
||||
Repo
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Config.Hero.PrimaryCTA.Label}}
|
||||
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="nb-btn-primary small">
|
||||
{{.Config.Hero.PrimaryCTA.Label}}
|
||||
@@ -1147,19 +1151,23 @@
|
||||
<button class="nb-mobile-toggle" onclick="document.getElementById('nb-mobile-nav').classList.toggle('open')">Menu</button>
|
||||
</nav>
|
||||
<div class="nb-mobile-menu" id="nb-mobile-nav">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
<a href="{{.URL}}" class="nb-nav-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="nb-nav-link">Docs</a>{{end}}
|
||||
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="nb-nav-link">API</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="nb-nav-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="nb-nav-link">Issues</a>{{end}}
|
||||
{{if .Config.ValueProps}}<a href="#value-props" class="nb-nav-link">Why</a>{{end}}
|
||||
{{if .Config.Features}}<a href="#features" class="nb-nav-link">Features</a>{{end}}
|
||||
{{if .Config.Pricing.Plans}}<a href="#pricing" class="nb-nav-link">Pricing</a>{{end}}
|
||||
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="nb-nav-link">Blog</a>{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}
|
||||
<a href="{{.RepoURL}}" class="nb-nav-repo">
|
||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
|
||||
Repository
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .PageIsBlogDetail}}
|
||||
@@ -1182,7 +1190,7 @@
|
||||
{{.BlogRenderedContent | SafeHTML}}
|
||||
</div>
|
||||
<div style="margin-top: 48px; padding-top: 24px; border-top: 2px solid var(--nb-border-hard);">
|
||||
<a href="{{.BlogBaseURL}}" class="nb-btn-secondary" style="text-decoration: none;">
|
||||
<a href="{{.BlogBaseURL}}" class="nb-btn-secondary" data-cta="secondary" style="text-decoration: none;">
|
||||
{{svg "octicon-arrow-left" 16}} Back to Blog
|
||||
</a>
|
||||
</div>
|
||||
@@ -1237,13 +1245,13 @@
|
||||
<p class="nb-hero-sub nb-reveal nb-reveal-delay-2">{{if .Config.Hero.Subheadline}}{{.Config.Hero.Subheadline}}{{else}}{{.Repository.Description}}{{end}}</p>
|
||||
<div class="nb-hero-buttons nb-reveal nb-reveal-delay-3">
|
||||
{{if .Config.Hero.PrimaryCTA.Label}}
|
||||
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="nb-btn-primary">
|
||||
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="nb-btn-primary" data-cta="primary">
|
||||
{{.Config.Hero.PrimaryCTA.Label}}
|
||||
{{svg "octicon-arrow-right" 18}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .Config.Hero.SecondaryCTA.Label}}
|
||||
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="nb-btn-secondary">
|
||||
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="nb-btn-secondary" data-cta="secondary">
|
||||
{{svg "octicon-play" 18}}
|
||||
{{.Config.Hero.SecondaryCTA.Label}}
|
||||
</a>
|
||||
@@ -1414,7 +1422,7 @@
|
||||
<p>{{.Config.CTASection.Subheadline}}</p>
|
||||
{{end}}
|
||||
{{if .Config.CTASection.Button.Label}}
|
||||
<a href="{{.Config.CTASection.Button.URL}}" class="nb-btn-cta">
|
||||
<a href="{{.Config.CTASection.Button.URL}}" class="nb-btn-cta" data-cta="cta-section">
|
||||
{{.Config.CTASection.Button.Label}}
|
||||
{{svg "octicon-arrow-right" 20}}
|
||||
</a>
|
||||
@@ -1452,7 +1460,7 @@
|
||||
</div>
|
||||
{{if .Config.Blog.CTAButton.Label}}
|
||||
<div style="text-align: center; margin-top: 48px;" class="nb-reveal">
|
||||
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="nb-btn-secondary">
|
||||
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="nb-btn-secondary" data-cta="secondary">
|
||||
{{.Config.Blog.CTAButton.Label}}
|
||||
{{svg "octicon-arrow-right" 18}}
|
||||
</a>
|
||||
@@ -1488,14 +1496,13 @@
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="nb-footer-links">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
<a href="{{.URL}}" class="nb-footer-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="{{.RepoURL}}" class="nb-footer-link">Repository</a>
|
||||
<a href="{{.RepoURL}}/releases" class="nb-footer-link">Releases</a>
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}<a href="{{.RepoURL}}" class="nb-footer-link">Repository</a>{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="nb-footer-link">Docs</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="nb-footer-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="nb-footer-link">Issues</a>{{end}}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
29
templates/pages/experiment_result.tmpl
Normal file
29
templates/pages/experiment_result.tmpl
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f8f9fa; color: #1a1a2e; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 48px; max-width: 480px; text-align: center; box-shadow: 0 4px 24px rgba(0,0,0,0.08); }
|
||||
.icon { font-size: 48px; margin-bottom: 16px; }
|
||||
h1 { font-size: 24px; margin: 0 0 12px; }
|
||||
p { color: #666; line-height: 1.6; margin: 0; }
|
||||
.name { font-weight: 600; color: #1a1a2e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{{if .ExperimentApproved}}
|
||||
<div class="icon">✔</div>
|
||||
<h1>Experiment Approved</h1>
|
||||
<p>The winning variant from <span class="name">{{.ExperimentName}}</span> will be applied to your landing page.</p>
|
||||
{{else if .ExperimentDeclined}}
|
||||
<div class="icon">✖</div>
|
||||
<h1>Experiment Declined</h1>
|
||||
<p>The experiment <span class="name">{{.ExperimentName}}</span> has been paused. Your landing page remains unchanged.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,21 +1,22 @@
|
||||
<footer class="pages-footer">
|
||||
<div class="container">
|
||||
{{if .Config.Footer.Links}}
|
||||
<div class="pages-footer-links">
|
||||
{{range .Config.Footer.Links}}
|
||||
<div class="pages-footer-column">
|
||||
{{if .Title}}
|
||||
<h4 class="pages-footer-title">{{.Title}}</h4>
|
||||
{{end}}
|
||||
<ul class="pages-footer-list">
|
||||
{{range .Items}}
|
||||
<li><a href="{{.URL}}">{{.Text}}</a></li>
|
||||
{{end}}
|
||||
<li><a href="{{.URL}}">{{.Label}}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="pages-footer-column">
|
||||
<ul class="pages-footer-list">
|
||||
{{if .Config.Navigation.ShowRepository}}<li><a href="{{AppSubUrl}}/{{.Repository.FullName}}">Repository</a></li>{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<li><a href="{{AppSubUrl}}/{{.Repository.FullName}}/wiki">Documentation</a></li>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<li><a href="{{AppSubUrl}}/{{.Repository.FullName}}/releases">Releases</a></li>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<li><a href="{{AppSubUrl}}/{{.Repository.FullName}}/issues">Issues</a></li>{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="pages-footer-bottom">
|
||||
{{if .Config.Footer.Copyright}}
|
||||
|
||||
@@ -9,16 +9,18 @@
|
||||
{{end}}
|
||||
</a>
|
||||
<div class="pages-nav-links">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
{{range .Items}}
|
||||
<a href="{{.URL}}" class="pages-nav-link">{{.Text}}</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<a href="{{.URL}}" class="pages-nav-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{AppSubUrl}}/{{.Repository.FullName}}/wiki" class="pages-nav-link">Docs</a>{{end}}
|
||||
{{if .Config.Navigation.ShowAPI}}<a href="{{AppSubUrl}}/{{.Repository.FullName}}/swagger" class="pages-nav-link">API</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{AppSubUrl}}/{{.Repository.FullName}}/releases" class="pages-nav-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{AppSubUrl}}/{{.Repository.FullName}}/issues" class="pages-nav-link">Issues</a>{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}
|
||||
<a href="{{AppSubUrl}}/{{.Repository.FullName}}" class="ui mini button" target="_blank">
|
||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy"> View Source
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -992,42 +992,44 @@
|
||||
<nav class="ea-nav">
|
||||
<a href="/" class="ea-nav-brand">{{if .Config.Brand.Name}}{{.Config.Brand.Name}}{{else}}{{.Repository.Name}}{{end}}</a>
|
||||
<div class="ea-nav-links">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
<a href="{{.URL}}" class="ea-nav-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="{{.RepoURL}}/wiki" class="ea-nav-link">Docs</a>
|
||||
<a href="{{.RepoURL}}" class="ea-nav-link">API</a>
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="ea-nav-link">Docs</a>{{end}}
|
||||
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="ea-nav-link">API</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="ea-nav-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="ea-nav-link">Issues</a>{{end}}
|
||||
{{if .Config.ValueProps}}<a href="#why" class="ea-nav-link">Why</a>{{end}}
|
||||
{{if .Config.Features}}<a href="#features" class="ea-nav-link">Features</a>{{end}}
|
||||
{{if .Config.Pricing.Plans}}<a href="#pricing" class="ea-nav-link">Pricing</a>{{end}}
|
||||
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="ea-nav-link">Blog</a>{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}
|
||||
<a href="{{.RepoURL}}" class="ea-btn-text">
|
||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
|
||||
Repository
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<button class="ea-mobile-toggle" onclick="document.getElementById('ea-mobile-nav').classList.toggle('open')">Menu</button>
|
||||
</nav>
|
||||
<div class="ea-mobile-menu" id="ea-mobile-nav">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
<a href="{{.URL}}" class="ea-nav-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="{{.RepoURL}}/wiki" class="ea-nav-link">Docs</a>
|
||||
<a href="{{.RepoURL}}" class="ea-nav-link">API</a>
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="ea-nav-link">Docs</a>{{end}}
|
||||
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="ea-nav-link">API</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="ea-nav-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="ea-nav-link">Issues</a>{{end}}
|
||||
{{if .Config.ValueProps}}<a href="#why" class="ea-nav-link">Why</a>{{end}}
|
||||
{{if .Config.Features}}<a href="#features" class="ea-nav-link">Features</a>{{end}}
|
||||
{{if .Config.Pricing.Plans}}<a href="#pricing" class="ea-nav-link">Pricing</a>{{end}}
|
||||
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="ea-nav-link">Blog</a>{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}
|
||||
<a href="{{.RepoURL}}" class="ea-btn-text">
|
||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
|
||||
Repository
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .PageIsBlogDetail}}
|
||||
@@ -1103,12 +1105,12 @@
|
||||
|
||||
<div class="ea-hero-ctas ea-reveal ea-reveal-delay-3">
|
||||
{{if .Config.Hero.PrimaryCTA.Label}}
|
||||
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="ea-btn-primary">
|
||||
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="ea-btn-primary" data-cta="primary">
|
||||
{{.Config.Hero.PrimaryCTA.Label}}
|
||||
{{svg "octicon-arrow-right" 14}}
|
||||
</a>
|
||||
{{else}}
|
||||
<a href="{{.RepoURL}}" class="ea-btn-primary">
|
||||
<a href="{{.RepoURL}}" class="ea-btn-primary" data-cta="primary">
|
||||
Get Started
|
||||
{{svg "octicon-arrow-right" 14}}
|
||||
</a>
|
||||
@@ -1297,7 +1299,7 @@
|
||||
{{end}}
|
||||
{{if .Config.CTASection.Button.Label}}
|
||||
<div class="ea-reveal ea-reveal-delay-2">
|
||||
<a href="{{.Config.CTASection.Button.URL}}" class="ea-btn-primary">
|
||||
<a href="{{.Config.CTASection.Button.URL}}" class="ea-btn-primary" data-cta="primary">
|
||||
{{.Config.CTASection.Button.Label}}
|
||||
{{svg "octicon-arrow-right" 14}}
|
||||
</a>
|
||||
@@ -1361,14 +1363,13 @@
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="ea-footer-links">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
<a href="{{.URL}}" class="ea-footer-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="{{.RepoURL}}" class="ea-footer-link">Repository</a>
|
||||
<a href="{{.RepoURL}}/wiki" class="ea-footer-link">Documentation</a>
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}<a href="{{.RepoURL}}" class="ea-footer-link">Repository</a>{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="ea-footer-link">Documentation</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="ea-footer-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="ea-footer-link">Issues</a>{{end}}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -983,23 +983,23 @@
|
||||
<span class="osh-nav-name">{{if .Config.Brand.Name}}{{.Config.Brand.Name}}{{else}}{{.Repository.Name}}{{end}}</span>
|
||||
</a>
|
||||
<div class="osh-nav-links">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
<a href="{{.URL}}" class="osh-nav-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="{{.RepoURL}}" class="osh-nav-link">Repository</a>
|
||||
<a href="{{.RepoURL}}/wiki" class="osh-nav-link">Docs</a>
|
||||
<a href="{{.RepoURL}}/releases" class="osh-nav-link">Releases</a>
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="osh-nav-link">Docs</a>{{end}}
|
||||
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="osh-nav-link">API</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="osh-nav-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="osh-nav-link">Issues</a>{{end}}
|
||||
{{if .Config.ValueProps}}<a href="#value-props" class="osh-nav-link">Why Us</a>{{end}}
|
||||
{{if .Config.Features}}<a href="#features" class="osh-nav-link">Features</a>{{end}}
|
||||
{{if .Config.Pricing.Plans}}<a href="#pricing" class="osh-nav-link">Pricing</a>{{end}}
|
||||
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="osh-nav-link">Blog</a>{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}
|
||||
<a href="{{.RepoURL}}" class="osh-nav-cta">
|
||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
|
||||
Repository
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<button class="osh-menu-toggle" onclick="this.parentElement.querySelector('.osh-nav-links').style.display=this.parentElement.querySelector('.osh-nav-links').style.display==='flex'?'none':'flex'">
|
||||
{{svg "octicon-three-bars" 20}}
|
||||
@@ -1026,7 +1026,7 @@
|
||||
{{.BlogRenderedContent | SafeHTML}}
|
||||
</div>
|
||||
<div style="margin-top: 48px; padding-top: 24px; border-top: 1px solid rgba(255,255,255,0.06);">
|
||||
<a href="{{.BlogBaseURL}}" class="osh-btn-secondary" style="text-decoration: none;">
|
||||
<a href="{{.BlogBaseURL}}" class="osh-btn-secondary" data-cta="secondary" style="text-decoration: none;">
|
||||
{{svg "octicon-arrow-left" 16}} Back to Blog
|
||||
</a>
|
||||
</div>
|
||||
@@ -1085,24 +1085,24 @@
|
||||
|
||||
<div class="osh-hero-ctas osh-reveal visible osh-reveal-delay-3">
|
||||
{{if .Config.Hero.PrimaryCTA.Label}}
|
||||
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="osh-btn-primary">
|
||||
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="osh-btn-primary" data-cta="primary">
|
||||
{{.Config.Hero.PrimaryCTA.Label}}
|
||||
{{svg "octicon-arrow-right" 16}}
|
||||
</a>
|
||||
{{else}}
|
||||
<a href="{{.RepoURL}}" class="osh-btn-primary">
|
||||
<a href="{{.RepoURL}}" class="osh-btn-primary" data-cta="primary">
|
||||
Get Started
|
||||
{{svg "octicon-arrow-right" 16}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if .Config.Hero.SecondaryCTA.Label}}
|
||||
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="osh-btn-secondary">
|
||||
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="osh-btn-secondary" data-cta="secondary">
|
||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
|
||||
{{.Config.Hero.SecondaryCTA.Label}}
|
||||
</a>
|
||||
{{else}}
|
||||
<a href="{{.RepoURL}}" class="osh-btn-secondary">
|
||||
<a href="{{.RepoURL}}" class="osh-btn-secondary" data-cta="secondary">
|
||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
|
||||
View Source
|
||||
</a>
|
||||
@@ -1293,7 +1293,7 @@
|
||||
{{if .Config.CTASection.Subheadline}}
|
||||
<p>{{.Config.CTASection.Subheadline}}</p>
|
||||
{{end}}
|
||||
<a href="{{if .Config.CTASection.Button.URL}}{{.Config.CTASection.Button.URL}}{{else}}{{.RepoURL}}{{end}}" class="osh-btn-primary" style="padding: 16px 32px; font-size: 15px;">
|
||||
<a href="{{if .Config.CTASection.Button.URL}}{{.Config.CTASection.Button.URL}}{{else}}{{.RepoURL}}{{end}}" class="osh-btn-primary" data-cta="primary" style="padding: 16px 32px; font-size: 15px;">
|
||||
{{if .Config.CTASection.Button.Label}}{{.Config.CTASection.Button.Label}}{{else}}Get Started{{end}}
|
||||
{{svg "octicon-arrow-right" 16}}
|
||||
</a>
|
||||
@@ -1330,7 +1330,7 @@
|
||||
</div>
|
||||
{{if .Config.Blog.CTAButton.Label}}
|
||||
<div style="text-align: center; margin-top: 48px;" class="osh-reveal">
|
||||
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="osh-btn-secondary">
|
||||
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="osh-btn-secondary" data-cta="secondary">
|
||||
{{.Config.Blog.CTAButton.Label}}
|
||||
{{svg "octicon-arrow-right" 16}}
|
||||
</a>
|
||||
@@ -1363,16 +1363,13 @@
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="osh-footer-links">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
<a href="{{.URL}}" class="osh-footer-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="{{.RepoURL}}" class="osh-footer-link">Repository</a>
|
||||
<a href="{{.RepoURL}}/wiki" class="osh-footer-link">Documentation</a>
|
||||
<a href="{{.RepoURL}}/releases" class="osh-footer-link">Releases</a>
|
||||
<a href="{{.RepoURL}}/issues" class="osh-footer-link">Issues</a>
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}<a href="{{.RepoURL}}" class="osh-footer-link">Repository</a>{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="osh-footer-link">Documentation</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="osh-footer-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="osh-footer-link">Issues</a>{{end}}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1100,39 +1100,47 @@
|
||||
<span class="gm-nav-name">{{if .Config.Brand.Name}}{{.Config.Brand.Name}}{{else}}{{.Repository.Name}}{{end}}</span>
|
||||
</a>
|
||||
<div class="gm-nav-links">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
<a href="{{.URL}}" class="gm-nav-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="gm-nav-link">Docs</a>{{end}}
|
||||
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="gm-nav-link">API</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="gm-nav-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="gm-nav-link">Issues</a>{{end}}
|
||||
{{if .Config.ValueProps}}<a href="#value-props" class="gm-nav-link">Why</a>{{end}}
|
||||
{{if .Config.Features}}<a href="#features" class="gm-nav-link">Features</a>{{end}}
|
||||
{{if .Config.Pricing.Plans}}<a href="#pricing" class="gm-nav-link">Pricing</a>{{end}}
|
||||
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="gm-nav-link">Blog</a>{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}
|
||||
<a href="{{.RepoURL}}" class="gm-nav-repo">
|
||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
|
||||
Repository
|
||||
</a>
|
||||
<a href="{{if .Config.Hero.PrimaryCTA.URL}}{{.Config.Hero.PrimaryCTA.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary" style="padding: 10px 20px; font-size: 13px;">
|
||||
{{end}}
|
||||
<a href="{{if .Config.Hero.PrimaryCTA.URL}}{{.Config.Hero.PrimaryCTA.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary" data-cta="primary" style="padding: 10px 20px; font-size: 13px;">
|
||||
<span>{{if .Config.Hero.PrimaryCTA.Label}}{{.Config.Hero.PrimaryCTA.Label}}{{else}}Get Started{{end}}</span>
|
||||
</a>
|
||||
</div>
|
||||
<button class="gm-mobile-toggle" onclick="document.getElementById('gm-mobile-nav').classList.toggle('open')">Menu</button>
|
||||
</nav>
|
||||
<div class="gm-mobile-menu" id="gm-mobile-nav">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
<a href="{{.URL}}" class="gm-nav-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="gm-nav-link">Docs</a>{{end}}
|
||||
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="gm-nav-link">API</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="gm-nav-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="gm-nav-link">Issues</a>{{end}}
|
||||
{{if .Config.ValueProps}}<a href="#value-props" class="gm-nav-link">Why</a>{{end}}
|
||||
{{if .Config.Features}}<a href="#features" class="gm-nav-link">Features</a>{{end}}
|
||||
{{if .Config.Pricing.Plans}}<a href="#pricing" class="gm-nav-link">Pricing</a>{{end}}
|
||||
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="gm-nav-link">Blog</a>{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}
|
||||
<a href="{{.RepoURL}}" class="gm-nav-repo">
|
||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
|
||||
Repository
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .PageIsBlogDetail}}
|
||||
@@ -1155,7 +1163,7 @@
|
||||
{{.BlogRenderedContent | SafeHTML}}
|
||||
</div>
|
||||
<div style="margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--gm-glass-border);">
|
||||
<a href="{{.BlogBaseURL}}" class="gm-btn-secondary" style="text-decoration: none;">
|
||||
<a href="{{.BlogBaseURL}}" class="gm-btn-secondary" data-cta="secondary" style="text-decoration: none;">
|
||||
{{svg "octicon-arrow-left" 16}} Back to Blog
|
||||
</a>
|
||||
</div>
|
||||
@@ -1214,14 +1222,14 @@
|
||||
</p>
|
||||
|
||||
<div class="gm-hero-ctas gm-reveal gm-reveal-delay-3">
|
||||
<a href="{{if .Config.Hero.PrimaryCTA.URL}}{{.Config.Hero.PrimaryCTA.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary">
|
||||
<a href="{{if .Config.Hero.PrimaryCTA.URL}}{{.Config.Hero.PrimaryCTA.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary" data-cta="primary">
|
||||
<span>
|
||||
{{if .Config.Hero.PrimaryCTA.Label}}{{.Config.Hero.PrimaryCTA.Label}}{{else}}Get Started{{end}}
|
||||
{{svg "octicon-arrow-right" 16}}
|
||||
</span>
|
||||
</a>
|
||||
{{if .Config.Hero.SecondaryCTA.Label}}
|
||||
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="gm-btn-secondary">
|
||||
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="gm-btn-secondary" data-cta="secondary">
|
||||
{{svg "octicon-play" 16}}
|
||||
{{.Config.Hero.SecondaryCTA.Label}}
|
||||
</a>
|
||||
@@ -1423,7 +1431,7 @@
|
||||
{{if .Config.CTASection.Subheadline}}
|
||||
<p>{{.Config.CTASection.Subheadline}}</p>
|
||||
{{end}}
|
||||
<a href="{{if .Config.CTASection.Button.URL}}{{.Config.CTASection.Button.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary" style="font-size: 16px; padding: 16px 36px;">
|
||||
<a href="{{if .Config.CTASection.Button.URL}}{{.Config.CTASection.Button.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary" data-cta="primary" style="font-size: 16px; padding: 16px 36px;">
|
||||
<span>
|
||||
{{if .Config.CTASection.Button.Label}}{{.Config.CTASection.Button.Label}}{{else}}Get Started Free{{end}}
|
||||
{{svg "octicon-arrow-right" 16}}
|
||||
@@ -1461,7 +1469,7 @@
|
||||
</div>
|
||||
{{if .Config.Blog.CTAButton.Label}}
|
||||
<div style="text-align: center; margin-top: 48px;" class="gm-reveal">
|
||||
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="gm-btn-secondary">
|
||||
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="gm-btn-secondary" data-cta="secondary">
|
||||
{{.Config.Blog.CTAButton.Label}}
|
||||
{{svg "octicon-arrow-right" 16}}
|
||||
</a>
|
||||
@@ -1494,16 +1502,13 @@
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="gm-footer-links">
|
||||
{{if .Config.Footer.Links}}
|
||||
{{range .Config.Footer.Links}}
|
||||
<a href="{{.URL}}" class="gm-footer-link">{{.Label}}</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<a href="{{.RepoURL}}" class="gm-footer-link">Repository</a>
|
||||
<a href="{{.RepoURL}}/wiki" class="gm-footer-link">Docs</a>
|
||||
<a href="{{.RepoURL}}/releases" class="gm-footer-link">Releases</a>
|
||||
<a href="{{.RepoURL}}/issues" class="gm-footer-link">Issues</a>
|
||||
{{end}}
|
||||
{{if .Config.Navigation.ShowRepository}}<a href="{{.RepoURL}}" class="gm-footer-link">Repository</a>{{end}}
|
||||
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="gm-footer-link">Docs</a>{{end}}
|
||||
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="gm-footer-link">Releases</a>{{end}}
|
||||
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="gm-footer-link">Issues</a>{{end}}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.brand_tagline"}}</label>
|
||||
<input name="brand_tagline" value="{{.Config.Brand.Tagline}}" placeholder="Your tagline here">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.brand_favicon_url"}}</label>
|
||||
<input name="brand_favicon_url" value="{{.Config.Brand.FaviconURL}}" placeholder="https://example.com/favicon.ico">
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.brand_favicon_url_help"}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,39 @@
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.navigation"}}</h5>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.navigation_desc"}}</p>
|
||||
<div class="inline field">
|
||||
<div class="ui toggle checkbox">
|
||||
<input type="checkbox" name="nav_show_docs" {{if .Config.Navigation.ShowDocs}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.nav_show_docs"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<div class="ui toggle checkbox">
|
||||
<input type="checkbox" name="nav_show_api" {{if .Config.Navigation.ShowAPI}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.nav_show_api"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<div class="ui toggle checkbox">
|
||||
<input type="checkbox" name="nav_show_repository" {{if .Config.Navigation.ShowRepository}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.nav_show_repository"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<div class="ui toggle checkbox">
|
||||
<input type="checkbox" name="nav_show_releases" {{if .Config.Navigation.ShowReleases}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.nav_show_releases"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<div class="ui toggle checkbox">
|
||||
<input type="checkbox" name="nav_show_issues" {{if .Config.Navigation.ShowIssues}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.nav_show_issues"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.public_releases"}}</h5>
|
||||
<div class="inline field">
|
||||
<div class="ui toggle checkbox">
|
||||
|
||||
Reference in New Issue
Block a user