export function screenPrintingCalculator(form: FormTs) {
// Blank garment prices per piece (quantity breaks never reduce these)
const garmentPrices: Record<string, number> = {
'cotton': 4.5,
'premium': 7,
'hoodie': 18
};
const garmentNames: Record<string, string> = {
'cotton': 'Cotton tee',
'premium': 'Premium tee',
'hoodie': 'Hoodie'
};
// Printing rates per garment
const printing = {
firstColor: 1.5, // first color at each active location
extraColor: 0.6, // each additional color pass at the same location (underbase counts as one)
screenSetup: 25 // once per screen (color and location, underbases included)
};
// Quantity breaks on printing only (they replace, not stack)
const quantityBreaks = {
from50Percent: 10,
from100Percent: 20
};
// Job-level services
const jobServices = {
artworkFee: 45, // one cleanup charge per batch
rushPercent: 25, // of garments plus discounted printing
minimumOrder: 150, // minimum production value before shipping
shippingFee: 20 // pickup is free
};
const garmentPrice = (id: string) => garmentPrices[id] ?? 0;
const garmentName = (id: string) => garmentNames[id] ?? id;
const money = (value: number) => {
const fixed = Math.abs(value).toFixed(2);
const parts = fixed.split('.');
const whole = (parts[0] ?? '0').replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return (value < 0 ? '-' : '') + '$' + whole + '.' + (parts[1] ?? '00');
};
const cents = (value: number) => Math.round(value * 100) / 100;
const clamp = (value: number | null | undefined, fallback: number, min: number, max: number) => {
const n = typeof value === 'number' ? Math.floor(value) : fallback;
return Math.min(max, Math.max(min, n));
};
const headerStyles: CustomStyles = { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' };
const stepStyles: CustomStyles = { 'font-size': '1.1rem', 'font-weight': '600', 'color': '#1e293b' };
const hintStyles: CustomStyles = { 'font-size': '0.9rem', 'color': '#64748b' };
const infoStyles: CustomStyles = { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' };
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Screen Printing Estimate',
customStyles: headerStyles
});
});
form.addRow(row => {
row.addTextPanel('intro', {
computedValue: () => 'Choose a garment, print locations and ink colors. One garment style and one shared design per batch; we confirm artwork, sizes and stock before production.',
customStyles: hintStyles
});
});
form.addSpacer({ height: 10 });
const pages = form.addPages('steps');
// Step 1: Your batch
const jobPage = pages.addPage('job');
jobPage.addRow(row => {
row.addTextPanel('jobTitle', {
computedValue: () => 'Step 1 of 4: Your batch',
customStyles: stepStyles
});
});
const jobSection = jobPage.addSubform('batch', { title: 'Garment and quantity', isCollapsible: false });
jobSection.addRow(row => {
row.addRadioButton('garment', {
label: 'Garment style',
options: [
{ id: 'cotton', name: 'Cotton tee - ' + money(garmentPrice('cotton')) + ' blank / piece' },
{ id: 'premium', name: 'Premium tee - ' + money(garmentPrice('premium')) + ' blank / piece' },
{ id: 'hoodie', name: 'Hoodie - ' + money(garmentPrice('hoodie')) + ' blank / piece' }
],
defaultValue: 'cotton',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addInteger('quantity', {
label: 'How many garments?',
min: 12,
max: 10000,
defaultValue: 50,
placeholder: 'e.g. 50',
tooltip: 'One garment style and one design. Size mix and stock are confirmed after your request.',
isRequired: true
}, '1fr');
});
jobSection.addRow(row => {
row.addTextPanel('breaksInfo', {
computedValue: () => 'Quantity breaks on printing: ' + quantityBreaks.from50Percent + '% off from 50 garments, '
+ quantityBreaks.from100Percent + '% off from 100 garments. Blank garments, setup and artwork stay unchanged.',
customStyles: infoStyles
});
});
const garmentKey = form.computedValue(() => {
const id = jobSection.radioButton('garment')?.value();
return id && garmentPrices[id] !== undefined ? id : 'cotton';
});
const quantity = form.computedValue(() => clamp(jobSection.integer('quantity')?.value(), 50, 12, 10000));
// Step 2: Your print
const printPage = pages.addPage('print');
printPage.addRow(row => {
row.addTextPanel('printTitle', {
computedValue: () => 'Step 2 of 4: Your print',
customStyles: stepStyles
});
});
const printSection = printPage.addSubform('design', { title: 'Colors and locations', isCollapsible: false });
printSection.addRow(row => {
row.addInteger('frontColors', {
label: 'Front ink colors',
min: 1,
max: 6,
defaultValue: 2,
tooltip: 'Design colors only; the dark-garment underbase is added separately.',
isRequired: true
}, '1fr');
row.addInteger('backColors', {
label: 'Back ink colors',
min: 0,
max: 6,
defaultValue: 0,
tooltip: 'Leave at 0 for no back print.',
isRequired: true
}, '1fr');
});
printSection.addRow(row => {
row.addCheckbox('underbase', {
label: 'Dark garments requiring an underbase (+1 screen and +1 color pass at each active location)',
defaultValue: false
});
});
printSection.addRow(row => {
row.addTextPanel('scopeInfo', {
computedValue: () => 'One design across the batch. Sleeve prints, individual names, mixed garment styles and special inks need a separate quote. Send your artwork by replying to the estimate email.',
customStyles: infoStyles
});
});
const frontColors = form.computedValue(() => clamp(printSection.integer('frontColors')?.value(), 2, 1, 6));
const backColors = form.computedValue(() => clamp(printSection.integer('backColors')?.value(), 0, 0, 6));
const underbase = form.computedValue(() => printSection.checkbox('underbase')?.value() === true);
const locations = form.computedValue(() => backColors() > 0 ? 2 : 1);
const screens = form.computedValue(() => frontColors() + backColors() + (underbase() ? locations() : 0));
printSection.addRow(row => {
row.addTextPanel('screensInfo', {
computedValue: () => 'Screens to set up: ' + screens() + ' (' + money(printing.screenSetup) + ' each)'
+ (underbase() ? ', including ' + locations() + ' underbase screen' + (locations() > 1 ? 's' : '') : ''),
customStyles: hintStyles
});
});
// Step 3: Job services
const servicesPage = pages.addPage('services');
servicesPage.addRow(row => {
row.addTextPanel('servicesTitle', {
computedValue: () => 'Step 3 of 4: Job services',
customStyles: stepStyles
});
});
const servicesSection = servicesPage.addSubform('services', { title: 'Artwork and delivery', isCollapsible: false });
servicesSection.addRow(row => {
row.addCheckbox('artwork', {
label: 'Artwork cleanup (+' + money(jobServices.artworkFee) + ' per job)',
tooltip: 'One job-level charge. Complex redesigns are quoted after review.',
defaultValue: false
}, '1fr');
row.addCheckbox('rush', {
label: 'Rush production (+' + jobServices.rushPercent + '% on garments and printing)',
tooltip: 'An estimate surcharge, not a confirmed deadline. We check availability before accepting.',
defaultValue: false
}, '1fr');
});
servicesSection.addRow(row => {
row.addRadioButton('delivery', {
label: 'Delivery',
options: [
{ id: 'pickup', name: 'Shop pickup (free)' },
{ id: 'shipping', name: 'Ship to me (+' + money(jobServices.shippingFee) + ')' }
],
defaultValue: 'pickup',
orientation: 'vertical',
isRequired: true
});
});
const artworkRequested = form.computedValue(() => servicesSection.checkbox('artwork')?.value() === true);
const rushRequested = form.computedValue(() => servicesSection.checkbox('rush')?.value() === true);
const shipping = form.computedValue(() => servicesSection.radioButton('delivery')?.value() === 'shipping');
// Pricing
const discountPercent = form.computedValue(() => {
if (quantity() >= 100) return quantityBreaks.from100Percent;
if (quantity() >= 50) return quantityBreaks.from50Percent;
return 0;
});
const garmentAmount = form.computedValue(() => cents(quantity() * garmentPrice(garmentKey())));
const printingList = form.computedValue(() => cents(quantity() * (locations() * printing.firstColor + (screens() - locations()) * printing.extraColor)));
const printingDiscount = form.computedValue(() => cents(printingList() * discountPercent() / 100));
const printingAmount = form.computedValue(() => cents(printingList() - printingDiscount()));
const setupAmount = form.computedValue(() => cents(screens() * printing.screenSetup));
const artworkAmount = form.computedValue(() => artworkRequested() ? jobServices.artworkFee : 0);
const rushAmount = form.computedValue(() => rushRequested() ? cents((garmentAmount() + printingAmount()) * jobServices.rushPercent / 100) : 0);
const production = form.computedValue(() => cents(garmentAmount() + printingAmount() + setupAmount() + artworkAmount() + rushAmount()));
const minimumAdjustment = form.computedValue(() => cents(Math.max(0, jobServices.minimumOrder - production())));
const shippingAmount = form.computedValue(() => shipping() ? jobServices.shippingFee : 0);
const total = form.computedValue(() => cents(production() + minimumAdjustment() + shippingAmount()));
const perPiece = form.computedValue(() => cents(total() / quantity()));
const batchSpec = () => quantity() + ' x ' + garmentName(garmentKey()) + '; front ' + frontColors() + ' color' + (frontColors() === 1 ? '' : 's')
+ '; back ' + backColors() + ' color' + (backColors() === 1 ? '' : 's') + '; underbase ' + (underbase() ? 'at each location' : 'none');
const priceRows = (): { label: string; amount: number }[] => {
const rows: { label: string; amount: number }[] = [
{ label: 'Blank garments (' + quantity() + ' x ' + money(garmentPrice(garmentKey())) + ')', amount: garmentAmount() },
{ label: 'Printing before quantity break', amount: printingList() }
];
if (printingDiscount() > 0) rows.push({ label: 'Printing quantity break (' + discountPercent() + '%)', amount: -printingDiscount() });
rows.push({ label: 'Screen setup (' + screens() + ' screens)', amount: setupAmount() });
if (artworkAmount() > 0) rows.push({ label: 'Artwork cleanup', amount: artworkAmount() });
if (rushAmount() > 0) rows.push({ label: 'Rush production (' + jobServices.rushPercent + '%)', amount: rushAmount() });
if (minimumAdjustment() > 0) rows.push({ label: 'Minimum production adjustment', amount: minimumAdjustment() });
rows.push({ label: shipping() ? 'Shipping' : 'Pickup', amount: shippingAmount() });
return rows;
};
// Step 4: Your estimate
const resultPage = pages.addPage('result');
resultPage.addRow(row => {
row.addTextPanel('resultTitle', {
computedValue: () => 'Step 4 of 4: Your estimate',
customStyles: stepStyles
});
});
const summarySection = resultPage.addSubform('summary', { title: 'Your Estimate', isCollapsible: false });
summarySection.addRow(row => {
row.addPriceDisplay('total', {
label: 'Estimated order total',
computedValue: () => total(),
currency: '$',
decimals: 2,
variant: 'large'
}, '2fr');
row.addPriceDisplay('perPiece', {
label: 'Average per garment',
computedValue: () => perPiece(),
currency: '$',
decimals: 2,
variant: 'highlight'
}, '1fr');
});
summarySection.addRow(row => {
row.addTextPanel('totalNote', {
computedValue: () => 'Illustrative rates, before applicable tax; subject to artwork, stock and production review.',
customStyles: { 'font-size': '0.85rem', 'color': '#64748b', 'font-style': 'italic' }
});
});
const breakdownSection = resultPage.addSubform('breakdown', { title: 'What goes into the estimate', isCollapsible: false });
breakdownSection.addRow(row => {
row.addTextPanel('batchSpec', {
computedValue: () => 'Batch: ' + batchSpec(),
customStyles: hintStyles
});
});
breakdownSection.addRow(row => {
row.addPriceDisplay('garments', {
label: 'Blank garments',
computedValue: () => garmentAmount(),
currency: '$',
decimals: 2
}, '1fr');
row.addPriceDisplay('printing', {
label: () => discountPercent() > 0 ? 'Printing (' + discountPercent() + '% quantity break applied)' : 'Printing',
computedValue: () => printingAmount(),
currency: '$',
decimals: 2,
originalPrice: () => printingDiscount() > 0 ? printingList() : undefined
}, '1fr');
});
breakdownSection.addRow(row => {
row.addPriceDisplay('setup', {
label: () => 'Screen setup (' + screens() + ' screens)',
computedValue: () => setupAmount(),
currency: '$',
decimals: 2,
prefix: '+'
}, '1fr');
row.addPriceDisplay('artwork', {
label: 'Artwork cleanup',
computedValue: () => artworkAmount(),
currency: '$',
decimals: 2,
prefix: '+',
isVisible: () => artworkAmount() > 0
}, '1fr');
});
breakdownSection.addRow(row => {
row.addPriceDisplay('rush', {
label: 'Rush production',
computedValue: () => rushAmount(),
currency: '$',
decimals: 2,
prefix: '+',
isVisible: () => rushAmount() > 0
}, '1fr');
row.addPriceDisplay('minimum', {
label: 'Minimum production adjustment',
computedValue: () => minimumAdjustment(),
currency: '$',
decimals: 2,
prefix: '+',
isVisible: () => minimumAdjustment() > 0
}, '1fr');
row.addPriceDisplay('shipping', {
label: 'Shipping',
computedValue: () => shippingAmount(),
currency: '$',
decimals: 2,
prefix: '+',
isVisible: () => shippingAmount() > 0
}, '1fr');
});
breakdownSection.addRow(row => {
row.addTextPanel('minimumNote', {
computedValue: () => 'Our minimum production value is ' + money(jobServices.minimumOrder) + '; the adjustment adds only the difference needed to reach it.',
customStyles: hintStyles,
isVisible: () => minimumAdjustment() > 0
});
});
// Contact
const contactSection = resultPage.addSubform('contact', { title: 'Ask the shop to review this job', isCollapsible: false });
contactSection.addRow(row => {
row.addTextbox('name', {
label: 'Name',
placeholder: 'John Smith',
isRequired: true
}, '1fr');
row.addEmail('email', {
label: 'Email for your estimate',
placeholder: 'john@example.com',
isRequired: true
}, '1fr');
});
contactSection.addRow(row => {
row.addTextbox('company', {
label: 'Company or team (optional)',
placeholder: 'Acme Running Club'
}, '1fr');
row.addTextbox('phone', {
label: 'Phone (optional)',
placeholder: '(555) 123-4567'
}, '1fr');
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Sizes, garment color and target date',
placeholder: 'Size mix, artwork notes, shipping destination and the date you need the garments.',
rows: 3,
maxLength: 1500
});
});
form.configureSubmitButton({
label: 'Request this estimate'
});
form.configureCompletionScreen({
type: 'text',
title: 'Your request is ready for review',
message: 'The shop confirms garment stock, artwork, sizes, tax and the production date before accepting an order. Reply to the estimate email with your artwork.'
});
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: 'screen-printing-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download estimate (PDF)',
header: {
title: 'Screen printing estimate',
subtitle: 'One garment style and one shared design per batch'
},
footer: {
text: 'Illustrative estimate before applicable tax. Not an accepted order.',
showPageNumbers: true
}
});
pdf.addSection('Your batch', section => {
section.addText(batchSpec());
section.addRow(row => {
row.addField('Screens including underbase', String(screens()));
row.addField('Artwork', artworkRequested() ? 'Cleanup requested' : 'Ready for review');
});
section.addRow(row => {
row.addField('Production', rushRequested() ? 'Rush requested, subject to confirmation' : 'Standard');
row.addField('Delivery', shipping() ? 'Shipping' : 'Pickup');
});
});
pdf.addSection('Estimated cost', section => {
const rows = priceRows().map(row => [row.label, money(row.amount)]);
rows.push(['Estimated total before tax', money(total())]);
section.addTable(['Item', 'Amount'], rows);
section.addText('Average per garment: ' + money(perPiece()) + '. Includes setup and selected job services; unit average is rounded to cents.');
});
pdf.addSection('Contact', section => {
section.addRow(row => {
row.addField('Name', contactSection.textbox('name')?.value() || '');
row.addField('Email', contactSection.email('email')?.value() || '');
});
section.addRow(row => {
row.addField('Company', contactSection.textbox('company')?.value() || '-');
row.addField('Phone', contactSection.textbox('phone')?.value() || '-');
});
});
const notes = contactSection.textarea('notes')?.value();
if (notes) {
pdf.addSection('Job notes', section => section.addText(notes));
}
pdf.addSection('Before production', section => {
section.addText('Illustrative estimate, not an accepted order. The shop confirms artwork, size mix, garment availability, delivery, applicable tax and production timing. Send artwork by replying to the estimate email. Mixed styles, sleeve prints, personalization and special inks require a separate quote.');
});
});
}