diff --git a/.github/workflows/sanity-deploy.yml b/.github/workflows/sanity-deploy.yml new file mode 100644 index 0000000..773ab59 --- /dev/null +++ b/.github/workflows/sanity-deploy.yml @@ -0,0 +1,26 @@ +name: Deploy Sanity Studio +on: + push: + branches: [main] + paths: + - 'apps/sanity/**' +jobs: + deploy: + name: Build and Deploy + runs-on: ubuntu-latest + env: + SANITY_AUTH_TOKEN: ${{ secrets.SANITY_DEPLOY_STUDIO_TOKEN }} + SANITY_STUDIO_PREVIEW_DOMAIN: ${{ secrets.SANITY_STUDIO_PREVIEW_DOMAIN }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 2 + - uses: actions/setup-node@v2 + with: + node-version: '18.x' + - uses: oven-sh/setup-bun@v2 + - name: Deploy Sanity Studio + run: | + cd ./apps/sanity + bun install + bun run sanity deploy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96fab4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +node_modules +.pnp +.pnp.js + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage + +# Turbo +.turbo + +# Vercel +.vercel + +# Build Outputs +.next/ +out/ +build +dist + + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..4e7126e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "printWidth": 120 +} diff --git a/apps/astro/.gitignore b/apps/astro/.gitignore new file mode 100644 index 0000000..16d54bb --- /dev/null +++ b/apps/astro/.gitignore @@ -0,0 +1,24 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/apps/astro/.vscode/extensions.json b/apps/astro/.vscode/extensions.json new file mode 100644 index 0000000..22a1505 --- /dev/null +++ b/apps/astro/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode"], + "unwantedRecommendations": [] +} diff --git a/apps/astro/.vscode/launch.json b/apps/astro/.vscode/launch.json new file mode 100644 index 0000000..d642209 --- /dev/null +++ b/apps/astro/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/apps/astro/astro.config.ts b/apps/astro/astro.config.ts new file mode 100644 index 0000000..2ffb053 --- /dev/null +++ b/apps/astro/astro.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "astro/config"; +import vercel from "@astrojs/vercel/serverless"; +import sitemap from "@astrojs/sitemap"; +import { DOMAIN } from "./src/global/constants"; +import { isPreviewDeployment } from "./src/utils/is-preview-deployment"; +import redirects from "./redirects"; + +export default defineConfig({ + site: DOMAIN, + integrations: [ + sitemap(), + ], + image: { + remotePatterns: [{ + protocol: "https", + hostname: "cdn.sanity.io" + }], + }, + prefetch: { + prefetchAll: true + }, + redirects: redirects, + output: isPreviewDeployment ? "server" : 'hybrid', + adapter: vercel(), +}); diff --git a/apps/astro/eslint.config.js b/apps/astro/eslint.config.js new file mode 100644 index 0000000..a279a37 --- /dev/null +++ b/apps/astro/eslint.config.js @@ -0,0 +1,10 @@ +import eslintPluginAstro from 'eslint-plugin-astro'; + +export default [ + ...eslintPluginAstro.configs.recommended, + { + rules: { + "no-unused-vars": "error", + } + } +]; diff --git a/apps/astro/package.json b/apps/astro/package.json new file mode 100644 index 0000000..585012e --- /dev/null +++ b/apps/astro/package.json @@ -0,0 +1,32 @@ +{ + "name": "astro-app", + "type": "module", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/check": "^0.9.4", + "@astrojs/sitemap": "^3.2.0", + "@astrojs/vercel": "^7.8.1", + "@sanity/client": "^6.22.1", + "astro": "^4.16.3", + "astro-portabletext": "^0.10.0", + "autoprefixer": "^10.4.20", + "cssnano": "^7.0.6", + "sharp-ico": "^0.1.5", + "typescript": "^5.6.3" + }, + "devDependencies": { + "@typescript-eslint/parser": "^8.8.1", + "eslint": "^9.12.0", + "eslint-plugin-astro": "^1.2.4", + "eslint-plugin-jsx-a11y": "^6.10.0", + "sass": "^1.79.5" + } +} diff --git a/apps/astro/postcss.config.cjs b/apps/astro/postcss.config.cjs new file mode 100644 index 0000000..c3dde52 --- /dev/null +++ b/apps/astro/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: [ + require('autoprefixer'), + require('cssnano'), + ], +}; diff --git a/apps/astro/public/fonts/Poppins-Regular.eot b/apps/astro/public/fonts/Poppins-Regular.eot new file mode 100644 index 0000000..4132b0e Binary files /dev/null and b/apps/astro/public/fonts/Poppins-Regular.eot differ diff --git a/apps/astro/public/fonts/Poppins-Regular.ttf b/apps/astro/public/fonts/Poppins-Regular.ttf new file mode 100644 index 0000000..7702714 Binary files /dev/null and b/apps/astro/public/fonts/Poppins-Regular.ttf differ diff --git a/apps/astro/public/fonts/Poppins-Regular.woff b/apps/astro/public/fonts/Poppins-Regular.woff new file mode 100644 index 0000000..d157296 Binary files /dev/null and b/apps/astro/public/fonts/Poppins-Regular.woff differ diff --git a/apps/astro/public/fonts/Poppins-Regular.woff2 b/apps/astro/public/fonts/Poppins-Regular.woff2 new file mode 100644 index 0000000..dd2881d Binary files /dev/null and b/apps/astro/public/fonts/Poppins-Regular.woff2 differ diff --git a/apps/astro/redirects.ts b/apps/astro/redirects.ts new file mode 100644 index 0000000..1631b30 --- /dev/null +++ b/apps/astro/redirects.ts @@ -0,0 +1,31 @@ +import type { ValidRedirectStatus } from 'astro'; +import sanityFetch from './src/utils/sanity.fetch' + +type RedirectData = { + source: string; + destination: string; + isPermanent: boolean; +}; + +const data = await sanityFetch({ + query: ` + *[_type == "redirects"][0].redirects { + "source": source.current, + "destination": destination.current, + isPermanent, + }[] + ` +}); +const redirects = data ? Object.fromEntries( + data.map(({ source, destination, isPermanent }) => [ + source, { + status: (isPermanent ? 301 : 302) as ValidRedirectStatus, + destination + } + ]) +) : {}; +const permanentRedirects = data ? data.filter(r => r.isPermanent).length : 0; +const temporaryRedirects = data ? data.length - permanentRedirects : 0; +console.log('\x1b[32m%s\x1b[0m', `✅ \x1b[1m${Object.keys(redirects).length}\x1b[0m\x1b[32m redirects added from Sanity (\x1b[1m${permanentRedirects}\x1b[0m\x1b[32m permanent and \x1b[1m${temporaryRedirects}\x1b[0m\x1b[32m temporary)`); + +export default redirects; diff --git a/apps/astro/src/assets/favicon.svg b/apps/astro/src/assets/favicon.svg new file mode 100644 index 0000000..9efdfa9 --- /dev/null +++ b/apps/astro/src/assets/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/astro/src/assets/icon.png b/apps/astro/src/assets/icon.png new file mode 100644 index 0000000..7c2caef Binary files /dev/null and b/apps/astro/src/assets/icon.png differ diff --git a/apps/astro/src/components/Components.astro b/apps/astro/src/components/Components.astro new file mode 100644 index 0000000..196b1b9 --- /dev/null +++ b/apps/astro/src/components/Components.astro @@ -0,0 +1,41 @@ +--- +import type { ComponentProps } from 'astro/types' +import FullWidthPhoto, { FullWidthPhoto_Query } from '@/components/global/FullWidthPhoto.astro' + +const components = { + FullWidthPhoto, +} + +type ComponentsMap = { + [Component in keyof typeof components]: { + _type: Component + } & ComponentProps<(typeof components)[Component]> +} + +export type ComponentsProps = Array + +type Props = { + data: ComponentsProps + indexStart?: number +} + +const { data, indexStart = 0 } = Astro.props + +export const Components_Query = /* groq */ ` + components[] { + _type, + sectionId, + _type == "FullWidthPhoto" => ${FullWidthPhoto_Query} + }, +` +--- + +{ + data?.map((item, i) => { + // NOTE: Using 'as any' is not ideal for type safety, but it's used here to simplify + // the implementation and avoid creating separate typed interfaces for each component. + const DynamicComponent = components[item._type] as any + if (!DynamicComponent) return null + return + }) +} diff --git a/apps/astro/src/components/global/FullWidthPhoto.astro b/apps/astro/src/components/global/FullWidthPhoto.astro new file mode 100644 index 0000000..37c5777 --- /dev/null +++ b/apps/astro/src/components/global/FullWidthPhoto.astro @@ -0,0 +1,47 @@ +--- +import Image, { ImageDataQuery, type ImageDataProps } from '@/components/ui/image' + +export const FullWidthPhoto_Query = ` + { + ${ImageDataQuery('img')} + }, +` + +type Props = { + index: number + sectionId?: string + img: ImageDataProps +} + +const { index, sectionId, img } = Astro.props +--- + +
+ +
+ + diff --git a/apps/astro/src/components/ui/image/index.astro b/apps/astro/src/components/ui/image/index.astro new file mode 100644 index 0000000..acec06c --- /dev/null +++ b/apps/astro/src/components/ui/image/index.astro @@ -0,0 +1,51 @@ +--- +import type { ComponentProps } from 'astro/types' +import { Image as AstroImage } from 'astro:assets' + +export type ImageDataProps = { + asset: { + url: string + altText: string + extension: string + metadata: { + dimensions: { + width: number + height: number + } + lqip: string + } + } +} + +type Props = ImageDataProps & { + sizes: string + priority?: boolean +} & Omit, 'src' | 'alt' | 'width' | 'height'> + +const { asset, sizes, priority, ...props } = Astro.props + +const imageProps = { + src: asset.url, + alt: asset.altText || '', + width: asset.metadata.dimensions.width, + height: asset.metadata.dimensions.height, + sizes, + style: { + background: `url(${asset.metadata.lqip}) center / cover no-repeat`, + }, + onload: 'this.removeAttribute("style")', + ...(priority && { + loading: 'eager', + fetchpriority: 'high', + }), + widths: [48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840], + ...(asset.extension === 'svg' && { + format: 'svg' as const, + widths: undefined, + sizes: undefined, + }), + ...props, +} +--- + +} /> diff --git a/apps/astro/src/components/ui/image/index.ts b/apps/astro/src/components/ui/image/index.ts new file mode 100644 index 0000000..d6f37c3 --- /dev/null +++ b/apps/astro/src/components/ui/image/index.ts @@ -0,0 +1,18 @@ +export { default, type ImageDataProps } from './index.astro'; + +export const ImageDataQuery = (name: string) => ` + ${name} { + asset -> { + url, + altText, + extension, + metadata { + dimensions { + width, + height, + }, + lqip, + }, + }, + }, +` diff --git a/apps/astro/src/components/ui/portable-text/Block.astro b/apps/astro/src/components/ui/portable-text/Block.astro new file mode 100644 index 0000000..4fa864c --- /dev/null +++ b/apps/astro/src/components/ui/portable-text/Block.astro @@ -0,0 +1 @@ + diff --git a/apps/astro/src/components/ui/portable-text/Mark.astro b/apps/astro/src/components/ui/portable-text/Mark.astro new file mode 100644 index 0000000..2a14398 --- /dev/null +++ b/apps/astro/src/components/ui/portable-text/Mark.astro @@ -0,0 +1,26 @@ +--- +import type { Props as $, Mark as MarkType } from 'astro-portabletext/types' +import { Mark as PortableTextMark } from 'astro-portabletext/components' + +export type Props = $> + +const { node, ...props } = Astro.props +--- + +{ + node.markType === 'link' ? ( + ).markDef.href} + {...((node as MarkType<{ type: 'external' | 'internal' }>).markDef.type === 'external' && { + target: '_blank', + rel: 'noreferrer', + })} + {...props}> + + + ) : ( + + + + ) +} diff --git a/apps/astro/src/components/ui/portable-text/index.astro b/apps/astro/src/components/ui/portable-text/index.astro new file mode 100644 index 0000000..324857d --- /dev/null +++ b/apps/astro/src/components/ui/portable-text/index.astro @@ -0,0 +1,36 @@ +--- +import type { HTMLAttributes } from 'astro/types' +import { PortableText } from 'astro-portabletext' +import type { PortableTextProps } from 'astro-portabletext/types' +import Mark from './Mark.astro' +import Block from './Block.astro' + +export type PortableTextValue = PortableTextProps['value'] + +type Props = { + value: PortableTextValue + heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' +} & HTMLAttributes<'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'> + +const { heading: HeadingTag, value, ...props } = Astro.props + +const valueArray = Array.isArray(value) ? value : [value] +--- + +{ + HeadingTag ? ( + + {valueArray.map((value: PortableTextValue, index: number) => + index > 0 ? ( + {} + ) : ( + + ) + )} + + ) : ( +
+ +
+ ) +} diff --git a/apps/astro/src/components/ui/portable-text/index.ts b/apps/astro/src/components/ui/portable-text/index.ts new file mode 100644 index 0000000..e53fa36 --- /dev/null +++ b/apps/astro/src/components/ui/portable-text/index.ts @@ -0,0 +1,15 @@ +export { default, type PortableTextValue } from './index.astro'; + +export const PortableTextQuery = (name: string) => ` + ${name}[] { + ..., + markDefs[] { + _type == "link" => { + _type, + _key, + type, + "href": select(type == "internal" => internal -> slug.current, type == "external" => external, "#"), + }, + }, + }, +` diff --git a/apps/astro/src/env.d.ts b/apps/astro/src/env.d.ts new file mode 100644 index 0000000..164b7d1 --- /dev/null +++ b/apps/astro/src/env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly SANITY_API_TOKEN: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/astro/src/global/constants.ts b/apps/astro/src/global/constants.ts new file mode 100644 index 0000000..e81d9e3 --- /dev/null +++ b/apps/astro/src/global/constants.ts @@ -0,0 +1,70 @@ + +/** + * Global declaration of theme color in HEX format. + * This color is used for theming purposes across the application. + * @constant + * @type {string} + */ +export const THEME_COLOR: string = "#011310"; + +/** + * Global declaration of background color in HEX format. + * This color is used for the background across the application. + * @constant + * @type {string} + */ +export const BACKGROUND_COLOR: string = "#000103"; + +/** + * Global declaration of the locale (language) for the application. + * This constant is used to set the language attribute in the HTML tag. + * @constant + * @type {string} + */ +export const LOCALE: string = "pl"; + +/** + * Global declaration of the domain for the application. + * This constant is used for constructing full URLs and determining external links. + * @constant + * @type {string} + */ +export const DOMAIN: string = "https://kryptonum.eu"; + +/** + * Global declaration of the default title for the application. + * This constant is used as a fallback title when a specific page title is not provided. + * @constant + * @type {string} + */ +export const DEFAULT_TITLE: string = "Kryptonum"; + +/** + * Global declaration of the default description for the application. + * This constant is used as a fallback description when a specific page description is not provided. + * It's typically used in meta tags for SEO purposes. + * @constant + * @type {string} + */ +export const DEFAULT_DESCRIPTION: string = "Kryptonum tworzy nieszablonowe projekty tym, którym zależy na: 👨🏻‍💻 profesjonalnej stronie, 🎨 unikatowym brandingu, 💰 dochodowym biznesie online."; + +/** + * Object containing regular expressions for validation purposes. + * @constant + * @type {Object} + * @property {RegExp} email - Regular expression for validating email addresses. + * @property {RegExp} phone - Regular expression for validating phone numbers. + * @property {RegExp} string - Regular expression for trimming and validating strings. + */ +export const REGEX: { email: RegExp; phone: RegExp; string: RegExp } = { + email: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, + phone: /^(?:\+(?:\d{1,3}))?(?:[ -]?\(?\d{1,4}\)?[ -]?\d{1,5}[ -]?\d{1,5}[ -]?\d{1,6})$/, + string: /^(?!\s+$)(.*?)\s*$/, +}; + +/** + * Global declaration of the easing function for JS animations. + * @constant + * @type {number[]} + */ +export const EASING: number[] = [0.6, -0.15, 0.27, 1.15]; diff --git a/apps/astro/src/global/global.scss b/apps/astro/src/global/global.scss new file mode 100644 index 0000000..96e834c --- /dev/null +++ b/apps/astro/src/global/global.scss @@ -0,0 +1,237 @@ +@font-face { + font-family: 'Poppins'; + src: + url('/fonts/Poppins-Regular.woff2') format('woff2'), + url('/fonts/Poppins-Regular.woff') format('woff'), + url('/fonts/Poppins-Regular.ttf') format('truetype'), + url('/fonts/Poppins-Regular.eot') format('embedded-opentype'); + font-weight: 400; + font-display: swap; + font-style: normal; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --gap: clamp(96px, calc(132vw / 7.68), 152px); + + --pageMargin: clamp(16px, calc(40vw / 7.68), 40px); + @media (max-width: 899px) and (min-width: 600px) { + --pageMargin: clamp(40px, calc(80vw / 7.68), 80px); + } +} + +svg { + max-width: 100%; + display: block; + height: auto; +} +canvas { + touch-action: pan-y; +} +a { + text-decoration: none; + color: inherit; +} +label { + display: block; +} +button { + border: none; + background-color: transparent; + cursor: pointer; + user-select: none; +} +::placeholder { + color: inherit; +} +sup { + font-size: 0.62em; + vertical-align: top; +} +textarea { + display: block; +} +input, +textarea, +button, +select { + font: inherit; + color: inherit; + background-color: transparent; + appearance: none; + border: none; + border-radius: 0; +} +ul, +ol { + list-style-type: none; +} +summary { + cursor: pointer; + list-style: none; + &::marker, + &::-webkit-details-marker { + display: none; + } +} +iframe { + border: none; + display: block; +} +video { + display: block; + max-width: 100%; + height: auto; +} +picture, +img { + display: inline-block; + max-width: 100%; + height: auto; + vertical-align: bottom; + object-fit: cover; +} + +:focus { + outline: none; +} +:focus-visible { + outline: 2px solid var(--primary-800, #01403b); + outline-offset: 3px; +} + +html, +body { + overflow-x: clip; +} +html { + scroll-behavior: smooth; + scroll-padding-top: 123px; +} +body { + overflow: clip; + min-width: 320px; + -webkit-tap-highlight-color: transparent; + background: var(--background-100, #fffcf9); + color: var(--primary-900, #001b19); + font-family: 'Poppins', sans-serif; + font-size: 1rem; + line-height: 158%; +} + +main, +.max-width { + max-width: 1200px; + width: calc(100% - var(--pageMargin) * 2); + margin: 0 auto; + height: 100%; +} +main { + display: grid; + row-gap: var(--gap); + margin: clamp(32px, calc(80vw / 7.68), 80px) auto var(--gap); +} + +h1, +.h1, +h2, +.h2, +h3, +.h3, +h4, +.h4, +h5, +.h5, +h6, +.h6 { + overflow-wrap: anywhere; + font-weight: 400; + line-height: 128%; + color: var(--primary-800, #01403b); + strong { + font-weight: 400; + color: var(--primary-900, #001b19); + } +} +h1, +.h1, +h2, +.h2 { + font-size: clamp(calc(28rem / 16), calc(42vw / 7.68), calc(42rem / 16)); +} +h3, +.h3 { + font-size: clamp(calc(18rem / 16), calc(24vw / 7.68), calc(24rem / 16)); +} + +.link { + line-height: normal; + text-decoration: underline; + transition: color 0.5s; + text-underline-offset: 5px; + &:hover { + color: var(--primary-700); + &::after { + transform: translate(1px, -1px); + } + } + &[target='_blank'] { + &::after { + content: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNyIgZmlsbD0ibm9uZSI+PHBhdGggc3Ryb2tlPSIjMUNCNkFBIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Im00LjMzMyAxMS43ODggNy4zMzQtNy4zMzNtMCAwaC02bTYgMHY2Ii8+PC9zdmc+'); + display: inline-block; + margin-left: 4px; + vertical-align: middle; + transition: transform 0.3s var(--easing); + } + } +} + +.sec-wo-margin { + margin: 0 calc(var(--pageMargin) * -1); + @media (min-width: 1280px) { + margin: 0 calc((100vw - 1200px) / -2); + } +} + +.ordered-list, +.unordered-list { + display: grid; + gap: 8px; +} +.unordered-list { + li { + padding-left: 18px; + position: relative; + &::before { + content: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMCIgaGVpZ2h0PSIxMyIgZmlsbD0iIzBGOTI4OCI+PHBhdGggZD0iTTUgLjgwMmMtLjM2NSAwLS42NDcuMTUyLS45MDQuMzg1LS4yMzUuMjE0LS40NzcuNTIzLS43NjIuODg2bC0uMDIuMDI1Yy0uNTgzLjc0My0xLjI0OCAxLjY2My0xLjc2OSAyLjU4NC0uNTEyLjkwNy0uOTIgMS44NzMtLjkyIDIuNjkzIDAgMi41NTggMS45MzMgNC42OCA0LjM3NSA0LjY4czQuMzc1LTIuMTIyIDQuMzc1LTQuNjhjMC0uODItLjQwOC0xLjc4Ni0uOTItMi42OTMtLjUyMS0uOTIxLTEuMTg2LTEuODQtMS43Ny0yLjU4NGwtLjAxOS0uMDI1Yy0uMjg1LS4zNjMtLjUyNy0uNjcyLS43NjItLjg4NkM1LjY0Ny45NTQgNS4zNjQuODAyIDUgLjgwMloiLz48L3N2Zz4='); + width: 10px; + position: absolute; + left: 0; + top: 1px; + } + } +} +.ordered-list { + list-style-type: decimal; + padding-left: 1.5rem; +} + +.cta-wrapper { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: clamp(12px, calc(16vw / 7.68), 16px); +} + +div.paragraph { + & > *:not(:last-child) { + margin-bottom: 8px; + } +} diff --git a/apps/astro/src/layouts/Head.astro b/apps/astro/src/layouts/Head.astro new file mode 100644 index 0000000..bad05fa --- /dev/null +++ b/apps/astro/src/layouts/Head.astro @@ -0,0 +1,72 @@ +--- +import { getImage } from 'astro:assets' +import icon from '@/assets/icon.png' +import faviconSvg from '@/assets/favicon.svg' +import { DOMAIN, LOCALE, THEME_COLOR } from '@/global/constants' +import sanityFetch from '@/utils/sanity.fetch' + +const icons = { + favicon: await getImage({ src: faviconSvg, format: 'svg' }), + appleTouchIcon: await getImage({ src: icon, width: 180, height: 180, format: 'png' }), +} + +export type OpenGraphImageProps = { + url: string + height: string +} + +const seo = await sanityFetch<{ openGraphImage: OpenGraphImageProps }>({ + query: ` + *[_type == "global"][0].seo { + "openGraphImage": { + "url": img.asset -> url + "?w=1200", + "height": round(1200 / img.asset -> metadata.dimensions.aspectRatio), + }, + } + `, +}) + +export type Props = { + path: string + title: string + description: string + openGraphImage?: OpenGraphImageProps +} + +const { path, title, description, openGraphImage } = Astro.props + +const OpenGraphImage = { + url: openGraphImage?.url || seo.openGraphImage?.url, + height: openGraphImage?.height || seo.openGraphImage?.height, +} + +const url = `${DOMAIN}${path}` +--- + + +{title} + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/astro/src/layouts/Layout.astro b/apps/astro/src/layouts/Layout.astro new file mode 100644 index 0000000..e16dc61 --- /dev/null +++ b/apps/astro/src/layouts/Layout.astro @@ -0,0 +1,19 @@ +--- +import '@/global/global.scss' +import { LOCALE } from '../global/constants' +import Head, { type Props as HeadProps } from './Head.astro' + +type Props = HeadProps +--- + + + + + + + +
+ +
+ + diff --git a/apps/astro/src/pages/favicon.ico.ts b/apps/astro/src/pages/favicon.ico.ts new file mode 100644 index 0000000..7a5f1e3 --- /dev/null +++ b/apps/astro/src/pages/favicon.ico.ts @@ -0,0 +1,17 @@ +export const prerender = true; + +import type { APIRoute } from "astro"; +import path from "node:path"; +import sharp from "sharp"; +import ico from "sharp-ico"; + +const favicon = path.resolve("src/assets/favicon.svg"); + +export const GET: APIRoute = async () => { + const buffer = await sharp(favicon).resize(32).toBuffer(); + const icoBuffer = ico.encode([buffer]); + + return new Response(icoBuffer, { + headers: { "Content-Type": "image/x-icon" }, + }); +}; diff --git a/apps/astro/src/pages/index.astro b/apps/astro/src/pages/index.astro new file mode 100644 index 0000000..68ed073 --- /dev/null +++ b/apps/astro/src/pages/index.astro @@ -0,0 +1,10 @@ +--- +import Layout from '@/src/layouts/Layout.astro' +import metadataFetch from '@/utils/metadata.fetch' + +const metadata = await metadataFetch('Index_Page') +--- + + +

Index Page

+
diff --git a/apps/astro/src/pages/manifest.webmanifest.ts b/apps/astro/src/pages/manifest.webmanifest.ts new file mode 100644 index 0000000..a847ff6 --- /dev/null +++ b/apps/astro/src/pages/manifest.webmanifest.ts @@ -0,0 +1,39 @@ +import type { APIRoute } from "astro"; +import { getImage } from "astro:assets"; +import icon from "@/assets/icon.png"; +import { BACKGROUND_COLOR, DEFAULT_DESCRIPTION, DEFAULT_TITLE, THEME_COLOR } from "@/global/constants"; + +const sizes = [192, 512]; + +export const GET: APIRoute = async () => { + const icons = await Promise.all( + sizes.map(async size => { + const { src, options: { format, width, height } } = await getImage({ + src: icon, + width: size, + height: size, + format: "png", + }); + return { + src: src, + type: `image/${format}`, + sizes: `${width}x${height}`, + }; + }) + ); + + const manifest = JSON.stringify({ + start_url: "/", + display: "standalone", + name: DEFAULT_TITLE, + short_name: DEFAULT_TITLE, + description: DEFAULT_DESCRIPTION, + background_color: BACKGROUND_COLOR, + theme_color: THEME_COLOR, + icons, + }); + + return new Response(manifest, { + headers: { "Content-Type": "application/manifest+json" }, + }); +}; diff --git a/apps/astro/src/utils/is-preview-deployment.ts b/apps/astro/src/utils/is-preview-deployment.ts new file mode 100644 index 0000000..8882e59 --- /dev/null +++ b/apps/astro/src/utils/is-preview-deployment.ts @@ -0,0 +1 @@ +export const isPreviewDeployment = process.env.VERCEL_ENV === "preview" || process.env.NODE_ENV !== "production"; diff --git a/apps/astro/src/utils/metadata.fetch.ts b/apps/astro/src/utils/metadata.fetch.ts new file mode 100644 index 0000000..a73cc23 --- /dev/null +++ b/apps/astro/src/utils/metadata.fetch.ts @@ -0,0 +1,28 @@ + +import type { Props } from "@/src/layouts/Head.astro"; +import sanityFetch from "@/utils/sanity.fetch"; + +export default async function metadataFetch(type: string, slug?: string): Promise { + const filter = slug + ? `*[_type == '${type}' && slug.current == $slug][0]` + : `*[_type == "${type}"][0]`; + + const seo = await sanityFetch({ + query: /* groq */ ` + ${filter} { + "path": slug.current, + "title": seo.title, + "description": seo.description, + "openGraphImage": { + "url": seo.img.asset -> url + "?w=1200", + "height": round(1200 / seo.img.asset -> metadata.dimensions.aspectRatio), + }, + } + `, + ...(slug && { params: { slug: slug } }), + }); + if (!seo?.path) throw new Error(`The path for '${type}' is not specified`); + if (!seo?.title) throw new Error(`The title for '${type}' is not specified`); + if (!seo?.description) throw new Error(`The description for '${type}' is not specified`); + return seo; +} diff --git a/apps/astro/src/utils/sanity.fetch.ts b/apps/astro/src/utils/sanity.fetch.ts new file mode 100644 index 0000000..91ecfa0 --- /dev/null +++ b/apps/astro/src/utils/sanity.fetch.ts @@ -0,0 +1,28 @@ +import { createClient, type QueryParams } from '@sanity/client' +import { isPreviewDeployment } from './is-preview-deployment'; +import { loadEnv } from "vite"; + +const { SANITY_API_TOKEN } = loadEnv(process.env.SANITY_API_TOKEN!, process.cwd(), ""); + +if (isPreviewDeployment && !SANITY_API_TOKEN) { + throw new Error("The `SANITY_API_TOKEN` environment variable is required."); +} + +export const client = createClient({ + projectId: 'k3p1raj0', + dataset: 'production', + apiVersion: '2024-08-30', + useCdn: false, + perspective: isPreviewDeployment ? 'previewDrafts' : 'published', + ...(isPreviewDeployment && { token: SANITY_API_TOKEN }), +}) + +export default async function sanityFetch({ + query, + params = {}, +}: { + query: string; + params?: QueryParams; +}): Promise { + return await client.fetch(query, params); +} diff --git a/apps/astro/tsconfig.json b/apps/astro/tsconfig.json new file mode 100644 index 0000000..235e379 --- /dev/null +++ b/apps/astro/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "verbatimModuleSyntax": true, + "baseUrl": ".", + "paths": { + "@/src/*": ["./src/*"], + "@/global/*": ["./src/global/*"], + "@/components/*": ["./src/components/*"], + "@/utils/*": ["./src/utils/*"], + "@/assets/*": ["./src/assets/*"], + "@/public/*": ["./public/*"] + } + } +} diff --git a/apps/sanity/.eslintrc b/apps/sanity/.eslintrc new file mode 100644 index 0000000..af05325 --- /dev/null +++ b/apps/sanity/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "@sanity/eslint-config-studio", + "rules": { + "@typescript-eslint/no-unused-vars": "error" + } +} diff --git a/apps/sanity/.gitignore b/apps/sanity/.gitignore new file mode 100644 index 0000000..aa9909c --- /dev/null +++ b/apps/sanity/.gitignore @@ -0,0 +1,29 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +/node_modules +/.pnp +.pnp.js + +# Compiled Sanity Studio +/dist + +# Temporary Sanity runtime, generated by the CLI on every dev server start +/.sanity + +# Logs +/logs +*.log + +# Coverage directory used by testing tools +/coverage + +# Misc +.DS_Store +*.pem + +# Typescript +*.tsbuildinfo + +# Dotenv and similar local-only files +*.local diff --git a/apps/sanity/constants.ts b/apps/sanity/constants.ts new file mode 100644 index 0000000..f11065f --- /dev/null +++ b/apps/sanity/constants.ts @@ -0,0 +1,18 @@ + +/** + * Global declaration of the domain for the application. + * This constant is used for constructing full URLs and determining external links. + * @constant + * @type {string} + */ +export const DOMAIN: string = "https://kryptonum.eu"; + +/** + * The domain used for preview deployments. + * This constant is utilized to generate URLs for preview environments, + * allowing content to be reviewed before it's published to the main site. + * @constant + * @type {string} + */ +export const PREVIEW_DEPLOYMENT_DOMAIN: string = process.env.SANITY_STUDIO_PREVIEW_DOMAIN ?? ""; + diff --git a/apps/sanity/env.d.ts b/apps/sanity/env.d.ts new file mode 100644 index 0000000..ef3c5c9 --- /dev/null +++ b/apps/sanity/env.d.ts @@ -0,0 +1,5 @@ +/// + +interface ImportMetaEnv { + SANITY_STUDIO_PREVIEW_DOMAIN: string; +} diff --git a/apps/sanity/package.json b/apps/sanity/package.json new file mode 100644 index 0000000..1e3b846 --- /dev/null +++ b/apps/sanity/package.json @@ -0,0 +1,33 @@ +{ + "name": "sanity-app", + "private": true, + "version": "1.0.0", + "main": "package.json", + "license": "UNLICENSED", + "scripts": { + "dev": "sanity dev", + "start": "sanity start", + "build": "sanity build", + "deploy": "sanity deploy", + "deploy-graphql": "sanity graphql deploy" + }, + "keywords": [ + "sanity" + ], + "dependencies": { + "@sanity/vision": "^3.60.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sanity": "^3.60.0", + "sanity-plugin-iframe-pane": "^3.1.6", + "sanity-plugin-media": "^2.3.2", + "styled-components": "^6.1.13" + }, + "devDependencies": { + "@sanity/eslint-config-studio": "^4.0.0", + "@types/react": "^18.3.11", + "eslint": "^9.12.0", + "prettier": "^3.3.3", + "typescript": "^5.6.3" + } +} diff --git a/apps/sanity/sanity-typegen.json b/apps/sanity/sanity-typegen.json new file mode 100644 index 0000000..fcd6e42 --- /dev/null +++ b/apps/sanity/sanity-typegen.json @@ -0,0 +1,4 @@ +{ + "schema": "schema.json", + "generates": "../astro/sanity.types.ts" +} diff --git a/apps/sanity/sanity.cli.ts b/apps/sanity/sanity.cli.ts new file mode 100644 index 0000000..df20bac --- /dev/null +++ b/apps/sanity/sanity.cli.ts @@ -0,0 +1,9 @@ +import { defineCliConfig } from 'sanity/cli' + +export default defineCliConfig({ + api: { + projectId: 'k3p1raj0', + dataset: 'production' + }, + studioHost: 'kryptonum' +}) diff --git a/apps/sanity/sanity.config.ts b/apps/sanity/sanity.config.ts new file mode 100644 index 0000000..2e87511 --- /dev/null +++ b/apps/sanity/sanity.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'sanity' +import { structure } from './structure' +import { schemaTypes, singletonActions, singletonTypes } from './structure/schema-types' +import { structureTool } from 'sanity/structure' +import { media } from 'sanity-plugin-media' +import { visionTool } from '@sanity/vision' +import { showProductionUrl } from './utils/show-production-url' + +export default defineConfig({ + name: 'default', + title: 'kryptonum-eu', + + projectId: 'k3p1raj0', + dataset: 'production', + + plugins: [ + structureTool({ structure }), + media(), + visionTool(), + showProductionUrl(), + ], + + schema: { + types: schemaTypes, + templates: (templates) => + templates.filter(({ schemaType }) => !singletonTypes.has(schemaType)), + }, + + document: { + actions: (input, context) => + singletonTypes.has(context.schemaType) + ? input.filter(({ action }) => action && singletonActions.has(action)) + : input, + }, +}) diff --git a/apps/sanity/schema/Components.ts b/apps/sanity/schema/Components.ts new file mode 100644 index 0000000..08cd79a --- /dev/null +++ b/apps/sanity/schema/Components.ts @@ -0,0 +1,20 @@ +import { defineType } from "sanity"; + +export default defineType({ + name: 'components', + type: 'array', + title: 'Components', + of: [ + + ], + options: { + insertMenu: { + filter: true, + showIcons: true, + views: [ + { name: 'grid', previewImageUrl: (schemaTypeName) => `/static/${schemaTypeName}.webp` }, + { name: 'list' }, + ] + } + } +}); diff --git a/apps/sanity/schema/collectionTypes/Faq_Collection.ts b/apps/sanity/schema/collectionTypes/Faq_Collection.ts new file mode 100644 index 0000000..d5c6081 --- /dev/null +++ b/apps/sanity/schema/collectionTypes/Faq_Collection.ts @@ -0,0 +1,37 @@ +import { defineField, defineType } from "sanity"; +import { toPlainText } from "../../utils/to-plain-text"; + +const title = 'Zbiór elementów FAQ'; +const icon = () => '❓'; + +export default defineType({ + name: 'Faq_Collection', + type: 'document', + title, + icon, + fields: [ + defineField({ + name: 'question', + type: 'Heading', + title: 'Pytanie', + validation: Rule => Rule.required(), + }), + defineField({ + name: 'answer', + type: 'PortableText', + title: 'Odpowiedź', + validation: Rule => Rule.required(), + }), + ], + preview: { + select: { + title: 'question', + subtitle: 'answer', + }, + prepare: ({ title, subtitle }) => ({ + title: toPlainText(title), + subtitle: toPlainText(subtitle), + icon, + }), + }, +}); diff --git a/apps/sanity/schema/components/FullWidthPhoto.ts b/apps/sanity/schema/components/FullWidthPhoto.ts new file mode 100644 index 0000000..9f87ec6 --- /dev/null +++ b/apps/sanity/schema/components/FullWidthPhoto.ts @@ -0,0 +1,31 @@ +import { defineField } from 'sanity'; +import sectionId from '../ui/sectionId'; + +const name = 'FullWidthPhoto'; +const title = 'Section with a full-width photo'; +const icon = () => '🖼️'; + +export default defineField({ + name, + type: 'object', + title, + icon, + fields: [ + defineField({ + name: 'img', + type: 'image', + title: 'Image', + validation: Rule => Rule.required(), + }), + ...sectionId, + ], + preview: { + select: { + media: 'img', + }, + prepare: ({ media }) => ({ + title: title, + media, + }), + }, +}); diff --git a/apps/sanity/schema/singleTypes/Index_Page.ts b/apps/sanity/schema/singleTypes/Index_Page.ts new file mode 100644 index 0000000..693b9ec --- /dev/null +++ b/apps/sanity/schema/singleTypes/Index_Page.ts @@ -0,0 +1,39 @@ +import { defineField, defineType } from "sanity" +import { defineSlugForDocument } from "../../utils/define-slug-for-document"; + +const name = 'Index_Page'; +const title = 'Homepage'; +const slug = '/'; + +export default defineType({ + name: name, + type: 'document', + title: title, + icon: () => '🏠', + fields: [ + ...defineSlugForDocument({ slug: slug }), + defineField({ + name: 'components', + type: 'components', + title: 'Page Components', + }), + defineField({ + name: 'seo', + type: 'seo', + title: 'SEO', + group: 'seo', + }), + ], + groups: [ + { + name: 'seo', + title: 'SEO', + }, + ], + preview: { + prepare: () => ({ + title: title, + subtitle: slug + }) + } +}); diff --git a/apps/sanity/schema/singleTypes/global.tsx b/apps/sanity/schema/singleTypes/global.tsx new file mode 100644 index 0000000..4e99ef1 --- /dev/null +++ b/apps/sanity/schema/singleTypes/global.tsx @@ -0,0 +1,109 @@ +import { defineField, defineType } from 'sanity'; + +export default defineType({ + name: 'global', + type: 'document', + title: 'Global', + icon: () => '🌍', + fields: [ + defineField({ + name: 'email', + type: 'string', + title: 'Email', + validation: Rule => Rule.required().email(), + }), + defineField({ + name: 'tel', + type: 'string', + title: 'Phone number (optional)', + }), + defineField({ + name: 'socials', + type: 'object', + title: 'Social media', + options: { collapsible: true }, + fields: [ + defineField({ + name: 'instagram', + type: 'url', + title: 'Instagram', + validation: Rule => Rule.uri({ scheme: ['https'] }).error('Provide a valid URL (starting with https://)'), + }), + defineField({ + name: 'facebook', + type: 'url', + title: 'Facebook', + validation: Rule => Rule.uri({ scheme: ['https'] }).error('Provide a valid URL (starting with https://)'), + }), + defineField({ + name: 'tiktok', + type: 'url', + title: 'TikTok', + validation: Rule => Rule.uri({ scheme: ['https'] }).error('Provide a valid URL (starting with https://)'), + }), + defineField({ + name: 'linkedin', + type: 'url', + title: 'LinkedIn', + validation: Rule => Rule.uri({ scheme: ['https'] }).error('Provide a valid URL (starting with https://)'), + }), + ], + }), + defineField({ + name: 'seo', + type: 'object', + title: 'Global SEO', + fields: [ + defineField({ + name: 'img', + type: 'image', + title: 'Social Share Image', + description: 'Social Share Image is visible when sharing website on social media. The dimensions of the image should be 1200x630px. For maximum compatibility, use JPG or PNG formats, as WebP may not be supported everywhere.', + validation: Rule => Rule.required() + }), + ], + validation: Rule => Rule.required(), + }), + defineField({ + name: 'OrganizationSchema', + type: 'object', + title: 'Organization structured data', + description: ( + <> + Learn more about{' '} + + Organization structured data + + + ), + options: { collapsible: true }, + fields: [ + defineField({ + name: 'name', + type: 'string', + title: 'Name', + description: 'Enter the name of your organization as you want it to appear in search results.', + validation: Rule => Rule.required(), + }), + defineField({ + name: 'description', + type: 'text', + rows: 3, + title: 'Description', + description: 'A brief description of your organization that will appear in search results.', + validation: Rule => Rule.required(), + }), + ], + }), + ], + preview: { + prepare: () => ({ + title: 'Global settings', + }) + } +}) + diff --git a/apps/sanity/schema/singleTypes/redirects.tsx b/apps/sanity/schema/singleTypes/redirects.tsx new file mode 100644 index 0000000..8780afa --- /dev/null +++ b/apps/sanity/schema/singleTypes/redirects.tsx @@ -0,0 +1,98 @@ +import { defineField, defineType, type SlugRule } from 'sanity'; +import { Box, Text, Tooltip } from '@sanity/ui'; + +type RedirectTypes = { + _key: string; + source: { current: string }; + destination: { current: string }; + isPermanent: boolean; +} + +const SlugValidation = (Rule: SlugRule) => Rule.custom((value) => { + if (!value || !value.current) return "The value can't be blank"; + if (!value.current.startsWith("/")) return "The path must be a relative path (starts with /)"; + return true; +}); + +export default defineType({ + name: 'redirects', + type: 'document', + title: 'Redirects', + description: 'Redirects are used to redirect users to a different page. This is useful for SEO purposes.', + icon: () => '🔀', + fields: [ + defineField({ + name: 'redirects', + type: 'array', + description: 'Redirects are used to redirect users to a different page. This is useful for SEO purposes. Remember about good practices for redirects as they can affect SEO.', + of: [ + defineField({ + name: 'redirect', + type: 'object', + fields: [ + defineField({ + name: 'source', + type: 'slug', + validation: Rule => [ + SlugValidation(Rule), + Rule.custom((value, context) => { + const redirects = (context.document?.redirects || []) as RedirectTypes[]; + const currentRedirect = context.parent as RedirectTypes + const isDuplicate = redirects.some(redirect => + redirect._key !== currentRedirect._key && redirect.source?.current === value?.current + ); + if (isDuplicate) return "This source path is already used in another redirect. Source paths must be unique."; + return true; + }) + ] + }), + defineField({ + name: 'destination', + type: 'slug', + validation: SlugValidation, + }), + defineField({ + name: 'isPermanent', + type: 'boolean', + initialValue: true, + }), + ], + preview: { + select: { + source: 'source.current', + destination: 'destination.current', + isPermanent: 'isPermanent', + }, + prepare({ source, destination, isPermanent }) { + return { + title: `Source: ${source}`, + subtitle: `Destination: ${destination}`, + media: () => + + {isPermanent ? '🔒 Permanent' : '🔄 Temporary'} + + + } + placement="top" + portal + > + + {isPermanent ? '🔒' : '🔄'} + + + } + } + }, + }) + ], + }) + ], + preview: { + prepare: () => ({ + title: 'Redirects', + }) + } +}) + diff --git a/apps/sanity/schema/ui/PortableText/CustomInput.tsx b/apps/sanity/schema/ui/PortableText/CustomInput.tsx new file mode 100644 index 0000000..15dde79 --- /dev/null +++ b/apps/sanity/schema/ui/PortableText/CustomInput.tsx @@ -0,0 +1,25 @@ +import type { PortableTextInputProps } from "sanity"; +import styled from "styled-components"; + +const Container = styled.div` + [data-testid='pt-editor'][data-fullscreen='false'] { + height: auto; + min-height: 88px; + [data-testid="scroll-container"] { + max-height: 610px; + } + .pt-editable { + padding-bottom: 12px; + } + } +` +export const CustomInput = (props: PortableTextInputProps) => { + return ( + + {props.renderDefault({ + initialActive: true, + ...props, + })} + + ); +}; diff --git a/apps/sanity/schema/ui/PortableText/Heading.tsx b/apps/sanity/schema/ui/PortableText/Heading.tsx new file mode 100644 index 0000000..e37c515 --- /dev/null +++ b/apps/sanity/schema/ui/PortableText/Heading.tsx @@ -0,0 +1,23 @@ +import { defineArrayMember, defineType } from "sanity"; +import { CustomInput } from "./CustomInput"; + +export default defineType({ + name: 'Heading', + type: 'array', + title: 'Heading', + components: { + // @ts-ignore + input: CustomInput + }, + of: [defineArrayMember({ + type: 'block', + styles: [{ title: 'Normal', value: 'normal' }], + lists: [], + marks: { + annotations: [], + decorators: [ + { title: 'Strong', value: 'strong' }, + ], + } + })], +}); diff --git a/apps/sanity/schema/ui/PortableText/index.tsx b/apps/sanity/schema/ui/PortableText/index.tsx new file mode 100644 index 0000000..ea526e9 --- /dev/null +++ b/apps/sanity/schema/ui/PortableText/index.tsx @@ -0,0 +1,97 @@ +import { defineField } from "sanity"; +import { CustomInput } from "./CustomInput"; +import { isValidUrl } from "../../../utils/is-valid-url"; +import { InternalLinkableTypes } from "../../../structure/internal-linkable-types"; + +export const PortableText = ({ name, title, allowHeadings = false }: { name?: string, title?: string, allowHeadings?: boolean }) => defineField({ + name: name || 'PortableText', + type: 'array', + title: title || 'Portable Text', + components: { + // @ts-ignore + input: CustomInput + }, + of: [{ + type: 'block', + styles: [ + { title: 'Normal', value: 'normal' }, + ...(allowHeadings ? [ + { title: 'Heading 2', value: 'h2' }, + { title: 'Heading 3', value: 'h3' } + ] : []) + ], + lists: [ + { title: 'Bullet', value: 'bullet' }, + { title: 'Numbered', value: 'number' } + ], + marks: { + decorators: [ + { title: 'Strong', value: 'strong' }, + { title: 'Emphasis', value: 'em' } + ], + annotations: [ + defineField({ + name: 'link', + type: 'object', + title: 'Link', + icon: () => '🔗', + fields: [ + defineField({ + name: 'type', + type: 'string', + title: 'Type', + description: 'Choose "External" for links to websites outside your domain, or "Internal" for links to pages within your site.', + options: { + list: ['external', 'internal'], + layout: 'radio', + direction: 'horizontal', + }, + initialValue: 'external', + }), + defineField({ + name: 'external', + type: 'string', + title: 'URL', + description: 'Specify the full URL. Ensure it starts with "https://", "mailto:" or "tel:" protocol.', + hidden: ({ parent }) => parent?.type !== 'external', + validation: (Rule) => [ + Rule.custom((value, { parent }) => { + const type = (parent as { type?: string })?.type; + if (type === 'external') { + if (!value) return "URL is required"; + if (!value.startsWith('https://') && !value.startsWith('mailto:') && !value.startsWith('tel:')) { + return 'External link must start with the "https://", "mailto:" or "tel:" protocol'; + } + if (!isValidUrl(value)) return 'Invalid URL'; + } + return true; + }), + ], + }), + defineField({ + name: 'internal', + type: 'reference', + title: 'Internal reference to page', + description: 'Select an internal page to link to.', + to: InternalLinkableTypes, + options: { + disableNew: true, + filter: 'defined(slug.current)', + }, + hidden: ({ parent }) => parent?.type !== 'internal', + validation: (rule) => [ + rule.custom((value, { parent }) => { + const type = (parent as { type?: string })?.type; + if (type === 'internal' && !value?._ref) return "You have to choose internal page to link to."; + return true; + }), + ], + }), + ] + }), + ] + } + }], +}); + +export default PortableText({}); diff --git a/apps/sanity/schema/ui/cta.tsx b/apps/sanity/schema/ui/cta.tsx new file mode 100644 index 0000000..8d8f48d --- /dev/null +++ b/apps/sanity/schema/ui/cta.tsx @@ -0,0 +1,117 @@ +import { defineField, defineType } from "sanity" +import { Tooltip, Box, Text, } from '@sanity/ui'; +import { isValidUrl } from "../../utils/is-valid-url"; +import { InternalLinkableTypes } from "../../structure/internal-linkable-types"; + +const name = 'cta'; +const title = 'Call to action'; +const icon = () => '🗣️'; + +export default defineType({ + name, + type: 'object', + title, + icon, + fields: [ + defineField({ + name: 'text', + type: 'string', + title: 'Text', + description: 'The text that will be displayed on the button.', + validation: Rule => Rule.required(), + }), + defineField({ + name: 'theme', + type: 'string', + title: 'Theme', + description: 'Theme is used to style the button. Choose "Primary" for the main call to action, or "Secondary" for less important actions.', + options: { + list: ['primary', 'secondary'], + layout: 'radio', + direction: 'horizontal', + }, + initialValue: 'primary', + validation: Rule => Rule.required(), + }), + defineField({ + name: 'type', + type: 'string', + title: 'Type', + description: 'Choose "External" for links to websites outside your domain, or "Internal" for links to pages within your site.', + options: { + list: ['external', 'internal'], + layout: 'radio', + direction: 'horizontal', + }, + initialValue: 'external', + validation: Rule => Rule.required(), + }), + defineField({ + name: 'external', + type: 'string', + title: 'URL', + description: 'Specify the full URL. Ensure it starts with "https://" and is a valid URL.', + hidden: ({ parent }) => parent?.type !== 'external', + validation: (Rule) => [ + Rule.custom((value, { parent }) => { + const type = (parent as { type?: string })?.type; + if (type === 'external') { + if (!value) return "URL is required"; + if (!value.startsWith('https://')) { + return 'External link must start with the "https://" protocol'; + } + if (!isValidUrl(value)) return 'Invalid URL'; + } + return true; + }), + ], + }), + defineField({ + name: 'internal', + type: 'reference', + title: 'Internal reference to page', + description: 'Select an internal page to link to.', + to: InternalLinkableTypes, + options: { + disableNew: true, + filter: 'defined(slug.current)', + }, + hidden: ({ parent }) => parent?.type !== 'internal', + validation: (rule) => [ + rule.custom((value, { parent }) => { + const type = (parent as { type?: string })?.type; + if (type === 'internal' && !value?._ref) return "You have to choose internal page to link to."; + return true; + }), + ], + }), + ], + preview: { + select: { + title: 'text', + theme: 'theme', + type: 'type', + external: 'external', + internal: 'internal.slug.current', + }, + prepare({ title, theme, type, external, internal }) { + return { + title: `${title}`, + subtitle: type === 'external' ? external : internal, + media: () => + + {theme === 'primary' ? 'Primary button' : 'Secondary button'} + + + } + placement="top" + portal + > + {icon()} + + }; + }, + }, +}); diff --git a/apps/sanity/schema/ui/sectionId.tsx b/apps/sanity/schema/ui/sectionId.tsx new file mode 100644 index 0000000..ae1bc1f --- /dev/null +++ b/apps/sanity/schema/ui/sectionId.tsx @@ -0,0 +1,28 @@ +import { defineField } from "sanity"; + +type Props = { + _key: string; + sectionId?: string +} + +export default [ + defineField({ + name: 'sectionId', + type: 'string', + title: 'Section ID (optional)', + description: 'The Section ID is a unique identifier used to link to specific sections of the page.', + validation: Rule => [ + Rule.custom((value, context) => { + if (!value) return true; + if (value?.startsWith('#')) return 'Section ID cannot start with a "#" symbol. It has to be just a string.'; + const components = (context.document?.components || []) as Props[]; + const currentComponent = context.parent as Props + const isDuplicate = components.some(component => + component._key !== currentComponent._key && component.sectionId === value + ); + if (isDuplicate) return "This section ID is already used in another component. Section IDs must be unique."; + return true; + }) + ] + }), +] diff --git a/apps/sanity/schema/ui/seo.tsx b/apps/sanity/schema/ui/seo.tsx new file mode 100644 index 0000000..8d99aca --- /dev/null +++ b/apps/sanity/schema/ui/seo.tsx @@ -0,0 +1,41 @@ +import { defineField, defineType } from "sanity" + +export default defineType({ + name: 'seo', + title: 'SEO', + type: 'object', + validation: Rule => Rule.required(), + fields: [ + defineField({ + name: 'title', + type: 'string', + title: 'Title', + description: 'Title is visible in the browser tab and in search engines.', + validation: Rule => [ + Rule.max(70).warning('The field should not be longer than 70 characters.'), + Rule.required() + ], + }), + defineField({ + name: 'description', + type: 'text', + title: 'Description', + rows: 4, + description: 'Description is visible in search engines and when sharing the page on social media.', + validation: Rule => [ + Rule.max(165).warning('The field should not be longer than 165 characters.'), + Rule.required() + ], + }), + defineField({ + name: 'img', + type: 'image', + title: 'Social Share Image (optional)', + description: ( + <> + Social Share Image is visible when sharing website on social media. The dimensions of the image should be 1200x630px. For maximum compatibility, use JPG or PNG formats, as WebP may not be supported everywhere. If this field is left empty, the image defined in global settings will be used. + + ), + }), + ], +}); diff --git a/apps/sanity/static/.gitkeep b/apps/sanity/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/sanity/structure/create-collection.tsx b/apps/sanity/structure/create-collection.tsx new file mode 100644 index 0000000..0e2b299 --- /dev/null +++ b/apps/sanity/structure/create-collection.tsx @@ -0,0 +1,30 @@ +import type { StructureBuilder } from "sanity/structure"; +import { schemaTypes } from "./schema-types"; +import { Preview } from "./preview"; +import { TYPES_TO_EXCLUDE_PREVIEWS } from "."; + +export const createCollection = (S: StructureBuilder, name: string) => { + const { title, icon } = schemaTypes.find(item => item.name === name) as { title: string, icon: React.ReactNode }; + return S.listItem() + .id(name) + .title(title) + .icon(icon) + .child( + S.documentTypeList(name) + .title(title) + .child(documentId => + S.document() + .documentId(documentId) + .schemaType(name) + .views([ + S.view.form().title('Editor').icon(() => '🖋️'), + ...(!TYPES_TO_EXCLUDE_PREVIEWS.includes(name) ? [ + S.view + .component(Preview) + .title('Preview') + .icon(() => '👀') + ] : []), + ]) + ) + ); +}; diff --git a/apps/sanity/structure/create-singleton.tsx b/apps/sanity/structure/create-singleton.tsx new file mode 100644 index 0000000..adc6590 --- /dev/null +++ b/apps/sanity/structure/create-singleton.tsx @@ -0,0 +1,27 @@ +import type { StructureBuilder } from 'sanity/structure' +import { schemaTypes } from "./schema-types"; +import { TYPES_TO_EXCLUDE_PREVIEWS } from '.'; +import { Preview } from './preview'; + +export const createSingleton = (S: StructureBuilder, name: string) => { + const { title, icon } = schemaTypes.find(item => item.name === name) as { title: string, icon: React.ReactNode }; + return S.listItem() + .id(name) + .title(title) + .icon(icon) + .child(documentId => + S.document() + .documentId(documentId) + .schemaType(name) + .title(title) + .views([ + S.view.form().title('Editor').icon(() => '🖋️'), + ...(!TYPES_TO_EXCLUDE_PREVIEWS.includes(name) ? [ + S.view + .component(Preview) + .title('Preview') + .icon(() => '👀') + ] : []), + ]) + ) +}; diff --git a/apps/sanity/structure/index.tsx b/apps/sanity/structure/index.tsx new file mode 100644 index 0000000..93fa966 --- /dev/null +++ b/apps/sanity/structure/index.tsx @@ -0,0 +1,18 @@ +import type { StructureResolver } from 'sanity/structure' +import { createSingleton } from './create-singleton' +import { createCollection } from './create-collection'; + +export const TYPES_TO_EXCLUDE_PREVIEWS = ['global', 'redirects', 'Faq_Collection']; + +export const structure: StructureResolver = (S) => + S.list() + .id('root') + .title('Zawartość') + .items([ + createSingleton(S, "global"), + createSingleton(S, "redirects"), + S.divider(), + createSingleton(S, "Index_Page"), + S.divider(), + createCollection(S, "Faq_Collection"), + ]) diff --git a/apps/sanity/structure/internal-linkable-types.ts b/apps/sanity/structure/internal-linkable-types.ts new file mode 100644 index 0000000..8ea204a --- /dev/null +++ b/apps/sanity/structure/internal-linkable-types.ts @@ -0,0 +1,9 @@ +/** + * Array of objects defining the types of documents that can be linked internally. + * Each object contains a 'type' property specifying the document type. + * + * @type {{type: string}[]} + */ +export const InternalLinkableTypes: { type: string }[] = [ + { type: 'Index_Page' }, +]; diff --git a/apps/sanity/structure/preview.tsx b/apps/sanity/structure/preview.tsx new file mode 100644 index 0000000..f6f194e --- /dev/null +++ b/apps/sanity/structure/preview.tsx @@ -0,0 +1,13 @@ +import { Iframe, type IframeProps } from "sanity-plugin-iframe-pane"; +import { PREVIEW_DEPLOYMENT_DOMAIN } from "../constants"; + +export const Preview = ({ document }: { document: IframeProps['document'] }) => { + const slug = (document.displayed.slug as { current?: string })?.current; + if (!slug) return
🛑 Preview not available: The slug is missing
; + return