Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/(builder)/ycode/components/ElementLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1456,9 +1456,10 @@ export default function ElementLibrary({ isOpen, onClose, liveLayerUpdates }: El
return (
<div
className={cn(
'fixed left-64 top-14 bottom-0 w-64 bg-background border-r z-50 flex flex-col items-center justify-center p-6 text-center',
'fixed top-14 bottom-0 w-64 bg-background border-r z-50 flex flex-col items-center justify-center p-6 text-center',
!isOpen && 'hidden'
)}
style={{ left: `${leftSidebarWidth}px` }}
>
<Empty>
<EmptyMedia variant="icon">
Expand Down
20 changes: 10 additions & 10 deletions app/(builder)/ycode/components/HeaderBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,16 @@ export default function HeaderBar({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canManageSettings && !pathname?.startsWith('/ycode/localization') && (
<>
<DropdownMenuItem
onClick={() => router.push('/ycode/localization')}
>
Manage locales
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuRadioGroup
value={selectedLocaleId || ''}
onValueChange={(value) => {
Expand All @@ -651,16 +661,6 @@ export default function HeaderBar({
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
{canManageSettings && !pathname?.startsWith('/ycode/localization') && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => router.push('/ycode/localization')}
>
Manage locales
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>

Expand Down
11 changes: 10 additions & 1 deletion components/LayerRendererPublic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ interface LayerRendererPublicProps {
currentLocale?: Locale | null;
availableLocales?: Locale[];
localeSelectorFormat?: 'locale' | 'code';
/** Pre-computed relative URLs per locale ID (translated slugs) for the locale selector. */
localizedPageUrls?: Record<string, string>;
isInsideForm?: boolean;
isInsideLink?: boolean;
parentFormSettings?: FormSettings;
Expand Down Expand Up @@ -139,6 +141,7 @@ const LayerRendererPublic: React.FC<LayerRendererPublicProps> = ({
currentLocale,
availableLocales = [],
localeSelectorFormat,
localizedPageUrls,
isInsideForm = false,
isInsideLink = false,
parentFormSettings,
Expand Down Expand Up @@ -252,6 +255,7 @@ const LayerRendererPublic: React.FC<LayerRendererPublicProps> = ({
currentLocale={currentLocale}
availableLocales={availableLocales}
localeSelectorFormat={localeSelectorFormat}
localizedPageUrls={localizedPageUrls}
isInsideForm={isInsideForm}
isInsideLink={isInsideLink}
parentFormSettings={parentFormSettings}
Expand Down Expand Up @@ -294,6 +298,7 @@ const LayerItem: React.FC<{
currentLocale?: Locale | null;
availableLocales?: Locale[];
localeSelectorFormat?: 'locale' | 'code';
localizedPageUrls?: Record<string, string>;
isInsideForm?: boolean;
isInsideLink?: boolean;
parentFormSettings?: FormSettings;
Expand Down Expand Up @@ -324,6 +329,7 @@ const LayerItem: React.FC<{
currentLocale,
availableLocales,
localeSelectorFormat,
localizedPageUrls,
isInsideForm = false,
isInsideLink = false,
parentFormSettings,
Expand Down Expand Up @@ -384,6 +390,7 @@ const LayerItem: React.FC<{
currentLocale,
availableLocales,
localeSelectorFormat,
localizedPageUrls,
isInsideForm,
isInsideLink,
parentFormSettings,
Expand All @@ -398,7 +405,7 @@ const LayerItem: React.FC<{
serverSettings,
lcpCandidateLayerId,
passwordProtection,
}), [isPublished, pageId, collectionLayerData, collectionLayerItemId, effectiveLayerDataMap, pageCollectionItemId, pageCollectionItemData, pageCollectionSortedItemIds, hiddenLayerInfo, currentLocale, availableLocales, localeSelectorFormat, isInsideForm, isInsideLink, parentFormSettings, pages, folders, collectionItemSlugs, isPreview, translations, anchorMap, resolvedAssets, componentsProp, serverSettings, lcpCandidateLayerId, passwordProtection]);
}), [isPublished, pageId, collectionLayerData, collectionLayerItemId, effectiveLayerDataMap, pageCollectionItemId, pageCollectionItemData, pageCollectionSortedItemIds, hiddenLayerInfo, currentLocale, availableLocales, localeSelectorFormat, localizedPageUrls, isInsideForm, isInsideLink, parentFormSettings, pages, folders, collectionItemSlugs, isPreview, translations, anchorMap, resolvedAssets, componentsProp, serverSettings, lcpCandidateLayerId, passwordProtection]);

const renderComponentBlock: RenderComponentBlockFn = useCallback(
(comp, resolvedLayers, _overrides, key, innerAncestorIds) => {
Expand Down Expand Up @@ -1675,6 +1682,7 @@ const LayerItem: React.FC<{
currentLocale={currentLocale}
availableLocales={availableLocales}
localeSelectorFormat={localeSelectorFormat}
localizedPageUrls={localizedPageUrls}
isInsideForm={isInsideForm}
isInsideLink={isInsideLink}
parentFormSettings={parentFormSettings}
Expand Down Expand Up @@ -1737,6 +1745,7 @@ const LayerItem: React.FC<{
availableLocales={availableLocales}
currentPageSlug={currentPageSlug}
isPublished={isPublished}
localizedPageUrls={localizedPageUrls}
/>
</Tag>
);
Expand Down
72 changes: 71 additions & 1 deletion components/PageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ import { getMapboxAccessToken, getGoogleMapsEmbedApiKey } from '@/lib/map-server
import { getAllColorVariables } from '@/lib/repositories/colorVariableRepository';
import { getSettingByKey } from '@/lib/repositories/settingsRepository';
import { getItemsWithValues, getItemsWithValuesByIds } from '@/lib/repositories/collectionItemRepository';
import { getValuesByItemIds } from '@/lib/repositories/collectionItemValueRepository';
import { getFieldsByCollectionId } from '@/lib/repositories/collectionFieldRepository';
import { REF_PAGE_PREFIX, REF_COLLECTION_PREFIX, isCollectionItemKeyword, parseCollectionLinkValue } from '@/lib/link-utils';
import { getClassesString, hasPasswordFormLayer } from '@/lib/layer-utils';
import type { Layer, Component, Page, CollectionItemWithValues, CollectionField, Locale, PageFolder, PasswordProtectionContext } from '@/types';
import { buildLocalizedPageUrls, type LocalizedDynamicSlug } from '@/lib/page-utils';
import { getTranslatableKey } from '@/lib/locale-runtime';
import { getTranslationsByLocale } from '@/lib/repositories/translationRepository';
import type { Layer, Component, Page, CollectionItemWithValues, CollectionField, Locale, PageFolder, PasswordProtectionContext, Translation } from '@/types';

interface PageLinkRef { collection_item_id: string; page_id: string }

Expand Down Expand Up @@ -408,6 +412,71 @@ export default async function PageRenderer({
console.error('[PageRenderer] Error fetching link resolution data:', error);
}

// Referenced/ref item slugs above are stored in the source language. On a
// non-default locale, swap each to its translated slug so dynamic-page links
// resolve to the localized URL (e.g. /fr/.../a-fr instead of /fr/.../a-en).
if (translations && locale && !locale.is_default) {
for (const itemId of Object.keys(collectionItemSlugs)) {
const translatedSlug = translations[`cms:${itemId}:field:key:slug`]?.content_value;
if (translatedSlug) {
collectionItemSlugs[itemId] = translatedSlug;
}
}
}

// Pre-compute localized URLs for the locale selector so switching language
// preserves translated folder/page/CMS slugs instead of reusing the source slug.
// Only runs on multi-locale pages that actually render a locale selector.
let localizedPageUrls: Record<string, string> | undefined;
if (
availableLocales.length > 1 &&
layerTreeHasLayer(resolvedLayers, l => l.name === 'localeSelector')
) {
try {
const translationsByLocale: Record<string, Record<string, Translation>> = {};
await Promise.all(
availableLocales
.filter(l => !l.is_default)
.map(async (l) => {
// Reuse already-loaded translations for the current locale
if (locale && l.id === locale.id && translations) {
translationsByLocale[l.id] = translations as Record<string, Translation>;
return;
}
const rows = await getTranslationsByLocale(l.id, usePublishedData);
const map: Record<string, Translation> = {};
for (const t of rows) {
map[getTranslatableKey(t)] = t;
}
translationsByLocale[l.id] = map;
})
);

// Dynamic pages need the translated CMS item slug per locale
let dynamicSlug: LocalizedDynamicSlug | null = null;
if (page.is_dynamic && collectionItem) {
const slugField = collectionFields.find(f => f.key === 'slug');
if (slugField) {
// collectionItem.values are already translated for the current locale,
// so fetch the raw (default-locale) slug to use as the default + fallback.
const rawValues = await getValuesByItemIds([collectionItem.id], usePublishedData, undefined, [slugField.id]);
const sourceSlug = rawValues[collectionItem.id]?.[slugField.id];
if (sourceSlug) {
dynamicSlug = {
itemId: collectionItem.id,
contentKey: slugField.key ? `field:key:${slugField.key}` : `field:id:${slugField.id}`,
defaultValue: String(sourceSlug),
};
}
}
}

localizedPageUrls = buildLocalizedPageUrls(page, folders, availableLocales, translationsByLocale, dynamicSlug);
} catch (error) {
console.error('[PageRenderer] Error building localized page URLs:', error);
}
}

// Extract custom code from page settings and resolve placeholders for dynamic pages
const rawPageCustomCodeHead = page.settings?.custom_code?.head || '';
const rawPageCustomCodeBody = page.settings?.custom_code?.body || '';
Expand Down Expand Up @@ -711,6 +780,7 @@ export default async function PageRenderer({
hiddenLayerInfo={hiddenLayerInfo}
currentLocale={locale}
availableLocales={availableLocales}
localizedPageUrls={localizedPageUrls}
pages={pages as any}
folders={folders as any}
collectionItemSlugs={collectionItemSlugs}
Expand Down
22 changes: 16 additions & 6 deletions components/layers/LocaleSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ interface LocaleSelectorProps {
availableLocales: Locale[];
currentPageSlug: string;
isPublished: boolean;
/**
* Pre-computed relative URLs per locale ID, resolved server-side with
* translated folder/page/CMS slugs. When available, these take precedence
* over naive prefix manipulation so translated slugs are preserved.
*/
localizedPageUrls?: Record<string, string>;
}

/**
Expand All @@ -18,6 +24,7 @@ export default function LocaleSelector({
availableLocales,
currentPageSlug,
isPublished,
localizedPageUrls,
}: LocaleSelectorProps) {
// Detect if we're in preview mode
const isPreviewMode = typeof window !== 'undefined' && window.location.pathname.startsWith('/ycode/preview');
Expand All @@ -30,13 +37,16 @@ export default function LocaleSelector({
const selectedLocaleId = event.target.value;
const selectedLocale = availableLocales.find(l => l.id === selectedLocaleId);

if (selectedLocale) {
// Build the new URL for the selected locale
const newUrl = buildLocalizedUrl(currentPageSlug, selectedLocale, currentLocale || null, isPreviewMode);
if (!selectedLocale) return;

// Redirect to the new URL
window.location.href = newUrl;
}
// Prefer the server-resolved URL (translated slugs); fall back to naive prefixing
const precomputed = localizedPageUrls?.[selectedLocaleId];
const newUrl = precomputed
? (isPreviewMode ? `/ycode/preview${precomputed}` : precomputed)
: buildLocalizedUrl(currentPageSlug, selectedLocale, currentLocale || null, isPreviewMode);

// Redirect to the new URL
window.location.href = newUrl;
};

return (
Expand Down
58 changes: 58 additions & 0 deletions lib/page-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,64 @@ export function buildLocalizedDynamicPageUrl(
return patternPath.replace(/\{slug\}/g, collectionItemSlug);
}

/**
* Slug context for a dynamic (CMS-driven) page, used to resolve the translated
* item slug per locale.
*/
export interface LocalizedDynamicSlug {
/** Collection item ID (translation `source_id`). */
itemId: string;
/** Translation `content_key` for the slug field (e.g. `field:key:slug`). */
contentKey: string;
/** Default-locale slug value, used as the fallback. */
defaultValue: string;
}

/**
* Build relative localized URLs for a page across every locale, keyed by locale ID.
* Reuses translated folder/page slugs and, for dynamic pages, the translated CMS
* item slug. Powers the locale selector so switching language preserves the
* correct translated path.
*
* @param translationsByLocale - Per-locale translation maps keyed by translatable key
* @param dynamicSlug - Slug context for dynamic pages (omit for static pages)
*
* @example
* buildLocalizedPageUrls(page, folders, locales, translationsByLocale, dynamicSlug)
* // { 'locale-en': '/solutions/structured-websites', 'locale-fr': '/fr/solutions/sites-structures' }
*/
export function buildLocalizedPageUrls(
page: Page,
allFolders: PageFolder[],
locales: Locale[],
translationsByLocale: Record<string, Record<string, Translation> | undefined>,
dynamicSlug?: LocalizedDynamicSlug | null
): Record<string, string> {
const urls: Record<string, string> = {};

for (const locale of locales) {
const isDefaultLocale = locale.is_default;
const localeArg = isDefaultLocale ? null : locale;
const translations = isDefaultLocale ? undefined : translationsByLocale[locale.id];

if (dynamicSlug) {
const translatedSlug = isDefaultLocale
? dynamicSlug.defaultValue
: translations?.[getTranslatableKey({
source_type: 'cms',
source_id: dynamicSlug.itemId,
content_key: dynamicSlug.contentKey,
})]?.content_value || dynamicSlug.defaultValue;

urls[locale.id] = buildLocalizedDynamicPageUrl(page, allFolders, translatedSlug, localeArg, translations);
} else {
urls[locale.id] = buildLocalizedSlugPath(page, allFolders, 'page', localeArg, translations);
}
}

return urls;
}

/**
* Detect locale code from URL path
* Checks if the first segment of the path is a valid locale code from the provided list
Expand Down