Version

Portal Design

A project's portal design as a connector's creator surface edits it — the draft beside the published design, every choice with its label and options, a signed preview address, and the saves, publish, discard and reset the dashboard's designer makes.

Subscriby\Connector\Core\PortalDesign. Every project has a hosted portal page, and its look is one design document, published beside a draft: the creator changes the draft, previews it on the real page and publishes when it is right. This port is that designer for a platform that has a conversation with creators. Every call takes the CreatorRef of the account that is talking and the ProjectRef of the project, and runs as that creator: the owner, or a teammate under the same project permissions the dashboard's designer checks. The details answer every choice twice over, the stored value a save sends back and the label the core phrases for it, with the options each control offers, so a surface prints words and never maps a value to a name of its own.

The images stay on the dashboard

The banner image and the project photo are project attributes, not design keys: they are uploaded in the dashboard's designer and show on the portal at once. A connector's surface says so and links to the dashboard until it can take an upload.

designOf()

$design = $this->designs->designOf($creator, $project);

$design->projectName;
$design->hasDraft;                 // whether a draft waits to be published or discarded
$design->draftSavedAt;             // when it was last saved, or null
$design->previewUrl;               // a signed address that shows the draft on the real page for ten minutes, or null without a portal page
$design->portalUrl;                // the portal page, or null while the project has no handle
$design->preset->label;            // "Classic", with ->value "classic"
foreach ($design->presets as $option) { $option->value; $option->label; }
$design->colorMode->label;         // "Light", "Dark" or "Follow the visitor"
$design->colorModes;               // the three, as options
$design->accent;                   // "#a3e635", every colour a six-digit lowercase hex code
$design->accentSecondary;          // the creator's second accent, or null for the preset's own
$design->accentSecondaryInUse;     // what the portal paints with either way
$design->background; $design->grid; $design->surface; $design->text; $design->heading; $design->muted;
$design->tagline;                  // one line under the project's name, or null
$design->taglineMax;               // 80
foreach ($design->socialLinks as $link) { $link->network; $link->networkLabel; $link->url; $link->label; $link->title; }
$design->socialLinksMax;           // 12
$design->networks;                 // every network a link may point at, as options
$design->customNetwork;            // the value of the one network whose link the creator names
$design->sections;                 // the sections a reset may name, as options
$design->fontDisplay->label;       // "Asap Condensed", the face of the headings and figures
$design->fontBody->label;          // "Inter", the face of everything else
$design->fonts;                    // the twelve faces as options, the bundled pair first
$design->headingCase->label;       // "Uppercase" or "As written"
$design->headingCases;             // the two, as options
$design->typeScale->label;         // "Compact", "Regular" or "Large"
$design->typeScales;               // the three, as options
$design->radius->label;            // "Sharp", "Soft" or "Round"
$design->radii;                    // the three, as options
$design->buttonStyle->label;       // "Pill", "Rounded" or "Square"
$design->buttonStyles;             // the three, as options
$design->buttonFill->label;        // "Dark", "Accent" or "Outline"
$design->buttonFills;              // the three, as options
$design->buttonCase->label;        // "Uppercase" or "As written"; its options are headingCases
$design->shadow->label;            // "None", "Soft" or "Strong"
$design->shadows;                  // the three, as options
$design->cardBorder;               // whether the two surface cards draw a hairline border
$design->arrangement->label;       // "Left", "Right", "Stacked" or "Hero"
$design->arrangements;             // the four, as options
$design->contentWidth->label;      // "Narrow", "Regular" or "Wide"
$design->contentWidths;            // the three, as options
$design->stickySidebar;            // whether the sidebar card stays in view while the page scrolls
$design->planGrid->label;          // "List", "Two columns" or "Three columns"
$design->planGrids;                // the three, as options
$design->planDensity->label;       // "Compact" or "Roomy"
$design->planDensities;            // the two, as options
$design->plansHeading;             // whether the plan list opens with its heading and subtitle
$design->metricsSubscribers;       // whether the subscriber count shows, while the project shows metrics at all
$design->metricsFollowers;         // whether the follower count shows
$design->metricsThreshold;         // the count a figure must reach before it shows; 0 shows every figure
$design->plansTitle;               // the creator's heading over the plan list, or null for the built-in wording
$design->plansSubtitle;            // ... and so on for labelLogin, labelShare, labelSubscribe, checkoutTitle,
$design->successMessage;           //     checkoutSubtitle, checkoutNote, successTitle and successMessage
$design->copyMax;                  // ['plans_title' => 60, 'label_login' => 24, ...], the ceiling of each text
$design->perksTitle;               // the heading over the perks, or null for the built-in wording
$design->perks;                    // ['Weekly research notes', ...], the perks in order
$design->faqTitle;                 // the heading over the questions, or null for the built-in wording
$design->faq;                      // PortalFaqItemDetails[]: ->question and ->answer
$design->aboutName;                // the creator's name on the about card, or null
$design->aboutBio;                 // a few lines about the creator, or null
$design->blockLimits;              // ['perks' => 12, 'perk' => 120, 'faq' => 12, 'faq_question' => 160, 'faq_answer' => 800, ...]
$design->customButtonLabel;        // the second sidebar button's label, or null when there is no button
$design->customButtonUrl;          // its address; the pair is always both or neither
$design->footerRefundUrl;          // the footer links, or null for none: refund policy, contact (a page or mailto:), imprint
$design->footerLine;               // a line of the creator's under the footer links, or null
$design->footerHelp;               // whether the footer carries the Help link
$design->customButtonLabelMax;     // 24
$design->footerLineMax;            // 120
$design->announcementText;         // the strip across the top of the page, or null for none
$design->announcementUrl;          // where it leads, or null
$design->announcementStartsAt;     // "2026-11-01T03:30:00Z", ISO 8601 in UTC, or null for at once
$design->announcementEndsAt;       // the same, or null for never
$design->announcementTextMax;      // 160
$design->videoUrl;                 // "https://www.youtube.com/watch?v=…" or "https://vimeo.com/…", the canonical address, or null
$design->videoProvider;            // "YouTube" | "Vimeo" | null
$design->galleryTitle;             // the heading over the gallery, or null for the built-in wording
$design->gallery;                  // string[]: the pictures as stored, in order
$design->testimonialsTitle;        // the heading over the quotes, or null for the built-in wording
$design->testimonials;             // PortalTestimonialDetails[]: ->quote, ->name, ->subtitle (nullable, a title or a city), ->avatarUrl (nullable)
$design->mediaLimits;              // ['gallery' => 8, 'testimonials' => 12, 'testimonial_quote' => 300, 'testimonial_name' => 60, 'testimonial_subtitle' => 60, 'title' => 60]
$design->sidebarItems;             // PortalPlacementDetails[] in order: ->key ("tagline", "metrics", "buttons", "description", "social_links", "footer"), ->label, ->shown
$design->contentBlocks;            // PortalPlacementDetails[] in order: ->key ("perks", "about", "faq", "video", "gallery", "testimonials"), ->label, ->shown
$design->localeSwitcher;           // whether the list of languages shows under the sidebar card
$design->featuredPlanId;           // the plan with the Most popular badge, or null
$design->featuredPlanName;         // its name, or null
$design->planOptions;              // PortalDesignOption[]: every plan of the project, ->value an id the draft may feature, ->label its name
$design->couponBox;                // "always" | "link" (only once a coupon arrived with the visit)
$design->couponBoxes;              // PortalDesignOption[] for the two settings
$design->shareButton;              // whether the header shows the Share button
$design->shareNetworks;            // string[]: the networks the share sheet offers, e.g. "x", "telegram", "whatsapp"
$design->shareNetworkOptions;      // PortalDesignOption[]: every network the sheet can draw
$design->loginButton;              // whether a visitor sees the Login button
$design->socialProof;              // whether the plan list says how many joined this week
$design->seatsBadges;              // whether a series card shows the seats left
$design->seoIndexable;             // whether search engines may list the portal, on top of the project's own readiness
$design->seoSocialCard;            // whether a shared link sends the generated card
$design->pixelMeta;                // the Meta Pixel dataset id, or null
$design->pixelGa4;                 // the Google Analytics 4 measurement id ("G-…"), or null
$design->pixelTiktok;              // the TikTok Pixel id, or null
$design->pixelGoogleAds;           // the Google Ads conversion id ("AW-…"), or null
$design->customCss;                // the creator's own stylesheet, or null
$design->customCssMax;             // 16384
$design->seoTitleSuffix;           // the words after the project's name in the page title, or null for "Official Subscriptions"
$design->seoDescription;           // the description search engines and shared links show, or null for one cut from the project's text
$design->socialCardUrl;            // an uploaded card as stored, or null for the card the portal draws in its colours
$design->seoTitleSuffixMax;        // 60
$design->seoDescriptionMax;        // 160
$design->defaultLocale;            // "es" or null for the platform's default
$design->locales;                  // string[]: the languages the switcher offers, e.g. ["en", "es"]
$design->localeOptions;            // PortalDesignOption[]: every language, ->value a code, ->label its own name

PortalDesignDetails is the draft when one waits, else the published design, which is what a creator edits either way: the first change to a project with no draft starts one from the published design, and nothing a visitor sees moves until publish().

saveDraft()

use Subscriby\Connector\Data\PortalDesignDraft;
use Subscriby\Connector\Data\PortalPlacementDraft;
use Subscriby\Connector\Data\PortalSocialLinkDraft;
use Subscriby\Connector\Data\PortalTestimonialDraft;

$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(tagline: 'Weekly notes, every Monday'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(preset: 'midnight'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(colorMode: 'system'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(accent: '#f43f5e', grid: false));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(text: '#27272a', surface: '#ffffff')); // the body text, the headings and the captions must read on the surface and the background: a pair under a 3:1 contrast is refused as invalid_design
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(accentSecondary: PortalDesignDraft::NONE));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(fontDisplay: 'playfair-display', fontBody: 'manrope'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(headingCase: 'as_written', typeScale: 'large'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(radius: 'sharp', buttonStyle: 'square', buttonFill: 'outline', shadow: 'none', cardBorder: true));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(arrangement: 'stacked', contentWidth: 'wide', planGrid: 'two', planDensity: 'compact', plansHeading: false, metricsThreshold: 50));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(plansTitle: 'Pick your pass', labelSubscribe: 'Join', successMessage: "Grab a seat.\n\nThe doors open at nine."));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(plansTitle: PortalDesignDraft::NONE)); // back to the built-in wording
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(perks: ['Weekly research notes', 'The members lounge'], aboutName: 'Ada'));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(faq: [new PortalFaqItemDraft('When do the notes arrive?', 'Every Monday, before nine.')])); // the whole list each time
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(customButtonLabel: 'Our store', customButtonUrl: 'https://store.example.com', footerContactUrl: 'mailto:hello@example.com', footerHelp: false));
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(announcementText: 'Doors open Sunday at nine', announcementStartsAt: '2026-11-01T09:00:00+05:30', announcementEndsAt: '2026-11-02T09:00:00+05:30')); // any zone in, UTC out
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(videoUrl: 'https://youtu.be/dQw4w9WgXcQ', testimonials: [new PortalTestimonialDraft('Worth every cent.', 'Grace Hopper', 'Lisbon')])); // any share address in, the canonical one out; the whole quote list each time, each photo resent as read, because a picture is uploaded on the dashboard alone
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(sidebarItems: [new PortalPlacementDraft('social_links'), new PortalPlacementDraft('metrics', shown: false)], localeSwitcher: false)); // the order wanted; a part left out keeps its place at the end, shown
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(featuredPlanId: $design->planOptions[0]->value, couponBox: 'link', shareNetworks: ['x', 'telegram', 'whatsapp'], socialProof: true)); // a plan the project does not have is refused as invalid_design
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(seoIndexable: false, pixelGa4: 'G-ABC123XYZ', customCss: '.portal-card { border-width: 2px; }')); // an id in the wrong shape, or a sheet with @import, url() or anything that runs, is refused as invalid_design
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(seoTitleSuffix: 'Memberships', seoDescription: 'Weekly research notes and the members lounge.')); // the card image is uploaded on the dashboard alone
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(defaultLocale: 'es', locales: ['en', 'es'])); // the whole list; a list without its default, or empty, is refused as invalid_design
$design = $this->designs->saveDraft($creator, $project, new PortalDesignDraft(socialLinks: [
    new PortalSocialLinkDraft('website', 'https://example.com'),
    new PortalSocialLinkDraft('custom', 'https://example.com/notes', 'My notes'),
]));

A draft names only what changed: null keeps a choice as it is, so a bot that answers one question sends one field. The tagline and the second accent are cleared with PortalDesignDraft::NONE, the empty string, since null cannot mean both. A preset recolours every colour through the core's own rule and keeps the tagline, the links and the colour mode, so a surface sends the preset's value and never copies a palette. The social-link row is always sent whole, because a row is edited by adding and removing links; a custom link needs a label, a named network ignores one. The core rebuilds and validates the whole document on every save, so a colour that is not a hex code, a tagline past its length or an address without a scheme is refused with the sentence the dashboard would show, and the draft is left as it was.

publish(), discardDraft() and resetSection()

$design = $this->designs->publish($creator, $project);                 // the draft becomes the published design
$design = $this->designs->discardDraft($creator, $project);            // the draft goes, the published design is shown again
$design = $this->designs->resetSection($creator, $project, 'brand');   // one section back to the defaults, in the draft

publish() and discardDraft() refuse with no_draft when nothing waits. A reset names a section from PortalDesignDetails::$sections (brand, colours, typography, shape, layout, copy, blocks, extras, announcement, media, sections, behaviour, visibility, language) and leaves the other sections as drafted.

Refusals

Every refusal is a PortalDesignRefused with a stable reason beside the core's sentence:

ReasonWhen
project_unknownThe creator is unknown, or may not see the project. Answer it as missing information.
forbiddenThe creator may see the project but not update it (a teammate without the permission).
invalid_designA value the portal cannot draw; the sentence names the choice.
no_draftA publish or a discard with nothing drafted.
section_unknownA reset naming a section the catalogue does not carry.

Declaring it

A creator surface that offers the designer declares portal_design among its management_commands, and ManagementSurface::commands() returns ManagementCommand::PortalDesign with it. The Telegram connector's platform bot is the reference: a 🎨 Portal Design button on the project card opens a home screen with the Brand and Colours screens, the preview, Publish and Discard.

How is this guide?

Last updated on

Version

On this page

Subscriby is a product
designed by you — for you.
No boardroom full of executives deciding what we ships next. Our roadmap always shaped by you with your feedback.

Share feedback or a request

For AI agents: llms.txt · llms-full.txt